Skip to main content

dbmcp_sqlite/tools/
list_tables.rs

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