Skip to main content

dbmcp_postgres/tools/
list_tables.rs

1//! MCP tool: `listTables`.
2
3use dbmcp_server::pagination::{Cursor, Pager};
4
5use super::prelude::*;
6use crate::types::{ListEntriesResponse, ListTablesRequest};
7
8const NAME: &str = "listTables";
9const TITLE: &str = "List Tables";
10const DESCRIPTION_PINNED: &str = include_str!("../../assets/tools/list_tables/pinned.md");
11const DESCRIPTION_UNPINNED: &str = include_str!("../../assets/tools/list_tables/unpinned.md");
12
13fn annotations() -> ToolAnnotations {
14    ToolAnnotations::new()
15        .read_only(true)
16        .destructive(false)
17        .idempotent(true)
18        .open_world(false)
19}
20
21/// Marker type for the `listTables` MCP tool (pinned variant — no `database` field).
22pub(crate) struct PinnedListTablesTool;
23
24impl ToolBase for PinnedListTablesTool {
25    type Parameter = ListTablesRequest;
26    type Output = ListEntriesResponse;
27    type Error = ErrorData;
28
29    fn name() -> Cow<'static, str> {
30        NAME.into()
31    }
32
33    fn title() -> Option<String> {
34        Some(TITLE.into())
35    }
36
37    fn description() -> Option<Cow<'static, str>> {
38        Some(DESCRIPTION_PINNED.into())
39    }
40
41    fn annotations() -> Option<ToolAnnotations> {
42        Some(annotations())
43    }
44
45    fn input_schema() -> Option<Arc<JsonObject>> {
46        Some(input_schema::<Self::Parameter>(true))
47    }
48
49    fn output_schema() -> Option<Arc<JsonObject>> {
50        Some(output_schema::<Self::Output>())
51    }
52}
53
54impl AsyncTool<PostgresHandler> for PinnedListTablesTool {
55    async fn invoke(handler: &PostgresHandler, params: Self::Parameter) -> Result<Self::Output, Self::Error> {
56        handler
57            .list_tables(None, params.cursor, params.search, params.detailed)
58            .await
59    }
60}
61
62/// Marker type for the `listTables` MCP tool (unpinned variant — carries `database`).
63pub(crate) struct UnpinnedListTablesTool;
64
65impl ToolBase for UnpinnedListTablesTool {
66    type Parameter = ListTablesRequest;
67    type Output = ListEntriesResponse;
68    type Error = ErrorData;
69
70    fn name() -> Cow<'static, str> {
71        NAME.into()
72    }
73
74    fn title() -> Option<String> {
75        Some(TITLE.into())
76    }
77
78    fn description() -> Option<Cow<'static, str>> {
79        Some(DESCRIPTION_UNPINNED.into())
80    }
81
82    fn annotations() -> Option<ToolAnnotations> {
83        Some(annotations())
84    }
85
86    fn input_schema() -> Option<Arc<JsonObject>> {
87        Some(input_schema::<Self::Parameter>(false))
88    }
89
90    fn output_schema() -> Option<Arc<JsonObject>> {
91        Some(output_schema::<Self::Output>())
92    }
93}
94
95impl AsyncTool<PostgresHandler> for UnpinnedListTablesTool {
96    async fn invoke(handler: &PostgresHandler, params: Self::Parameter) -> Result<Self::Output, Self::Error> {
97        handler
98            .list_tables(params.database, params.cursor, params.search, params.detailed)
99            .await
100    }
101}
102
103/// Brief-mode SQL: single `pg_tables` scan with optional `ILIKE` filter.
104const BRIEF_SQL: &str = r"
105    SELECT tablename
106    FROM pg_tables
107    WHERE schemaname = 'public'
108      AND ($1::text IS NULL OR tablename ILIKE '%' || $1 || '%')
109    ORDER BY tablename
110    LIMIT $2 OFFSET $3";
111
112/// Detailed-mode SQL: single CTE returning one `json_build_object` per table.
113///
114/// `LIMIT`/`OFFSET` are pushed into `table_info` so every downstream CTE
115/// scans at most `page_size + 1` tables, never the full schema.
116const DETAILED_SQL: &str = r"
117    WITH table_info AS (
118        SELECT
119            t.oid AS table_oid,
120            ns.nspname AS schema_name,
121            t.relname AS table_name,
122            pg_get_userbyid(t.relowner) AS table_owner,
123            obj_description(t.oid, 'pg_class') AS table_comment,
124            t.relkind AS object_kind
125        FROM pg_class t
126        JOIN pg_namespace ns ON ns.oid = t.relnamespace
127        WHERE t.relkind IN ('r', 'p')
128          AND ns.nspname = 'public'
129          AND ($1::text IS NULL OR t.relname ILIKE '%' || $1 || '%')
130        ORDER BY t.relname
131        LIMIT $2 OFFSET $3
132    ),
133    columns_info AS (
134        SELECT
135            att.attrelid AS table_oid,
136            att.attname AS column_name,
137            format_type(att.atttypid, att.atttypmod) AS data_type,
138            att.attnum AS ordinal_position,
139            NOT att.attnotnull AS nullable,
140            pg_get_expr(ad.adbin, ad.adrelid) AS column_default,
141            col_description(att.attrelid, att.attnum) AS column_comment
142        FROM pg_attribute att
143        LEFT JOIN pg_attrdef ad ON att.attrelid = ad.adrelid AND att.attnum = ad.adnum
144        JOIN table_info ti ON att.attrelid = ti.table_oid
145        WHERE att.attnum > 0 AND NOT att.attisdropped
146    ),
147    constraints_info AS (
148        SELECT
149            con.conrelid AS table_oid,
150            con.conname AS constraint_name,
151            pg_get_constraintdef(con.oid) AS constraint_definition,
152            CASE con.contype
153                WHEN 'p' THEN 'PRIMARY KEY'
154                WHEN 'f' THEN 'FOREIGN KEY'
155                WHEN 'u' THEN 'UNIQUE'
156                WHEN 'c' THEN 'CHECK'
157                ELSE con.contype::text
158            END AS constraint_type,
159            COALESCE((
160                SELECT array_agg(att.attname ORDER BY u.attposition)
161                FROM unnest(con.conkey) WITH ORDINALITY AS u(attnum, attposition)
162                JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = u.attnum
163            ), ARRAY[]::name[]) AS constraint_columns,
164            CASE WHEN con.confrelid <> 0
165                 THEN (con.confrelid::regclass)::text
166                 ELSE NULL END AS referenced_table,
167            (
168                SELECT array_agg(att.attname ORDER BY u.attposition)
169                FROM unnest(con.confkey) WITH ORDINALITY AS u(attnum, attposition)
170                JOIN pg_attribute att ON att.attrelid = con.confrelid AND att.attnum = u.attnum
171                WHERE con.contype = 'f'
172            ) AS referenced_columns
173        FROM pg_constraint con
174        JOIN table_info ti ON con.conrelid = ti.table_oid
175        WHERE con.contype IN ('p', 'f', 'u', 'c')
176    ),
177    indexes_info AS (
178        SELECT
179            idx.indrelid AS table_oid,
180            ic.relname AS index_name,
181            pg_get_indexdef(idx.indexrelid) AS index_definition,
182            idx.indisunique AS is_unique,
183            idx.indisprimary AS is_primary,
184            am.amname AS index_method,
185            (
186                SELECT array_agg(att.attname ORDER BY u.ord)
187                FROM unnest(idx.indkey::int[]) WITH ORDINALITY AS u(colidx, ord)
188                LEFT JOIN pg_attribute att ON att.attrelid = idx.indrelid AND att.attnum = u.colidx
189                WHERE u.colidx <> 0
190            ) AS index_columns
191        FROM pg_index idx
192        JOIN pg_class ic ON ic.oid = idx.indexrelid
193        JOIN pg_am am ON am.oid = ic.relam
194        JOIN table_info ti ON idx.indrelid = ti.table_oid
195    ),
196    triggers_info AS (
197        SELECT
198            tg.tgrelid AS table_oid,
199            tg.tgname AS trigger_name,
200            pg_get_triggerdef(tg.oid) AS trigger_definition,
201            tg.tgenabled::text AS trigger_enabled
202        FROM pg_trigger tg
203        JOIN table_info ti ON tg.tgrelid = ti.table_oid
204        WHERE NOT tg.tgisinternal
205    )
206    SELECT ti.table_name AS name, json_build_object(
207        'schema',  ti.schema_name,
208        'kind',    CASE ti.object_kind
209                       WHEN 'r' THEN 'TABLE'
210                       WHEN 'p' THEN 'PARTITIONED_TABLE'
211                       ELSE ti.object_kind::text
212                   END,
213        'owner',   ti.table_owner,
214        'comment', ti.table_comment,
215        'columns', COALESCE((
216            SELECT json_agg(json_build_object(
217                'name',            ci.column_name,
218                'dataType',        ci.data_type,
219                'ordinalPosition', ci.ordinal_position,
220                'nullable',        ci.nullable,
221                'default',         ci.column_default,
222                'comment',         ci.column_comment
223            ) ORDER BY ci.ordinal_position)
224            FROM columns_info ci WHERE ci.table_oid = ti.table_oid
225        ), '[]'::json),
226        'constraints', COALESCE((
227            SELECT json_agg(json_build_object(
228                'name',              cons.constraint_name,
229                'type',              cons.constraint_type,
230                'columns',           cons.constraint_columns,
231                'definition',        cons.constraint_definition,
232                'referencedTable',   cons.referenced_table,
233                'referencedColumns', cons.referenced_columns
234            ))
235            FROM constraints_info cons WHERE cons.table_oid = ti.table_oid
236        ), '[]'::json),
237        'indexes', COALESCE((
238            SELECT json_agg(json_build_object(
239                'name',       ii.index_name,
240                'columns',    ii.index_columns,
241                'unique',     ii.is_unique,
242                'primary',    ii.is_primary,
243                'method',     ii.index_method,
244                'definition', ii.index_definition
245            ))
246            FROM indexes_info ii WHERE ii.table_oid = ti.table_oid
247        ), '[]'::json),
248        'triggers', COALESCE((
249            SELECT json_agg(json_build_object(
250                'name',       tri.trigger_name,
251                'definition', tri.trigger_definition,
252                'enabled',    tri.trigger_enabled
253            ))
254            FROM triggers_info tri WHERE tri.table_oid = ti.table_oid
255        ), '[]'::json)
256    ) AS entry
257    FROM table_info ti
258    ORDER BY ti.schema_name, ti.table_name";
259
260impl PostgresHandler {
261    /// Lists one page of tables in a database, optionally filtered and/or detailed.
262    ///
263    /// # Errors
264    ///
265    /// Returns [`ErrorData`] with code `-32602` if `cursor` is malformed,
266    /// or an internal-error [`ErrorData`] if `database` is invalid
267    /// or the underlying query fails.
268    pub async fn list_tables(
269        &self,
270        database: Option<String>,
271        cursor: Option<Cursor>,
272        search: Option<String>,
273        detailed: bool,
274    ) -> Result<ListEntriesResponse, ErrorData> {
275        // Identifier validation runs inside the connection pool — passing the
276        // trimmed name straight through avoids a redundant round-trip check.
277        let database = database.as_deref().map(str::trim).filter(|s| !s.is_empty());
278        let pattern = search.as_deref().map(str::trim).filter(|s| !s.is_empty());
279        let pager = Pager::new(cursor, self.config.page_size);
280
281        if detailed {
282            let rows: Vec<(String, sqlx::types::Json<serde_json::Value>)> = self
283                .connection
284                .fetch(
285                    sqlx::query(DETAILED_SQL)
286                        .bind(pattern)
287                        .bind(pager.limit())
288                        .bind(pager.offset()),
289                    database,
290                )
291                .await?;
292            let (rows, next_cursor) = pager.paginate(rows);
293            return Ok(ListEntriesResponse::detailed(
294                rows.into_iter().map(|(name, json)| (name, json.0)).collect(),
295                next_cursor,
296            ));
297        }
298
299        let rows: Vec<String> = self
300            .connection
301            .fetch_scalar(
302                sqlx::query(BRIEF_SQL)
303                    .bind(pattern)
304                    .bind(pager.limit())
305                    .bind(pager.offset()),
306                database,
307            )
308            .await?;
309        let (tables, next_cursor) = pager.paginate(rows);
310        Ok(ListEntriesResponse::brief(tables, next_cursor))
311    }
312}