Skip to main content

dbmcp_postgres/tools/
list_tables.rs

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