1use 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
14pub(crate) struct ListTablesTool;
16
17impl ListTablesTool {
18 const NAME: &'static str = "listTables";
19 const TITLE: &'static str = "List Tables";
20 const DESCRIPTION: &'static str = r#"List tables in a database, optionally filtered and/or with full metadata.
21
22<usecase>
23Use when:
24- Exploring a database to find relevant tables (brief mode, default).
25- Searching for a table by partial name (pass `search`).
26- Inspecting a table's columns, constraints, indexes, and triggers before writing a query (pass `detailed: true`). This supersedes the legacy `getTableSchema` tool.
27</usecase>
28
29<parameters>
30- `database` — Database to target. Defaults to the active database.
31- `cursor` — Opaque pagination cursor; echo the prior response's `nextCursor`.
32- `search` — Case-insensitive filter on table names via `ILIKE`. `%` matches any sequence; `_` matches a single character.
33- `detailed` — When `true`, returns full metadata objects keyed by table name instead of bare name strings. Default `false`.
34</parameters>
35
36<examples>
37✓ "What tables are in mydb?" → listTables(database="mydb")
38✓ "Find the orders table" → listTables(search="order")
39✓ "What columns does orders have?" → listTables(search="orders", detailed=true)
40</examples>
41
42<what_it_returns>
43Brief mode (default): a sorted JSON array of table-name strings, e.g. `["customers", "orders"]`.
44Detailed 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": "public", "kind": "TABLE", …}}`.
45</what_it_returns>
46
47<pagination>
48Paginated. 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.
49</pagination>"#;
50}
51
52impl ToolBase for ListTablesTool {
53 type Parameter = ListTablesRequest;
54 type Output = ListTablesResponse;
55 type Error = ErrorData;
56
57 fn name() -> Cow<'static, str> {
58 Self::NAME.into()
59 }
60
61 fn title() -> Option<String> {
62 Some(Self::TITLE.into())
63 }
64
65 fn description() -> Option<Cow<'static, str>> {
66 Some(Self::DESCRIPTION.into())
67 }
68
69 fn annotations() -> Option<ToolAnnotations> {
70 Some(
71 ToolAnnotations::new()
72 .read_only(true)
73 .destructive(false)
74 .idempotent(true)
75 .open_world(false),
76 )
77 }
78}
79
80impl AsyncTool<PostgresHandler> for ListTablesTool {
81 async fn invoke(handler: &PostgresHandler, params: Self::Parameter) -> Result<Self::Output, Self::Error> {
82 handler.list_tables(params).await
83 }
84}
85
86const BRIEF_SQL: &str = r"
88 SELECT tablename
89 FROM pg_tables
90 WHERE schemaname = 'public'
91 AND ($1::text IS NULL OR tablename ILIKE '%' || $1 || '%')
92 ORDER BY tablename
93 LIMIT $2 OFFSET $3";
94
95const DETAILED_SQL: &str = r"
100 WITH table_info AS (
101 SELECT
102 t.oid AS table_oid,
103 ns.nspname AS schema_name,
104 t.relname AS table_name,
105 pg_get_userbyid(t.relowner) AS table_owner,
106 obj_description(t.oid, 'pg_class') AS table_comment,
107 t.relkind AS object_kind
108 FROM pg_class t
109 JOIN pg_namespace ns ON ns.oid = t.relnamespace
110 WHERE t.relkind IN ('r', 'p')
111 AND ns.nspname = 'public'
112 AND ($1::text IS NULL OR t.relname ILIKE '%' || $1 || '%')
113 ORDER BY t.relname
114 LIMIT $2 OFFSET $3
115 ),
116 columns_info AS (
117 SELECT
118 att.attrelid AS table_oid,
119 att.attname AS column_name,
120 format_type(att.atttypid, att.atttypmod) AS data_type,
121 att.attnum AS ordinal_position,
122 NOT att.attnotnull AS nullable,
123 pg_get_expr(ad.adbin, ad.adrelid) AS column_default,
124 col_description(att.attrelid, att.attnum) AS column_comment
125 FROM pg_attribute att
126 LEFT JOIN pg_attrdef ad ON att.attrelid = ad.adrelid AND att.attnum = ad.adnum
127 JOIN table_info ti ON att.attrelid = ti.table_oid
128 WHERE att.attnum > 0 AND NOT att.attisdropped
129 ),
130 constraints_info AS (
131 SELECT
132 con.conrelid AS table_oid,
133 con.conname AS constraint_name,
134 pg_get_constraintdef(con.oid) AS constraint_definition,
135 CASE con.contype
136 WHEN 'p' THEN 'PRIMARY KEY'
137 WHEN 'f' THEN 'FOREIGN KEY'
138 WHEN 'u' THEN 'UNIQUE'
139 WHEN 'c' THEN 'CHECK'
140 ELSE con.contype::text
141 END AS constraint_type,
142 COALESCE((
143 SELECT array_agg(att.attname ORDER BY u.attposition)
144 FROM unnest(con.conkey) WITH ORDINALITY AS u(attnum, attposition)
145 JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = u.attnum
146 ), ARRAY[]::name[]) AS constraint_columns,
147 CASE WHEN con.confrelid <> 0
148 THEN (con.confrelid::regclass)::text
149 ELSE NULL END AS referenced_table,
150 (
151 SELECT array_agg(att.attname ORDER BY u.attposition)
152 FROM unnest(con.confkey) WITH ORDINALITY AS u(attnum, attposition)
153 JOIN pg_attribute att ON att.attrelid = con.confrelid AND att.attnum = u.attnum
154 WHERE con.contype = 'f'
155 ) AS referenced_columns
156 FROM pg_constraint con
157 JOIN table_info ti ON con.conrelid = ti.table_oid
158 WHERE con.contype IN ('p', 'f', 'u', 'c')
159 ),
160 indexes_info AS (
161 SELECT
162 idx.indrelid AS table_oid,
163 ic.relname AS index_name,
164 pg_get_indexdef(idx.indexrelid) AS index_definition,
165 idx.indisunique AS is_unique,
166 idx.indisprimary AS is_primary,
167 am.amname AS index_method,
168 (
169 SELECT array_agg(att.attname ORDER BY u.ord)
170 FROM unnest(idx.indkey::int[]) WITH ORDINALITY AS u(colidx, ord)
171 LEFT JOIN pg_attribute att ON att.attrelid = idx.indrelid AND att.attnum = u.colidx
172 WHERE u.colidx <> 0
173 ) AS index_columns
174 FROM pg_index idx
175 JOIN pg_class ic ON ic.oid = idx.indexrelid
176 JOIN pg_am am ON am.oid = ic.relam
177 JOIN table_info ti ON idx.indrelid = ti.table_oid
178 ),
179 triggers_info AS (
180 SELECT
181 tg.tgrelid AS table_oid,
182 tg.tgname AS trigger_name,
183 pg_get_triggerdef(tg.oid) AS trigger_definition,
184 tg.tgenabled::text AS trigger_enabled
185 FROM pg_trigger tg
186 JOIN table_info ti ON tg.tgrelid = ti.table_oid
187 WHERE NOT tg.tgisinternal
188 )
189 SELECT ti.table_name AS name, json_build_object(
190 'schema', ti.schema_name,
191 'kind', CASE ti.object_kind
192 WHEN 'r' THEN 'TABLE'
193 WHEN 'p' THEN 'PARTITIONED_TABLE'
194 ELSE ti.object_kind::text
195 END,
196 'owner', ti.table_owner,
197 'comment', ti.table_comment,
198 'columns', COALESCE((
199 SELECT json_agg(json_build_object(
200 'name', ci.column_name,
201 'dataType', ci.data_type,
202 'ordinalPosition', ci.ordinal_position,
203 'nullable', ci.nullable,
204 'default', ci.column_default,
205 'comment', ci.column_comment
206 ) ORDER BY ci.ordinal_position)
207 FROM columns_info ci WHERE ci.table_oid = ti.table_oid
208 ), '[]'::json),
209 'constraints', COALESCE((
210 SELECT json_agg(json_build_object(
211 'name', cons.constraint_name,
212 'type', cons.constraint_type,
213 'columns', cons.constraint_columns,
214 'definition', cons.constraint_definition,
215 'referencedTable', cons.referenced_table,
216 'referencedColumns', cons.referenced_columns
217 ))
218 FROM constraints_info cons WHERE cons.table_oid = ti.table_oid
219 ), '[]'::json),
220 'indexes', COALESCE((
221 SELECT json_agg(json_build_object(
222 'name', ii.index_name,
223 'columns', ii.index_columns,
224 'unique', ii.is_unique,
225 'primary', ii.is_primary,
226 'method', ii.index_method,
227 'definition', ii.index_definition
228 ))
229 FROM indexes_info ii WHERE ii.table_oid = ti.table_oid
230 ), '[]'::json),
231 'triggers', COALESCE((
232 SELECT json_agg(json_build_object(
233 'name', tri.trigger_name,
234 'definition', tri.trigger_definition,
235 'enabled', tri.trigger_enabled
236 ))
237 FROM triggers_info tri WHERE tri.table_oid = ti.table_oid
238 ), '[]'::json)
239 ) AS entry
240 FROM table_info ti
241 ORDER BY ti.schema_name, ti.table_name";
242
243impl PostgresHandler {
244 pub async fn list_tables(
252 &self,
253 ListTablesRequest {
254 database,
255 cursor,
256 search,
257 detailed,
258 }: ListTablesRequest,
259 ) -> Result<ListTablesResponse, ErrorData> {
260 let database = database.as_deref().map(str::trim).filter(|s| !s.is_empty());
263 let pattern = search.as_deref().map(str::trim).filter(|s| !s.is_empty());
264 let pager = Pager::new(cursor, self.config.page_size);
265
266 if detailed {
267 let rows: Vec<(String, sqlx::types::Json<serde_json::Value>)> = self
268 .connection
269 .fetch(
270 sqlx::query(DETAILED_SQL)
271 .bind(pattern)
272 .bind(pager.limit())
273 .bind(pager.offset()),
274 database,
275 )
276 .await?;
277 let (rows, next_cursor) = pager.paginate(rows);
278 return Ok(ListTablesResponse::detailed(
279 rows.into_iter().map(|(name, json)| (name, json.0)).collect(),
280 next_cursor,
281 ));
282 }
283
284 let rows: Vec<String> = self
285 .connection
286 .fetch_scalar(
287 sqlx::query(BRIEF_SQL)
288 .bind(pattern)
289 .bind(pager.limit())
290 .bind(pager.offset()),
291 database,
292 )
293 .await?;
294 let (tables, next_cursor) = pager.paginate(rows);
295 Ok(ListTablesResponse::brief(tables, next_cursor))
296 }
297}