Skip to main content

dbmcp_sqlite/tools/
list_tables.rs

1//! MCP tool: `listTables`.
2
3use std::borrow::Cow;
4
5use dbmcp_server::pagination::Pager;
6use dbmcp_sql::Connection as _;
7use rmcp::handler::server::router::tool::{AsyncTool, ToolBase};
8use rmcp::model::{ErrorData, ToolAnnotations};
9
10use crate::SqliteHandler;
11use crate::types::{ListTablesRequest, ListTablesResponse};
12
13/// Marker type for the `listTables` MCP tool.
14pub(crate) struct ListTablesTool;
15
16impl ListTablesTool {
17    const NAME: &'static str = "listTables";
18    const TITLE: &'static str = "List Tables";
19    const DESCRIPTION: &'static str = r#"List tables in the connected database, optionally filtered and/or with full metadata.
20
21<usecase>
22Use when:
23- Exploring a database to find relevant tables (brief mode, default).
24- Searching for a table by partial name (pass `search`).
25- Inspecting a table's columns, constraints, indexes, and triggers before writing a query (pass `detailed: true`). This supersedes the legacy `getTableSchema` workflow.
26</usecase>
27
28<parameters>
29- `cursor` — Opaque pagination cursor; echo the prior response's `nextCursor`.
30- `search` — Case-insensitive filter on table names via `LIKE`. `%` matches any sequence; `_` matches a single character.
31- `detailed` — When `true`, returns full metadata objects keyed by table name instead of bare name strings. Default `false`.
32</parameters>
33
34<examples>
35✓ "What tables are in this database?" → listTables()
36✓ "Find the orders table" → listTables(search="order")
37✓ "What columns does orders have?" → listTables(search="orders", detailed=true)
38</examples>
39
40<what_it_returns>
41Brief mode (default): a sorted JSON array of table-name strings, e.g. `["customers", "orders"]`.
42Detailed mode: a JSON object keyed by table name; each value carries `schema`, `kind`, `owner`, `comment`, `columns`, `constraints`, `indexes`, and `triggers` (the name is the key and is not repeated inside the value). Keys iterate in the same alphabetical order brief mode uses. Example: `{"orders": {"schema": "main", "kind": "TABLE", ...}}`.
43</what_it_returns>
44
45<pagination>
46Paginated. Pass the prior response's `nextCursor` as `cursor` to fetch the next page. The `search` filter must stay the same across pages for cursor continuity.
47</pagination>"#;
48}
49
50impl ToolBase for ListTablesTool {
51    type Parameter = ListTablesRequest;
52    type Output = ListTablesResponse;
53    type Error = ErrorData;
54
55    fn name() -> Cow<'static, str> {
56        Self::NAME.into()
57    }
58
59    fn title() -> Option<String> {
60        Some(Self::TITLE.into())
61    }
62
63    fn description() -> Option<Cow<'static, str>> {
64        Some(Self::DESCRIPTION.into())
65    }
66
67    fn annotations() -> Option<ToolAnnotations> {
68        Some(
69            ToolAnnotations::new()
70                .read_only(true)
71                .destructive(false)
72                .idempotent(true)
73                .open_world(false),
74        )
75    }
76}
77
78impl AsyncTool<SqliteHandler> for ListTablesTool {
79    async fn invoke(handler: &SqliteHandler, params: Self::Parameter) -> Result<Self::Output, Self::Error> {
80        handler.list_tables(params).await
81    }
82}
83
84/// Brief-mode SQL: `pragma_table_list` scan with optional `LIKE` filter.
85///
86/// `pragma_table_list` distinguishes ordinary tables (`type = 'table'`),
87/// virtual tables (`type = 'virtual'`), views (`type = 'view'`), and the
88/// automatically-generated shadow tables that back FTS5 / R-Tree / etc.
89/// (`type = 'shadow'`). Restricting to `('table', 'virtual')` hides shadow
90/// tables from users while still surfacing user-declared virtual tables.
91///
92/// `SQLite`'s `LIKE` operator is case-insensitive for ASCII by default and
93/// `dbmcp` does not toggle the `case_sensitive_like` PRAGMA, so a bare
94/// `LIKE` already matches case-insensitively (a `COLLATE NOCASE` on the
95/// RHS pattern would be a no-op — `LIKE` does not honor right-hand-side
96/// collation; see <https://www.sqlite.org/lang_expr.html>). User-facing
97/// wildcards (`%`, `_`) in `?1` flow straight into LIKE semantics — this
98/// matches the `PostgreSQL` contract established in commit `dbe917f`.
99const BRIEF_SQL: &str = r"
100    SELECT tl.name
101    FROM pragma_table_list tl
102    WHERE tl.schema = 'main'
103      AND tl.type IN ('table', 'virtual')
104      AND tl.name NOT LIKE 'sqlite_%'
105      AND (?1 IS NULL OR tl.name LIKE '%' || ?1 || '%')
106    ORDER BY tl.name
107    LIMIT ?2 OFFSET ?3";
108
109/// Detailed-mode SQL: single CTE joining `sqlite_master` with `pragma_*`
110/// table-valued functions and aggregating each table's columns, constraints,
111/// indexes, and triggers into JSON via `json_object` / `json_group_array`.
112///
113/// `LIMIT`/`OFFSET` are pushed into `table_info` so every downstream CTE scans
114/// at most `page_size + 1` tables, never the full schema.
115///
116/// `SQLite`-specific carve-outs (intentionally silent to clients per FR-012):
117/// CHECK constraints are not exposed by `SQLite`'s catalog, so `constraints[]`
118/// only emits PRIMARY KEY / FOREIGN KEY / UNIQUE entries. `owner`, `comment`,
119/// and column-level `comment` are always `NULL`. `method` is always `btree`,
120/// trigger `enabled` is always `true`, and `indexes[].definition` is either
121/// the raw `sqlite_master.sql` or, for auto-generated indexes with no user
122/// SQL, a synthesised `CREATE [UNIQUE] INDEX …` string so the field is never
123/// null. Columns use 1-based `ordinalPosition` (`cid + 1`) for cross-backend
124/// parity with `PostgreSQL`'s `pg_attribute.attnum`.
125const DETAILED_SQL: &str = r#"
126    WITH table_info AS (
127        SELECT
128            tl.name AS table_name,
129            CASE tl.type
130                WHEN 'virtual' THEN 'VIRTUAL_TABLE'
131                ELSE 'TABLE'
132            END AS kind
133        FROM pragma_table_list tl
134        WHERE tl.schema = 'main'
135          AND tl.type IN ('table', 'virtual')
136          AND tl.name NOT LIKE 'sqlite_%'
137          AND (?1 IS NULL OR tl.name LIKE '%' || ?1 || '%')
138        ORDER BY tl.name
139        LIMIT ?2 OFFSET ?3
140    ),
141    columns_info AS (
142        SELECT
143            ti.table_name,
144            c.cid AS cid,
145            json_object(
146                'name',            c.name,
147                'dataType',        c.type,
148                'ordinalPosition', c.cid + 1,
149                'nullable',        json(CASE WHEN c."notnull" = 0 THEN 'true' ELSE 'false' END),
150                'default',         c.dflt_value,
151                'comment',         NULL
152            ) AS column_json
153        FROM table_info ti, pragma_table_info(ti.table_name) c
154    ),
155    pk_constraints AS (
156        SELECT
157            ti.table_name,
158            json_object(
159                'name',       'PRIMARY',
160                'type',       'PRIMARY KEY',
161                'columns',    json_group_array(c.name),
162                'definition', 'PRIMARY KEY (' || group_concat('"' || c.name || '"', ', ') || ')'
163            ) AS constraint_json
164        FROM table_info ti, pragma_table_info(ti.table_name) c
165        WHERE c.pk > 0
166        GROUP BY ti.table_name
167        HAVING COUNT(c.name) > 0
168    ),
169    fk_constraints AS (
170        SELECT
171            ti.table_name,
172            json_object(
173                'name',              'fk_' || ti.table_name || '_' || f.id,
174                'type',              'FOREIGN KEY',
175                'columns',           json_group_array(f."from"),
176                'definition',        'FOREIGN KEY (' || group_concat('"' || f."from" || '"', ', ')
177                                     || ') REFERENCES "' || f."table" || '"('
178                                     || group_concat('"' || f."to" || '"', ', ') || ')',
179                'referencedTable',   f."table",
180                'referencedColumns', json_group_array(f."to")
181            ) AS constraint_json
182        FROM table_info ti, pragma_foreign_key_list(ti.table_name) f
183        GROUP BY ti.table_name, f.id
184    ),
185    unique_constraints AS (
186        SELECT
187            ti.table_name,
188            json_object(
189                'name',       i.name,
190                'type',       'UNIQUE',
191                'columns',    (SELECT json_group_array(ii.name)
192                               FROM pragma_index_info(i.name) ii),
193                'definition', 'UNIQUE ('
194                              || (SELECT group_concat('"' || ii.name || '"', ', ')
195                                  FROM pragma_index_info(i.name) ii)
196                              || ')'
197            ) AS constraint_json
198        FROM table_info ti, pragma_index_list(ti.table_name) i
199        WHERE i."unique" = 1 AND i.origin <> 'pk'
200    ),
201    all_constraints AS (
202        SELECT table_name, constraint_json FROM pk_constraints
203        UNION ALL
204        SELECT table_name, constraint_json FROM fk_constraints
205        UNION ALL
206        SELECT table_name, constraint_json FROM unique_constraints
207    ),
208    indexes_info AS (
209        SELECT
210            ti.table_name,
211            json_object(
212                'name',       il.name,
213                'columns',    (SELECT json_group_array(ii.name) FROM pragma_index_info(il.name) ii),
214                'unique',     json(CASE WHEN il."unique" = 1 THEN 'true' ELSE 'false' END),
215                'primary',    json(CASE WHEN il.origin = 'pk' THEN 'true' ELSE 'false' END),
216                'method',     'btree',
217                'definition', COALESCE(
218                    (SELECT m2.sql FROM sqlite_master m2 WHERE m2.type = 'index' AND m2.name = il.name),
219                    'CREATE ' || CASE il."unique" WHEN 1 THEN 'UNIQUE INDEX ' ELSE 'INDEX ' END
220                        || '"' || il.name || '" ON "' || ti.table_name || '"('
221                        || (SELECT group_concat('"' || ii.name || '"', ', ') FROM pragma_index_info(il.name) ii)
222                        || ')'
223                )
224            ) AS index_json
225        FROM table_info ti, pragma_index_list(ti.table_name) il
226    ),
227    triggers_info AS (
228        SELECT
229            m.tbl_name AS table_name,
230            json_object(
231                'name',       m.name,
232                'definition', m.sql,
233                'enabled',    json('true')
234            ) AS trigger_json
235        FROM sqlite_master m
236        JOIN table_info ti ON ti.table_name = m.tbl_name
237        WHERE m.type = 'trigger'
238    )
239    SELECT
240        ti.table_name AS name,
241        json_object(
242            'schema',      'main',
243            'kind',        ti.kind,
244            'owner',       NULL,
245            'comment',     NULL,
246            'columns',     COALESCE(
247                               (SELECT json_group_array(json(ci.column_json))
248                                FROM (SELECT column_json
249                                      FROM columns_info
250                                      WHERE table_name = ti.table_name
251                                      ORDER BY cid) ci),
252                               json('[]')),
253            'constraints', COALESCE(
254                               (SELECT json_group_array(json(ac.constraint_json))
255                                FROM all_constraints ac
256                                WHERE ac.table_name = ti.table_name),
257                               json('[]')),
258            'indexes',     COALESCE(
259                               (SELECT json_group_array(json(ii.index_json))
260                                FROM indexes_info ii
261                                WHERE ii.table_name = ti.table_name),
262                               json('[]')),
263            'triggers',    COALESCE(
264                               (SELECT json_group_array(json(tg.trigger_json))
265                                FROM triggers_info tg
266                                WHERE tg.table_name = ti.table_name),
267                               json('[]'))
268        ) AS entry
269    FROM table_info ti
270    ORDER BY ti.table_name"#;
271
272impl SqliteHandler {
273    /// Lists one page of tables in the connected database, optionally filtered and/or detailed.
274    ///
275    /// # Errors
276    ///
277    /// Returns [`ErrorData`] with code `-32602` if `cursor` is malformed, or
278    /// an internal-error [`ErrorData`] if the underlying query fails.
279    pub async fn list_tables(
280        &self,
281        ListTablesRequest {
282            cursor,
283            search,
284            detailed,
285        }: ListTablesRequest,
286    ) -> Result<ListTablesResponse, ErrorData> {
287        let pattern = search.as_deref().map(str::trim).filter(|s| !s.is_empty());
288        let pager = Pager::new(cursor, self.config.page_size);
289
290        if detailed {
291            let rows: Vec<(String, sqlx::types::Json<serde_json::Value>)> = self
292                .connection
293                .fetch(
294                    sqlx::query(DETAILED_SQL)
295                        .bind(pattern)
296                        .bind(pager.limit())
297                        .bind(pager.offset()),
298                    None,
299                )
300                .await?;
301            let (rows, next_cursor) = pager.paginate(rows);
302            return Ok(ListTablesResponse::detailed(
303                rows.into_iter().map(|(name, json)| (name, json.0)).collect(),
304                next_cursor,
305            ));
306        }
307
308        let rows: Vec<String> = self
309            .connection
310            .fetch_scalar(
311                sqlx::query(BRIEF_SQL)
312                    .bind(pattern)
313                    .bind(pager.limit())
314                    .bind(pager.offset()),
315                None,
316            )
317            .await?;
318        let (tables, next_cursor) = pager.paginate(rows);
319        Ok(ListTablesResponse::brief(tables, next_cursor))
320    }
321}