Skip to main content

dbmcp_server/
types.rs

1//! Request and response types for MCP tool parameters.
2//!
3//! Most structs map to the input or output schema of a single MCP tool;
4//! [`ListEntriesResponse`] is the shared output for every `list*` tool
5//! (`listTables`, `listViews`, `listTriggers`, `listFunctions`,
6//! `listProcedures`, `listMaterializedViews`).
7
8use indexmap::IndexMap;
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use crate::pagination::Cursor;
14
15/// Two-shape listing payload: bare names in brief mode, name-keyed metadata in detailed mode.
16///
17/// Embedded as the `entries` field of [`ListEntriesResponse`]. Serialises untagged:
18/// brief mode → JSON array of strings, detailed mode → JSON object whose keys are
19/// entity names and whose values are the per-entity metadata.
20#[derive(Debug, Serialize, JsonSchema)]
21#[serde(untagged)]
22pub enum ListEntries {
23    /// Brief mode: sorted array of bare entity-name strings.
24    Brief(Vec<String>),
25    /// Detailed mode: name-keyed map; insertion order matches the SQL `ORDER BY` sort.
26    Detailed(IndexMap<String, Value>),
27}
28
29impl ListEntries {
30    /// Number of entries in the page, regardless of variant.
31    #[must_use]
32    pub fn len(&self) -> usize {
33        match self {
34            Self::Brief(v) => v.len(),
35            Self::Detailed(m) => m.len(),
36        }
37    }
38
39    /// Whether the page contains no entries.
40    #[must_use]
41    pub fn is_empty(&self) -> bool {
42        self.len() == 0
43    }
44
45    /// Returns the brief-mode names as a slice, or `None` in detailed mode.
46    #[must_use]
47    pub fn as_brief(&self) -> Option<&[String]> {
48        if let Self::Brief(v) = self { Some(v) } else { None }
49    }
50
51    /// Returns the detailed-mode map of name → metadata, or `None` in brief mode.
52    #[must_use]
53    pub fn as_detailed(&self) -> Option<&IndexMap<String, Value>> {
54        if let Self::Detailed(m) = self { Some(m) } else { None }
55    }
56
57    /// Consumes the payload and returns the brief-mode names, or `None` in detailed mode.
58    #[must_use]
59    pub fn into_brief(self) -> Option<Vec<String>> {
60        if let Self::Brief(v) = self { Some(v) } else { None }
61    }
62}
63
64/// Response for list-style tools (`listTables`, `listViews`, `listTriggers`,
65/// `listFunctions`, `listProcedures`, `listMaterializedViews`).
66#[derive(Debug, Serialize, JsonSchema)]
67pub struct ListEntriesResponse {
68    /// Page of matching entries. Shape depends on the request's `detailed` flag.
69    pub entries: ListEntries,
70    /// Opaque cursor pointing to the next page. Absent when this is the final page.
71    #[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
72    pub next_cursor: Option<Cursor>,
73}
74
75impl ListEntriesResponse {
76    /// Builds a brief-mode response from a page of bare entity names.
77    #[must_use]
78    pub fn brief(entries: Vec<String>, next_cursor: Option<Cursor>) -> Self {
79        Self {
80            entries: ListEntries::Brief(entries),
81            next_cursor,
82        }
83    }
84
85    /// Builds a detailed-mode response from a page of name → metadata entries.
86    #[must_use]
87    pub fn detailed(entries: IndexMap<String, Value>, next_cursor: Option<Cursor>) -> Self {
88        Self {
89            entries: ListEntries::Detailed(entries),
90            next_cursor,
91        }
92    }
93}
94
95/// Response for tools with no structured return data.
96#[derive(Debug, Serialize, JsonSchema)]
97pub struct MessageResponse {
98    /// Description of the completed operation.
99    pub message: String,
100}
101
102/// Request for the `listDatabases` tool.
103#[derive(Debug, Default, Deserialize, JsonSchema)]
104pub struct ListDatabasesRequest {
105    /// Opaque cursor from a prior response's `nextCursor`; omit for the first page.
106    #[serde(default)]
107    pub cursor: Option<Cursor>,
108}
109
110/// Response for the `listDatabases` tool.
111#[derive(Debug, Serialize, JsonSchema)]
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(rename = "nextCursor", 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)]
122pub struct CreateDatabaseRequest {
123    /// Name of the database to create. Must be non-empty.
124    pub database: String,
125}
126
127/// Request for the `dropDatabase` tool.
128#[derive(Debug, Default, Deserialize, JsonSchema)]
129pub struct DropDatabaseRequest {
130    /// Name of the database to drop. Must be non-empty.
131    pub database: String,
132}
133
134/// Request for the `listViews` tool.
135#[derive(Debug, Default, Deserialize, JsonSchema)]
136pub struct ListViewsRequest {
137    /// Opaque cursor from a prior response's `nextCursor`; omit for the first page.
138    #[serde(default)]
139    pub cursor: Option<Cursor>,
140    /// Database to list views from. Defaults to the active database.
141    #[serde(default)]
142    pub database: Option<String>,
143}
144
145/// Request for the `listTriggers` tool.
146#[derive(Debug, Default, Deserialize, JsonSchema)]
147pub struct ListTriggersRequest {
148    /// Opaque cursor from a prior response's `nextCursor`; omit for the first page.
149    #[serde(default)]
150    pub cursor: Option<Cursor>,
151    /// Optional case-insensitive filter on trigger names. The input is used within a `LIKE`
152    /// clause: `%` matches any sequence of characters and `_` matches any single character.
153    #[serde(default)]
154    pub search: Option<String>,
155    /// When `true`, each returned entry is a full metadata object (schema, table, timing,
156    /// events, activationLevel, definition, plus backend-specific fields); when `false` or
157    /// omitted, each entry is the bare trigger-name string.
158    #[serde(default)]
159    pub detailed: bool,
160    /// Database to list triggers from. Defaults to the active database.
161    #[serde(default)]
162    pub database: Option<String>,
163}
164
165/// Request for the `listFunctions` tool.
166#[derive(Debug, Default, Deserialize, JsonSchema)]
167pub struct ListFunctionsRequest {
168    /// Opaque cursor from a prior response's `nextCursor`; omit for the first page.
169    #[serde(default)]
170    pub cursor: Option<Cursor>,
171    /// Database to list functions from. Defaults to the active database.
172    #[serde(default)]
173    pub database: Option<String>,
174}
175
176/// Request for the `writeQuery` tool.
177#[derive(Debug, Default, Deserialize, JsonSchema)]
178pub struct QueryRequest {
179    /// The SQL query to execute.
180    pub query: String,
181    /// Database to run the query against. Defaults to the active database.
182    #[serde(default)]
183    pub database: Option<String>,
184}
185
186/// Request for the `readQuery` tool.
187#[derive(Debug, Default, Deserialize, JsonSchema)]
188pub struct ReadQueryRequest {
189    /// The SQL query to execute.
190    pub query: String,
191    /// Opaque cursor from a prior response's `nextCursor`; omit for the first page.
192    #[serde(default)]
193    pub cursor: Option<Cursor>,
194    /// Database to run the query against. Defaults to the active database.
195    #[serde(default)]
196    pub database: Option<String>,
197}
198
199/// Response for the `writeQuery` and `explainQuery` tools.
200#[derive(Debug, Serialize, JsonSchema)]
201pub struct QueryResponse {
202    /// Result rows, each a JSON object keyed by a column name.
203    pub rows: Vec<Value>,
204}
205
206/// Response for the `readQuery` tool.
207#[derive(Debug, Serialize, JsonSchema)]
208pub struct ReadQueryResponse {
209    /// Result rows, each a JSON object keyed by a column name.
210    pub rows: Vec<Value>,
211    /// Opaque cursor pointing to the next page. Absent when this is the final
212    /// page, when the result fits in one page, or when the statement is a
213    /// non-`SELECT` kind that does not paginate (e.g. `SHOW`, `EXPLAIN`).
214    #[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
215    pub next_cursor: Option<Cursor>,
216}
217
218/// Request for the `explainQuery` tool.
219#[derive(Debug, Default, Deserialize, JsonSchema)]
220pub struct ExplainQueryRequest {
221    /// The SQL query to explain.
222    pub query: String,
223    /// If true, use EXPLAIN ANALYZE for actual execution statistics. In read-only mode, only allowed for read-only statements. Defaults to false.
224    #[serde(default)]
225    pub analyze: bool,
226    /// Database to explain against. Defaults to the active database.
227    #[serde(default)]
228    pub database: Option<String>,
229}
230
231#[cfg(test)]
232mod tests {
233    use super::{IndexMap, ListEntries, ListEntriesResponse, ListTriggersRequest};
234    use serde_json::{Value, json};
235
236    #[test]
237    fn list_triggers_request_defaults_to_brief_mode_without_search() {
238        let req: ListTriggersRequest = serde_json::from_str("{}").expect("empty object should parse");
239        assert!(req.search.is_none());
240        assert!(!req.detailed, "detailed must default to false");
241        assert!(req.database.is_none());
242    }
243
244    #[test]
245    fn list_triggers_request_accepts_search_and_detailed() {
246        let req: ListTriggersRequest = serde_json::from_str(r#"{"search": "audit", "detailed": true}"#).expect("parse");
247        assert_eq!(req.search.as_deref(), Some("audit"));
248        assert!(req.detailed);
249    }
250
251    #[test]
252    fn list_triggers_request_accepts_database_and_inner_fields() {
253        let req: ListTriggersRequest =
254            serde_json::from_str(r#"{"database": "mydb", "search": "audit", "detailed": true}"#).expect("parse");
255        assert_eq!(req.database.as_deref(), Some("mydb"));
256        assert_eq!(req.search.as_deref(), Some("audit"));
257        assert!(req.detailed);
258    }
259
260    #[test]
261    fn brief_serializes_as_bare_string_array() {
262        let entries = ListEntries::Brief(vec!["customers".into(), "orders".into()]);
263        assert_eq!(serde_json::to_value(&entries).unwrap(), json!(["customers", "orders"]));
264    }
265
266    #[test]
267    fn detailed_serializes_as_keyed_object() {
268        let entries = ListEntries::Detailed(IndexMap::from([("orders".into(), json!({"kind": "TABLE"}))]));
269        assert_eq!(
270            serde_json::to_value(&entries).unwrap(),
271            json!({"orders": {"kind": "TABLE"}})
272        );
273    }
274
275    #[test]
276    fn brief_empty_serializes_as_empty_array() {
277        assert_eq!(serde_json::to_value(ListEntries::Brief(Vec::new())).unwrap(), json!([]));
278    }
279
280    #[test]
281    fn detailed_empty_serializes_as_empty_object() {
282        assert_eq!(
283            serde_json::to_value(ListEntries::Detailed(IndexMap::new())).unwrap(),
284            json!({})
285        );
286    }
287
288    #[test]
289    fn detailed_preserves_insertion_order() {
290        let map = IndexMap::from([
291            ("c".into(), json!({})),
292            ("a".into(), json!({})),
293            ("b".into(), json!({})),
294        ]);
295        let s = serde_json::to_string(&ListEntries::Detailed(map)).unwrap();
296        let positions = ["\"c\"", "\"a\"", "\"b\""].map(|k| s.find(k).expect(k));
297        assert!(positions.is_sorted(), "insertion order not preserved: {s}");
298    }
299
300    #[test]
301    fn list_entries_response_brief_serializes_with_entries_key() {
302        let response = ListEntriesResponse::brief(vec!["a".into()], None);
303        assert_eq!(serde_json::to_value(&response).unwrap(), json!({"entries": ["a"]}));
304    }
305
306    #[test]
307    fn list_entries_response_detailed_serializes_with_entries_key() {
308        let map = IndexMap::from([("a".into(), json!({"kind": "TABLE"}))]);
309        let response = ListEntriesResponse::detailed(map, None);
310        assert_eq!(
311            serde_json::to_value(&response).unwrap(),
312            json!({"entries": {"a": {"kind": "TABLE"}}})
313        );
314    }
315
316    #[test]
317    fn list_entries_response_omits_next_cursor_when_none() {
318        let response = ListEntriesResponse::brief(vec!["a".into()], None);
319        let value = serde_json::to_value(&response).unwrap();
320        assert!(
321            value.get("nextCursor").is_none(),
322            "nextCursor must be omitted when None"
323        );
324    }
325
326    #[test]
327    fn as_brief_and_as_detailed_unwrap_correct_variant() {
328        let brief = ListEntries::Brief(vec!["a".into()]);
329        assert_eq!(brief.as_brief(), Some(&["a".into()][..]));
330        assert!(brief.as_detailed().is_none());
331
332        let det = ListEntries::Detailed(IndexMap::from([("x".into(), json!(1))]));
333        assert!(det.as_brief().is_none());
334        assert_eq!(det.as_detailed().map(IndexMap::len), Some(1));
335    }
336
337    /// Detailed keyed payload must be strictly smaller than the prior array-of-objects
338    /// form for a representative 10-table fixture. The saving is one `"name": "<table>",`
339    /// fragment per entry; the contractual claim is the strict reduction across backends.
340    #[test]
341    fn detailed_payload_strictly_smaller_than_array_form() {
342        let metadata = json!({
343            "schema": "public", "kind": "TABLE", "owner": "app", "comment": null,
344            "columns": [
345                {"name": "id", "dataType": "bigint", "ordinalPosition": 1, "nullable": false, "default": null, "comment": null},
346                {"name": "created_at", "dataType": "timestamptz", "ordinalPosition": 2, "nullable": false, "default": "now()", "comment": null},
347            ],
348            "constraints": [{"name": "pk", "type": "PRIMARY KEY", "columns": ["id"], "definition": "PRIMARY KEY (id)"}],
349            "indexes": [], "triggers": [],
350        });
351        let tables = [
352            "customers",
353            "orders",
354            "items",
355            "products",
356            "inventory",
357            "suppliers",
358            "shipments",
359            "invoices",
360            "payments",
361            "audits",
362        ];
363        let new_map: IndexMap<String, Value> = tables.iter().map(|n| ((*n).into(), metadata.clone())).collect();
364        let old: Vec<Value> = tables
365            .iter()
366            .map(|n| {
367                let mut v = metadata.clone();
368                v["name"] = json!(n);
369                v
370            })
371            .collect();
372        let new_len = serde_json::to_vec(&ListEntries::Detailed(new_map)).unwrap().len();
373        let old_len = serde_json::to_vec(&old).unwrap().len();
374        assert!(new_len < old_len, "payload not smaller: new={new_len} old={old_len}");
375    }
376}