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