Skip to main content

dbmcp_server/
types.rs

1//! Request and response types for MCP tool parameters.
2//!
3//! Each struct maps to the JSON input or output schema of one MCP tool.
4
5use indexmap::IndexMap;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use crate::pagination::Cursor;
11
12/// Two-shape listing payload: bare names in brief mode, name-keyed metadata in detailed mode.
13///
14/// Shared by [`ListTablesResponse`] and [`ListTriggersResponse`]. Serialises untagged:
15/// brief mode → JSON array of strings, detailed mode → JSON object whose keys are
16/// entity names and whose values are the per-entity metadata.
17#[derive(Debug, Serialize, JsonSchema)]
18#[serde(untagged)]
19pub enum ListEntries {
20    /// Brief mode: sorted array of bare entity-name strings.
21    Brief(Vec<String>),
22    /// Detailed mode: name-keyed map; insertion order matches the SQL `ORDER BY` sort.
23    Detailed(IndexMap<String, Value>),
24}
25
26impl ListEntries {
27    /// Number of entries in the page, regardless of variant.
28    #[must_use]
29    pub fn len(&self) -> usize {
30        match self {
31            Self::Brief(v) => v.len(),
32            Self::Detailed(m) => m.len(),
33        }
34    }
35
36    /// Whether the page contains no entries.
37    #[must_use]
38    pub fn is_empty(&self) -> bool {
39        self.len() == 0
40    }
41
42    /// Returns the brief-mode names as a slice, or `None` in detailed mode.
43    #[must_use]
44    pub fn as_brief(&self) -> Option<&[String]> {
45        if let Self::Brief(v) = self { Some(v) } else { None }
46    }
47
48    /// Returns the detailed-mode map of name → metadata, or `None` in brief mode.
49    #[must_use]
50    pub fn as_detailed(&self) -> Option<&IndexMap<String, Value>> {
51        if let Self::Detailed(m) = self { Some(m) } else { None }
52    }
53
54    /// Consumes the payload and returns the brief-mode names, or `None` in detailed mode.
55    #[must_use]
56    pub fn into_brief(self) -> Option<Vec<String>> {
57        if let Self::Brief(v) = self { Some(v) } else { None }
58    }
59}
60
61/// Response for the `listTables` tool.
62#[derive(Debug, Serialize, JsonSchema)]
63pub struct ListTablesResponse {
64    /// Page of matching tables. Shape depends on the request's `detailed` flag.
65    pub tables: ListEntries,
66    /// Opaque cursor pointing to the next page. Absent when this is the final page.
67    #[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
68    pub next_cursor: Option<Cursor>,
69}
70
71impl ListTablesResponse {
72    /// Builds a brief-mode response from a page of bare table names.
73    #[must_use]
74    pub fn brief(tables: Vec<String>, next_cursor: Option<Cursor>) -> Self {
75        Self {
76            tables: ListEntries::Brief(tables),
77            next_cursor,
78        }
79    }
80
81    /// Builds a detailed-mode response from a page of name → metadata entries.
82    #[must_use]
83    pub fn detailed(tables: IndexMap<String, Value>, next_cursor: Option<Cursor>) -> Self {
84        Self {
85            tables: ListEntries::Detailed(tables),
86            next_cursor,
87        }
88    }
89}
90
91/// Response for tools with no structured return data.
92#[derive(Debug, Serialize, JsonSchema)]
93pub struct MessageResponse {
94    /// Description of the completed operation.
95    pub message: String,
96}
97
98/// Request for the `listDatabases` tool.
99#[derive(Debug, Default, Deserialize, JsonSchema)]
100pub struct ListDatabasesRequest {
101    /// Opaque cursor from a prior response's `nextCursor`; omit for the first page.
102    #[serde(default)]
103    pub cursor: Option<Cursor>,
104}
105
106/// Response for the `listDatabases` tool.
107#[derive(Debug, Serialize, JsonSchema)]
108pub struct ListDatabasesResponse {
109    /// Sorted list of database names for this page.
110    pub databases: Vec<String>,
111    /// Opaque cursor pointing to the next page. Absent when this is the final page.
112    #[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
113    pub next_cursor: Option<Cursor>,
114}
115
116/// Request for the `createDatabase` tool.
117#[derive(Debug, Default, Deserialize, JsonSchema)]
118pub struct CreateDatabaseRequest {
119    /// Name of the database to create. Must be non-empty.
120    pub database: String,
121}
122
123/// Request for the `dropDatabase` tool.
124#[derive(Debug, Default, Deserialize, JsonSchema)]
125pub struct DropDatabaseRequest {
126    /// Name of the database to drop. Must be non-empty.
127    pub database: String,
128}
129
130/// Request for the `listViews` tool.
131#[derive(Debug, Default, Deserialize, JsonSchema)]
132#[schemars(rename = "ListViewsRequest")]
133pub struct PinnedListViewsRequest {
134    /// Opaque cursor from a prior response's `nextCursor`; omit for the first page.
135    #[serde(default)]
136    pub cursor: Option<Cursor>,
137}
138
139/// Request for the `listViews` tool.
140#[derive(Debug, Default, Deserialize, JsonSchema)]
141#[schemars(rename = "ListViewsRequest")]
142pub struct UnpinnedListViewsRequest {
143    #[serde(flatten)]
144    pub inner: PinnedListViewsRequest,
145    /// Database to list views from. Defaults to the active database.
146    #[serde(default)]
147    pub database: Option<String>,
148}
149
150/// Response for the `listViews` tool.
151#[derive(Debug, Serialize, JsonSchema)]
152pub struct ListViewsResponse {
153    /// Page of matching views. Shape depends on the request's `detailed` flag.
154    pub views: ListEntries,
155    /// Opaque cursor pointing to the next page. Absent when this is the final page.
156    #[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
157    pub next_cursor: Option<Cursor>,
158}
159
160impl ListViewsResponse {
161    /// Builds a brief-mode response from a page of bare view names.
162    #[must_use]
163    pub fn brief(views: Vec<String>, next_cursor: Option<Cursor>) -> Self {
164        Self {
165            views: ListEntries::Brief(views),
166            next_cursor,
167        }
168    }
169
170    /// Builds a detailed-mode response from a page of name → metadata entries.
171    #[must_use]
172    pub fn detailed(views: IndexMap<String, Value>, next_cursor: Option<Cursor>) -> Self {
173        Self {
174            views: ListEntries::Detailed(views),
175            next_cursor,
176        }
177    }
178}
179
180/// Request for the `listTriggers` tool.
181#[derive(Debug, Default, Deserialize, JsonSchema)]
182#[schemars(rename = "ListTriggersRequest")]
183pub struct PinnedListTriggersRequest {
184    /// Opaque cursor from a prior response's `nextCursor`; omit for the first page.
185    #[serde(default)]
186    pub cursor: Option<Cursor>,
187    /// Optional case-insensitive filter on trigger names. The input is used within a `LIKE`
188    /// clause: `%` matches any sequence of characters and `_` matches any single character.
189    #[serde(default)]
190    pub search: Option<String>,
191    /// When `true`, each returned entry is a full metadata object (schema, table, timing,
192    /// events, activationLevel, definition, plus backend-specific fields); when `false` or
193    /// omitted, each entry is the bare trigger-name string.
194    #[serde(default)]
195    pub detailed: bool,
196}
197
198/// Request for the `listTriggers` tool.
199#[derive(Debug, Default, Deserialize, JsonSchema)]
200#[schemars(rename = "ListTriggersRequest")]
201pub struct UnpinnedListTriggersRequest {
202    #[serde(flatten)]
203    pub inner: PinnedListTriggersRequest,
204    /// Database to list triggers from. Defaults to the active database.
205    #[serde(default)]
206    pub database: Option<String>,
207}
208
209/// Response for the `listTriggers` tool.
210#[derive(Debug, Serialize, JsonSchema)]
211pub struct ListTriggersResponse {
212    /// Page of matching triggers. Shape depends on the request's `detailed` flag.
213    pub triggers: ListEntries,
214    /// Opaque cursor pointing to the next page. Absent when this is the final page.
215    #[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
216    pub next_cursor: Option<Cursor>,
217}
218
219impl ListTriggersResponse {
220    /// Builds a brief-mode response from a page of bare trigger names.
221    #[must_use]
222    pub fn brief(triggers: Vec<String>, next_cursor: Option<Cursor>) -> Self {
223        Self {
224            triggers: ListEntries::Brief(triggers),
225            next_cursor,
226        }
227    }
228
229    /// Builds a detailed-mode response from a page of name → metadata entries.
230    #[must_use]
231    pub fn detailed(triggers: IndexMap<String, Value>, next_cursor: Option<Cursor>) -> Self {
232        Self {
233            triggers: ListEntries::Detailed(triggers),
234            next_cursor,
235        }
236    }
237}
238
239/// Request for the `listFunctions` tool.
240#[derive(Debug, Default, Deserialize, JsonSchema)]
241#[schemars(rename = "ListFunctionsRequest")]
242pub struct PinnedListFunctionsRequest {
243    /// Opaque cursor from a prior response's `nextCursor`; omit for the first page.
244    #[serde(default)]
245    pub cursor: Option<Cursor>,
246}
247
248/// Request for the `listFunctions` tool.
249#[derive(Debug, Default, Deserialize, JsonSchema)]
250#[schemars(rename = "ListFunctionsRequest")]
251pub struct UnpinnedListFunctionsRequest {
252    #[serde(flatten)]
253    pub inner: PinnedListFunctionsRequest,
254    /// Database to list functions from. Defaults to the active database.
255    #[serde(default)]
256    pub database: Option<String>,
257}
258
259/// Response for the `listFunctions` tool.
260#[derive(Debug, Serialize, JsonSchema)]
261pub struct ListFunctionsResponse {
262    /// Page of matching functions. Shape depends on the request's `detailed` flag.
263    pub functions: ListEntries,
264    /// Opaque cursor pointing to the next page. Absent when this is the final page.
265    #[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
266    pub next_cursor: Option<Cursor>,
267}
268
269impl ListFunctionsResponse {
270    /// Builds a brief-mode response from a page of bare function names.
271    #[must_use]
272    pub fn brief(functions: Vec<String>, next_cursor: Option<Cursor>) -> Self {
273        Self {
274            functions: ListEntries::Brief(functions),
275            next_cursor,
276        }
277    }
278
279    /// Builds a detailed-mode response from a page of signature → metadata entries.
280    #[must_use]
281    pub fn detailed(functions: IndexMap<String, Value>, next_cursor: Option<Cursor>) -> Self {
282        Self {
283            functions: ListEntries::Detailed(functions),
284            next_cursor,
285        }
286    }
287}
288
289/// Response for the `listProcedures` tool.
290#[derive(Debug, Serialize, JsonSchema)]
291pub struct ListProceduresResponse {
292    /// Page of matching procedures. Shape depends on the request's `detailed` flag.
293    pub procedures: ListEntries,
294    /// Opaque cursor pointing to the next page. Absent when this is the final page.
295    #[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
296    pub next_cursor: Option<Cursor>,
297}
298
299impl ListProceduresResponse {
300    /// Builds a brief-mode response from a page of bare procedure names.
301    #[must_use]
302    pub fn brief(procedures: Vec<String>, next_cursor: Option<Cursor>) -> Self {
303        Self {
304            procedures: ListEntries::Brief(procedures),
305            next_cursor,
306        }
307    }
308
309    /// Builds a detailed-mode response from a page of signature → metadata entries.
310    #[must_use]
311    pub fn detailed(procedures: IndexMap<String, Value>, next_cursor: Option<Cursor>) -> Self {
312        Self {
313            procedures: ListEntries::Detailed(procedures),
314            next_cursor,
315        }
316    }
317}
318
319/// Request for the `writeQuery` tool.
320#[derive(Debug, Default, Deserialize, JsonSchema)]
321#[schemars(rename = "QueryRequest")]
322pub struct PinnedQueryRequest {
323    /// The SQL query to execute.
324    pub query: String,
325}
326
327/// Request for the `writeQuery` tool.
328#[derive(Debug, Default, Deserialize, JsonSchema)]
329#[schemars(rename = "QueryRequest")]
330pub struct UnpinnedQueryRequest {
331    #[serde(flatten)]
332    pub inner: PinnedQueryRequest,
333    /// Database to run the query against. Defaults to the active database.
334    #[serde(default)]
335    pub database: Option<String>,
336}
337
338/// Request for the `readQuery` tool.
339#[derive(Debug, Default, Deserialize, JsonSchema)]
340#[schemars(rename = "ReadQueryRequest")]
341pub struct PinnedReadQueryRequest {
342    /// The SQL query to execute.
343    pub query: String,
344    /// Opaque cursor from a prior response's `nextCursor`; omit for the first page.
345    #[serde(default)]
346    pub cursor: Option<Cursor>,
347}
348
349/// Request for the `readQuery` tool.
350#[derive(Debug, Default, Deserialize, JsonSchema)]
351#[schemars(rename = "ReadQueryRequest")]
352pub struct UnpinnedReadQueryRequest {
353    #[serde(flatten)]
354    pub inner: PinnedReadQueryRequest,
355    /// Database to run the query against. Defaults to the active database.
356    #[serde(default)]
357    pub database: Option<String>,
358}
359
360/// Response for the `writeQuery` and `explainQuery` tools.
361#[derive(Debug, Serialize, JsonSchema)]
362pub struct QueryResponse {
363    /// Result rows, each a JSON object keyed by a column name.
364    pub rows: Vec<Value>,
365}
366
367/// Response for the `readQuery` tool.
368#[derive(Debug, Serialize, JsonSchema)]
369pub struct ReadQueryResponse {
370    /// Result rows, each a JSON object keyed by a column name.
371    pub rows: Vec<Value>,
372    /// Opaque cursor pointing to the next page. Absent when this is the final
373    /// page, when the result fits in one page, or when the statement is a
374    /// non-`SELECT` kind that does not paginate (e.g. `SHOW`, `EXPLAIN`).
375    #[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
376    pub next_cursor: Option<Cursor>,
377}
378
379/// Request for the `explainQuery` tool.
380#[derive(Debug, Default, Deserialize, JsonSchema)]
381#[schemars(rename = "ExplainQueryRequest")]
382pub struct PinnedExplainQueryRequest {
383    /// The SQL query to explain.
384    pub query: String,
385    /// If true, use EXPLAIN ANALYZE for actual execution statistics. In read-only mode, only allowed for read-only statements. Defaults to false.
386    #[serde(default)]
387    pub analyze: bool,
388}
389
390/// Request for the `explainQuery` tool.
391#[derive(Debug, Default, Deserialize, JsonSchema)]
392#[schemars(rename = "ExplainQueryRequest")]
393pub struct UnpinnedExplainQueryRequest {
394    #[serde(flatten)]
395    pub inner: PinnedExplainQueryRequest,
396    /// Database to explain against. Defaults to the active database.
397    #[serde(default)]
398    pub database: Option<String>,
399}
400
401#[cfg(test)]
402mod tests {
403    use super::{
404        IndexMap, ListEntries, ListFunctionsResponse, ListTablesResponse, ListTriggersResponse,
405        PinnedListTriggersRequest, UnpinnedListTriggersRequest,
406    };
407    use serde_json::{Value, json};
408
409    #[test]
410    fn unpinned_list_triggers_request_defaults_to_brief_mode_without_search() {
411        let req: PinnedListTriggersRequest = serde_json::from_str("{}").expect("empty object should parse");
412        assert!(req.search.is_none());
413        assert!(!req.detailed, "detailed must default to false");
414    }
415
416    #[test]
417    fn unpinned_list_triggers_request_accepts_search_and_detailed() {
418        let req: PinnedListTriggersRequest =
419            serde_json::from_str(r#"{"search": "audit", "detailed": true}"#).expect("parse");
420        assert_eq!(req.search.as_deref(), Some("audit"));
421        assert!(req.detailed);
422    }
423
424    #[test]
425    fn pinned_list_triggers_request_accepts_database_and_inner_fields() {
426        let req: UnpinnedListTriggersRequest =
427            serde_json::from_str(r#"{"database": "mydb", "search": "audit", "detailed": true}"#).expect("parse");
428        assert_eq!(req.database.as_deref(), Some("mydb"));
429        assert_eq!(req.inner.search.as_deref(), Some("audit"));
430        assert!(req.inner.detailed);
431    }
432
433    #[test]
434    fn brief_serializes_as_bare_string_array() {
435        let entries = ListEntries::Brief(vec!["customers".into(), "orders".into()]);
436        assert_eq!(serde_json::to_value(&entries).unwrap(), json!(["customers", "orders"]));
437    }
438
439    #[test]
440    fn detailed_serializes_as_keyed_object() {
441        let entries = ListEntries::Detailed(IndexMap::from([("orders".into(), json!({"kind": "TABLE"}))]));
442        assert_eq!(
443            serde_json::to_value(&entries).unwrap(),
444            json!({"orders": {"kind": "TABLE"}})
445        );
446    }
447
448    #[test]
449    fn brief_empty_serializes_as_empty_array() {
450        assert_eq!(serde_json::to_value(ListEntries::Brief(Vec::new())).unwrap(), json!([]));
451    }
452
453    #[test]
454    fn detailed_empty_serializes_as_empty_object() {
455        assert_eq!(
456            serde_json::to_value(ListEntries::Detailed(IndexMap::new())).unwrap(),
457            json!({})
458        );
459    }
460
461    #[test]
462    fn detailed_preserves_insertion_order() {
463        let map = IndexMap::from([
464            ("c".into(), json!({})),
465            ("a".into(), json!({})),
466            ("b".into(), json!({})),
467        ]);
468        let s = serde_json::to_string(&ListEntries::Detailed(map)).unwrap();
469        let positions = ["\"c\"", "\"a\"", "\"b\""].map(|k| s.find(k).expect(k));
470        assert!(positions.is_sorted(), "insertion order not preserved: {s}");
471    }
472
473    #[test]
474    fn list_tables_response_brief_matches_legacy_wire_shape() {
475        let response = ListTablesResponse {
476            tables: ListEntries::Brief(vec!["a".into()]),
477            next_cursor: None,
478        };
479        assert_eq!(serde_json::to_value(&response).unwrap(), json!({"tables": ["a"]}));
480    }
481
482    #[test]
483    fn list_triggers_response_brief_matches_legacy_wire_shape() {
484        let response = ListTriggersResponse {
485            triggers: ListEntries::Brief(vec!["t1".into()]),
486            next_cursor: None,
487        };
488        assert_eq!(serde_json::to_value(&response).unwrap(), json!({"triggers": ["t1"]}));
489    }
490
491    #[test]
492    fn as_brief_and_as_detailed_unwrap_correct_variant() {
493        let brief = ListEntries::Brief(vec!["a".into()]);
494        assert_eq!(brief.as_brief(), Some(&["a".into()][..]));
495        assert!(brief.as_detailed().is_none());
496
497        let det = ListEntries::Detailed(IndexMap::from([("x".into(), json!(1))]));
498        assert!(det.as_brief().is_none());
499        assert_eq!(det.as_detailed().map(IndexMap::len), Some(1));
500    }
501
502    /// Detailed keyed payload must be strictly smaller than the prior array-of-objects
503    /// form for a representative 10-table fixture. The saving is one `"name": "<table>",`
504    /// fragment per entry; the contractual claim is the strict reduction across backends.
505    #[test]
506    fn detailed_payload_strictly_smaller_than_array_form() {
507        let metadata = json!({
508            "schema": "public", "kind": "TABLE", "owner": "app", "comment": null,
509            "columns": [
510                {"name": "id", "dataType": "bigint", "ordinalPosition": 1, "nullable": false, "default": null, "comment": null},
511                {"name": "created_at", "dataType": "timestamptz", "ordinalPosition": 2, "nullable": false, "default": "now()", "comment": null},
512            ],
513            "constraints": [{"name": "pk", "type": "PRIMARY KEY", "columns": ["id"], "definition": "PRIMARY KEY (id)"}],
514            "indexes": [], "triggers": [],
515        });
516        let tables = [
517            "customers",
518            "orders",
519            "items",
520            "products",
521            "inventory",
522            "suppliers",
523            "shipments",
524            "invoices",
525            "payments",
526            "audits",
527        ];
528        let new_map: IndexMap<String, Value> = tables.iter().map(|n| ((*n).into(), metadata.clone())).collect();
529        let old: Vec<Value> = tables
530            .iter()
531            .map(|n| {
532                let mut v = metadata.clone();
533                v["name"] = json!(n);
534                v
535            })
536            .collect();
537        let new_len = serde_json::to_vec(&ListEntries::Detailed(new_map)).unwrap().len();
538        let old_len = serde_json::to_vec(&old).unwrap().len();
539        assert!(new_len < old_len, "payload not smaller: new={new_len} old={old_len}");
540    }
541
542    #[test]
543    fn list_functions_response_brief_constructor_wraps_vec() {
544        let response = ListFunctionsResponse::brief(vec!["calc_total".into()], None);
545        assert!(matches!(response.functions, ListEntries::Brief(ref v) if v == &["calc_total"]));
546        assert!(response.next_cursor.is_none());
547    }
548
549    #[test]
550    fn list_functions_response_detailed_constructor_wraps_indexmap() {
551        let map = IndexMap::from([("calc_total(integer)".into(), json!({"language": "sql"}))]);
552        let response = ListFunctionsResponse::detailed(map, None);
553        assert!(matches!(response.functions, ListEntries::Detailed(_)));
554    }
555
556    #[test]
557    fn list_functions_response_brief_matches_legacy_wire_shape() {
558        let response = ListFunctionsResponse::brief(vec!["audit_user_login".into()], None);
559        assert_eq!(
560            serde_json::to_value(&response).unwrap(),
561            json!({"functions": ["audit_user_login"]})
562        );
563    }
564
565    #[test]
566    fn list_procedures_response_brief_constructor_wraps_vec() {
567        let response = super::ListProceduresResponse::brief(vec!["archive_order".into()], None);
568        assert!(matches!(response.procedures, ListEntries::Brief(ref v) if v == &["archive_order"]));
569        assert!(response.next_cursor.is_none());
570    }
571
572    #[test]
573    fn list_procedures_response_detailed_constructor_wraps_indexmap() {
574        let map = IndexMap::from([("archive_order(integer)".into(), json!({"language": "plpgsql"}))]);
575        let response = super::ListProceduresResponse::detailed(map, None);
576        assert!(matches!(response.procedures, ListEntries::Detailed(_)));
577    }
578
579    #[test]
580    fn list_procedures_response_brief_matches_legacy_wire_shape() {
581        let response = super::ListProceduresResponse::brief(vec!["archive_order".into()], None);
582        assert_eq!(
583            serde_json::to_value(&response).unwrap(),
584            json!({"procedures": ["archive_order"]})
585        );
586    }
587
588    #[test]
589    fn list_views_response_brief_constructor_wraps_vec() {
590        let response = super::ListViewsResponse::brief(vec!["active_users".into()], None);
591        assert!(matches!(response.views, ListEntries::Brief(ref v) if v == &["active_users"]));
592        assert!(response.next_cursor.is_none());
593    }
594
595    #[test]
596    fn list_views_response_detailed_constructor_wraps_indexmap() {
597        let map = IndexMap::from([("active_users".into(), json!({"schema": "public"}))]);
598        let response = super::ListViewsResponse::detailed(map, None);
599        assert!(matches!(response.views, ListEntries::Detailed(_)));
600    }
601
602    #[test]
603    fn list_views_response_brief_matches_legacy_wire_shape() {
604        let response = super::ListViewsResponse::brief(vec!["active_users".into()], None);
605        assert_eq!(
606            serde_json::to_value(&response).unwrap(),
607            json!({"views": ["active_users"]})
608        );
609    }
610}