Skip to main content

dbmcp_postgres/tools/
list_tables.rs

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