Skip to main content

agentdb/
mcp.rs

1//! # MCP (Model Context Protocol) Server Interface
2//!
3//! Implements the MCP JSON-RPC transport for AgentDB, exposing the database
4//! as an MCP-compatible tool server. Supports:
5//!
6//! - `initialize` / `initialized` handshake
7//! - `tools/list` — enumerate all AgentDB capabilities as MCP tools
8//! - `tools/call` — invoke any AgentDB operation
9//! - `resources/list` / `resources/read` — expose database stats and collections
10//!
11//! ## Usage
12//!
13//! ```rust,no_run
14//! use agentdb::{AgentDB, mcp::McpServer};
15//!
16//! let db = AgentDB::open("agent.db").unwrap();
17//! let server = McpServer::new(db);
18//!
19//! // Process a JSON-RPC request (from stdin, HTTP, WebSocket, etc.)
20//! let request = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
21//! let response = server.handle_message(request);
22//! println!("{}", response);
23//! ```
24
25use crate::db::AgentDB;
26use serde_json::{json, Value};
27use std::collections::HashMap;
28
29/// MCP server wrapping an AgentDB instance.
30pub struct McpServer {
31    db: AgentDB,
32}
33
34impl McpServer {
35    /// Create a new MCP server backed by the given database.
36    pub fn new(db: AgentDB) -> Self {
37        Self { db }
38    }
39
40    /// Handle a single JSON-RPC message string and return the response.
41    ///
42    /// Returns `None` for notifications (messages without an `id` field),
43    /// per JSON-RPC 2.0 spec: servers MUST NOT reply to notifications.
44    pub fn handle_message(&self, input: &str) -> Option<String> {
45        let req: Value = match serde_json::from_str(input) {
46            Ok(v) => v,
47            Err(e) => {
48                return Some(
49                    json!({
50                        "jsonrpc": "2.0",
51                        "id": null,
52                        "error": { "code": -32700, "message": format!("Parse error: {e}") }
53                    })
54                    .to_string(),
55                );
56            }
57        };
58
59        let is_notification = !req.get("id").is_some_and(|v| !v.is_null());
60        let id = req.get("id").cloned().unwrap_or(Value::Null);
61        let method = req.get("method").and_then(|m| m.as_str()).unwrap_or("");
62        let params = req.get("params").cloned().unwrap_or(Value::Object(Default::default()));
63
64        // Notifications: no response per JSON-RPC 2.0 / MCP spec
65        if is_notification {
66            match method {
67                "initialized" | "notifications/cancelled" | "notifications/progress" => {}
68                _ => {}
69            }
70            return None;
71        }
72
73        let result = match method {
74            "initialize" => self.handle_initialize(&params),
75            "ping" => Ok(json!({})),
76            "tools/list" => self.handle_tools_list(),
77            "tools/call" => self.handle_tools_call(&params),
78            "resources/list" => self.handle_resources_list(),
79            "resources/read" => self.handle_resources_read(&params),
80            "prompts/list" => self.handle_prompts_list(),
81            "prompts/get" => self.handle_prompts_get(&params),
82            _ => Err((-32601, format!("Method not found: {method}"))),
83        };
84
85        Some(match result {
86            Ok(value) => json!({ "jsonrpc": "2.0", "id": id, "result": value }).to_string(),
87            Err((code, msg)) => {
88                json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": msg } })
89                    .to_string()
90            }
91        })
92    }
93
94    fn handle_initialize(&self, _params: &Value) -> std::result::Result<Value, (i32, String)> {
95        Ok(json!({
96            "protocolVersion": "2024-11-05",
97            "capabilities": {
98                "tools": { "listChanged": false },
99                "resources": { "subscribe": false, "listChanged": false }
100            },
101            "serverInfo": {
102                "name": "agentdb",
103                "version": env!("CARGO_PKG_VERSION")
104            }
105        }))
106    }
107
108    fn handle_tools_list(&self) -> std::result::Result<Value, (i32, String)> {
109        Ok(json!({ "tools": self.tool_definitions() }))
110    }
111
112    fn handle_tools_call(&self, params: &Value) -> std::result::Result<Value, (i32, String)> {
113        let name = params
114            .get("name")
115            .and_then(|n| n.as_str())
116            .ok_or((-32602, "Missing 'name' parameter".to_string()))?;
117        let arguments = params
118            .get("arguments")
119            .cloned()
120            .unwrap_or(Value::Object(Default::default()));
121
122        let result = self.dispatch_tool(name, &arguments)?;
123
124        Ok(json!({
125            "content": [{
126                "type": "text",
127                "text": result.to_string()
128            }]
129        }))
130    }
131
132    fn handle_resources_list(&self) -> std::result::Result<Value, (i32, String)> {
133        Ok(json!({
134            "resources": [
135                {
136                    "uri": "agentdb://stats",
137                    "name": "Database Statistics",
138                    "description": "Current AgentDB database statistics",
139                    "mimeType": "application/json"
140                }
141            ]
142        }))
143    }
144
145    fn handle_resources_read(&self, params: &Value) -> std::result::Result<Value, (i32, String)> {
146        let uri = params
147            .get("uri")
148            .and_then(|u| u.as_str())
149            .ok_or((-32602, "Missing 'uri' parameter".to_string()))?;
150
151        match uri {
152            "agentdb://stats" => {
153                let stats = self
154                    .db
155                    .stats()
156                    .map_err(|e| (-32000, format!("Stats error: {e}")))?;
157                Ok(json!({
158                    "contents": [{
159                        "uri": "agentdb://stats",
160                        "mimeType": "application/json",
161                        "text": serde_json::to_string(&stats).unwrap_or_default()
162                    }]
163                }))
164            }
165            _ => Err((-32002, format!("Resource not found: {uri}"))),
166        }
167    }
168
169    fn handle_prompts_list(&self) -> std::result::Result<Value, (i32, String)> {
170        let templates = self
171            .db
172            .prompts()
173            .list_templates()
174            .map_err(|e| (-32000, e.to_string()))?;
175        let prompts: Vec<Value> = templates
176            .iter()
177            .map(|t| {
178                json!({
179                    "name": t.name,
180                    "description": format!("Prompt template '{}' v{}", t.name, t.version),
181                    "arguments": [{
182                        "name": "vars",
183                        "description": "JSON object of template variables for {{placeholder}} substitution",
184                        "required": false
185                    }]
186                })
187            })
188            .collect();
189        // Deduplicate by name (list_templates returns all versions)
190        let mut seen = std::collections::HashSet::new();
191        let unique: Vec<Value> = prompts
192            .into_iter()
193            .filter(|p| seen.insert(p["name"].as_str().unwrap_or("").to_string()))
194            .collect();
195        Ok(json!({ "prompts": unique }))
196    }
197
198    fn handle_prompts_get(&self, params: &Value) -> std::result::Result<Value, (i32, String)> {
199        let name = params
200            .get("name")
201            .and_then(|n| n.as_str())
202            .ok_or((-32602, "Missing 'name' parameter".to_string()))?;
203        let args = params.get("arguments").cloned().unwrap_or(Value::Object(Default::default()));
204        let vars: HashMap<String, String> = args
205            .get("vars")
206            .and_then(|v| serde_json::from_value(v.clone()).ok())
207            .unwrap_or_default();
208        let rendered = self
209            .db
210            .prompts()
211            .render(name, &vars)
212            .map_err(|e| (-32000, e.to_string()))?;
213        Ok(json!({
214            "description": format!("Rendered prompt template '{name}'"),
215            "messages": [{
216                "role": "user",
217                "content": { "type": "text", "text": rendered }
218            }]
219        }))
220    }
221
222    fn dispatch_tool(
223        &self,
224        name: &str,
225        args: &Value,
226    ) -> std::result::Result<Value, (i32, String)> {
227        let err = |e: crate::error::AgentDbError| (-32000, e.to_string());
228
229        match name {
230            "execute" => {
231                let sql = get_str(args, "sql")?;
232                let n = self.db.execute(sql).map_err(err)?;
233                Ok(json!({ "rows_affected": n }))
234            }
235            "query" => {
236                let sql = get_str(args, "sql")?;
237                let rows = self.db.query_json(sql).map_err(err)?;
238                Ok(Value::Array(rows))
239            }
240            "vector_upsert" => {
241                let collection = get_str(args, "collection")?;
242                let id = get_str(args, "id")?;
243                let vector: Vec<f32> = args
244                    .get("vector")
245                    .and_then(|v| serde_json::from_value(v.clone()).ok())
246                    .ok_or((-32602, "Missing 'vector' array".to_string()))?;
247                let metadata = args.get("metadata").cloned();
248                let dim = vector.len();
249                let col = self.db.vectors().collection(collection, dim).map_err(err)?;
250                col.upsert(crate::vectors::VectorEntry {
251                    id: id.to_string(),
252                    vector,
253                    metadata,
254                })
255                .map_err(err)?;
256                Ok(json!({ "ok": true }))
257            }
258            "vector_search" => {
259                let collection = get_str(args, "collection")?;
260                let query: Vec<f32> = args
261                    .get("query")
262                    .and_then(|v| serde_json::from_value(v.clone()).ok())
263                    .ok_or((-32602, "Missing 'query' array".to_string()))?;
264                let top_k = args
265                    .get("top_k")
266                    .and_then(|v| v.as_u64())
267                    .unwrap_or(10) as usize;
268                let filter = args.get("filter").cloned();
269                let dim = query.len();
270                let col = self.db.vectors().collection(collection, dim).map_err(err)?;
271                let results = col
272                    .search(
273                        &query,
274                        crate::vectors::SearchOptions {
275                            top_k,
276                            metric: crate::vectors::DistanceMetric::Cosine,
277                            filter,
278                        },
279                    )
280                    .map_err(err)?;
281                let arr: Vec<Value> = results
282                    .iter()
283                    .map(|r| json!({"id": r.id, "score": r.score, "metadata": r.metadata}))
284                    .collect();
285                Ok(Value::Array(arr))
286            }
287            "graph_add_node" => {
288                let id = get_str(args, "id")?;
289                let kind = get_str(args, "kind")?;
290                let data = args.get("data").cloned();
291                self.db.memory().add_node(id, kind, data).map_err(err)?;
292                Ok(json!({ "ok": true }))
293            }
294            "graph_add_edge" => {
295                let src = get_str(args, "src")?;
296                let dst = get_str(args, "dst")?;
297                let relation = get_str(args, "relation")?;
298                let weight = args.get("weight").and_then(|v| v.as_f64()).unwrap_or(1.0);
299                self.db
300                    .memory()
301                    .add_edge(src, dst, relation, weight)
302                    .map_err(err)?;
303                Ok(json!({ "ok": true }))
304            }
305            "graph_neighbors" => {
306                let node_id = get_str(args, "node_id")?;
307                let max_depth = args.get("max_depth").and_then(|v| v.as_u64()).unwrap_or(2) as usize;
308                let min_weight = args.get("min_weight").and_then(|v| v.as_f64()).unwrap_or(0.0);
309                let relation = args.get("relation").and_then(|v| v.as_str());
310                let results = self
311                    .db
312                    .memory()
313                    .neighbors(
314                        node_id,
315                        crate::memory::TraversalOptions {
316                            max_depth,
317                            min_weight: Some(min_weight),
318                            relation: relation.map(|s| s.to_string()),
319                        },
320                    )
321                    .map_err(err)?;
322                let arr: Vec<Value> = results
323                    .iter()
324                    .map(|r| {
325                        json!({"id": r.node.id, "kind": r.node.kind, "depth": r.depth, "weight": r.weight, "data": r.node.data})
326                    })
327                    .collect();
328                Ok(Value::Array(arr))
329            }
330            "tool_register" => {
331                let tool_name = get_str(args, "name")?;
332                let description = args.get("description").and_then(|v| v.as_str());
333                let schema = args.get("parameters_schema").cloned();
334                let version = args.get("version").and_then(|v| v.as_str());
335                let id = self
336                    .db
337                    .tools()
338                    .register_tool(tool_name, description, schema, version)
339                    .map_err(err)?;
340                Ok(json!({ "id": id }))
341            }
342            "tool_list" => {
343                let tools = self.db.tools().list_tools().map_err(err)?;
344                let arr: Vec<Value> = tools
345                    .iter()
346                    .map(|t| {
347                        json!({
348                            "id": t.id, "name": t.name,
349                            "description": t.description,
350                            "parameters_schema": t.parameters_schema,
351                            "version": t.version
352                        })
353                    })
354                    .collect();
355                Ok(Value::Array(arr))
356            }
357            "tool_log_call" => {
358                let tool_name = get_str(args, "tool_name")?;
359                let session_id = args.get("session_id").and_then(|v| v.as_str());
360                let arguments = args.get("arguments").cloned();
361                let result = args.get("result").cloned();
362                let error = args.get("error").and_then(|v| v.as_str());
363                let latency_ms = args.get("latency_ms").and_then(|v| v.as_i64()).unwrap_or(0);
364                let id = self
365                    .db
366                    .tools()
367                    .log_tool_call(session_id, tool_name, arguments, result, error, Some(latency_ms))
368                    .map_err(err)?;
369                Ok(json!({ "id": id }))
370            }
371            "audit_log" => {
372                let action = get_str(args, "action")?;
373                let table_name = get_str(args, "table_name")?;
374                let record_id = get_str(args, "record_id")?;
375                let actor = args.get("actor").and_then(|v| v.as_str());
376                let old_value = args.get("old_value").cloned();
377                let new_value = args.get("new_value").cloned();
378                let reason = args.get("reason").and_then(|v| v.as_str());
379                let id = self
380                    .db
381                    .audit()
382                    .log(actor, action, table_name, record_id, old_value, new_value, reason)
383                    .map_err(err)?;
384                Ok(json!({ "id": id }))
385            }
386            "audit_query_recent" => {
387                let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
388                let entries = self.db.audit().query_recent(Some(limit)).map_err(err)?;
389                let arr: Vec<Value> = entries
390                    .iter()
391                    .map(|e| {
392                        json!({
393                            "id": e.id, "timestamp": e.timestamp, "actor": e.actor,
394                            "action": e.action, "table_name": e.table_name,
395                            "record_id": e.record_id, "reason": e.reason
396                        })
397                    })
398                    .collect();
399                Ok(Value::Array(arr))
400            }
401            "context_add" => {
402                let session_id = get_str(args, "session_id")?;
403                let source_type = get_str(args, "source_type")?;
404                let source_id = get_str(args, "source_id")?;
405                let content_preview = args.get("content_preview").and_then(|v| v.as_str());
406                let token_count = args
407                    .get("token_count")
408                    .and_then(|v| v.as_i64())
409                    .ok_or((-32602, "Missing 'token_count'".to_string()))?;
410                let relevance_score = args
411                    .get("relevance_score")
412                    .and_then(|v| v.as_f64())
413                    .unwrap_or(0.5);
414                let priority = args.get("priority").and_then(|v| v.as_i64()).unwrap_or(0);
415                let id = self
416                    .db
417                    .context()
418                    .add_entry(
419                        session_id,
420                        source_type,
421                        source_id,
422                        content_preview,
423                        token_count,
424                        relevance_score,
425                        priority,
426                    )
427                    .map_err(err)?;
428                Ok(json!({ "id": id }))
429            }
430            "context_build_window" => {
431                let session_id = get_str(args, "session_id")?;
432                let max_tokens = args
433                    .get("max_tokens")
434                    .and_then(|v| v.as_i64())
435                    .ok_or((-32602, "Missing 'max_tokens'".to_string()))?;
436                let entries = self
437                    .db
438                    .context()
439                    .build_window(session_id, max_tokens)
440                    .map_err(err)?;
441                let arr: Vec<Value> = entries
442                    .iter()
443                    .map(|e| {
444                        json!({
445                            "id": e.id, "source_type": e.source_type,
446                            "source_id": e.source_id, "content_preview": e.content_preview,
447                            "token_count": e.token_count, "priority": e.priority
448                        })
449                    })
450                    .collect();
451                Ok(Value::Array(arr))
452            }
453            "context_clear" => {
454                let session_id = get_str(args, "session_id")?;
455                self.db.context().clear_session(session_id).map_err(err)?;
456                Ok(json!({ "ok": true }))
457            }
458            "prompt_create" => {
459                let name = get_str(args, "name")?;
460                let template = get_str(args, "template")?;
461                let model_hint = args.get("model_hint").and_then(|v| v.as_str());
462                let max_tokens = args.get("max_tokens").and_then(|v| v.as_i64());
463                let metadata = args.get("metadata").cloned();
464                let id = self
465                    .db
466                    .prompts()
467                    .create_template(name, template, model_hint, max_tokens, metadata)
468                    .map_err(err)?;
469                Ok(json!({ "id": id }))
470            }
471            "prompt_render" => {
472                let name = get_str(args, "name")?;
473                let vars: HashMap<String, String> = args
474                    .get("vars")
475                    .and_then(|v| serde_json::from_value(v.clone()).ok())
476                    .unwrap_or_default();
477                let rendered = self.db.prompts().render(name, &vars).map_err(err)?;
478                Ok(json!({ "text": rendered }))
479            }
480            "label_tag" => {
481                let table_name = get_str(args, "table_name")?;
482                let record_id = get_str(args, "record_id")?;
483                let label = get_str(args, "label")?;
484                let tagged_by = args.get("tagged_by").and_then(|v| v.as_str());
485                self.db
486                    .labels()
487                    .tag(table_name, record_id, label, tagged_by)
488                    .map_err(err)?;
489                Ok(json!({ "ok": true }))
490            }
491            "label_untag" => {
492                let table_name = get_str(args, "table_name")?;
493                let record_id = get_str(args, "record_id")?;
494                let label = get_str(args, "label")?;
495                self.db
496                    .labels()
497                    .untag(table_name, record_id, label)
498                    .map_err(err)?;
499                Ok(json!({ "ok": true }))
500            }
501            "label_get" => {
502                let table_name = get_str(args, "table_name")?;
503                let record_id = get_str(args, "record_id")?;
504                let labels = self
505                    .db
506                    .labels()
507                    .get_labels(table_name, record_id)
508                    .map_err(err)?;
509                let arr: Vec<Value> = labels
510                    .iter()
511                    .map(|l| {
512                        json!({
513                            "label": l.label, "tagged_by": l.tagged_by,
514                            "tagged_at": l.tagged_at
515                        })
516                    })
517                    .collect();
518                Ok(Value::Array(arr))
519            }
520            "label_has" => {
521                let table_name = get_str(args, "table_name")?;
522                let record_id = get_str(args, "record_id")?;
523                let label = get_str(args, "label")?;
524                let has = self
525                    .db
526                    .labels()
527                    .has_label(table_name, record_id, label)
528                    .map_err(err)?;
529                Ok(json!({ "has": has }))
530            }
531            "stats" => {
532                let stats = self.db.stats().map_err(err)?;
533                Ok(json!({
534                    "collections": stats.collections,
535                    "vectors": stats.vectors,
536                    "nodes": stats.nodes,
537                    "edges": stats.edges,
538                    "conversations": stats.conversations,
539                    "messages": stats.messages,
540                    "workflows": stats.workflows,
541                    "workflow_steps": stats.workflow_steps,
542                    "traces": stats.traces,
543                    "tools": stats.tools,
544                    "tool_calls": stats.tool_calls,
545                    "audit_entries": stats.audit_entries,
546                    "prompt_templates": stats.prompt_templates
547                }))
548            }
549            _ => Err((-32601, format!("Unknown tool: {name}"))),
550        }
551    }
552
553    fn tool_definitions(&self) -> Value {
554        json!([
555            tool_def("execute", "Execute a raw SQL statement (DDL/DML)", json!({
556                "type": "object",
557                "properties": { "sql": { "type": "string", "description": "SQL statement" } },
558                "required": ["sql"]
559            })),
560            tool_def("query", "Execute a SELECT and return rows as JSON", json!({
561                "type": "object",
562                "properties": { "sql": { "type": "string", "description": "SELECT statement" } },
563                "required": ["sql"]
564            })),
565            tool_def("vector_upsert", "Insert or update a vector embedding", json!({
566                "type": "object",
567                "properties": {
568                    "collection": { "type": "string" },
569                    "id": { "type": "string" },
570                    "vector": { "type": "array", "items": { "type": "number" } },
571                    "metadata": { "type": "object" }
572                },
573                "required": ["collection", "id", "vector"]
574            })),
575            tool_def("vector_search", "Approximate nearest-neighbor search", json!({
576                "type": "object",
577                "properties": {
578                    "collection": { "type": "string" },
579                    "query": { "type": "array", "items": { "type": "number" } },
580                    "top_k": { "type": "integer", "default": 10 },
581                    "filter": { "type": "object" }
582                },
583                "required": ["collection", "query"]
584            })),
585            tool_def("graph_add_node", "Add or update a memory graph node", json!({
586                "type": "object",
587                "properties": {
588                    "id": { "type": "string" },
589                    "kind": { "type": "string" },
590                    "data": { "type": "object" }
591                },
592                "required": ["id", "kind"]
593            })),
594            tool_def("graph_add_edge", "Add or update a directed graph edge", json!({
595                "type": "object",
596                "properties": {
597                    "src": { "type": "string" },
598                    "dst": { "type": "string" },
599                    "relation": { "type": "string" },
600                    "weight": { "type": "number", "default": 1.0 }
601                },
602                "required": ["src", "dst", "relation"]
603            })),
604            tool_def("graph_neighbors", "Traverse the memory graph from a node", json!({
605                "type": "object",
606                "properties": {
607                    "node_id": { "type": "string" },
608                    "max_depth": { "type": "integer", "default": 2 },
609                    "min_weight": { "type": "number", "default": 0.0 },
610                    "relation": { "type": "string" }
611                },
612                "required": ["node_id"]
613            })),
614            tool_def("tool_register", "Register or update a tool definition", json!({
615                "type": "object",
616                "properties": {
617                    "name": { "type": "string" },
618                    "description": { "type": "string" },
619                    "parameters_schema": { "type": "object" },
620                    "version": { "type": "string" }
621                },
622                "required": ["name"]
623            })),
624            tool_def("tool_list", "List all registered tools", json!({
625                "type": "object", "properties": {}
626            })),
627            tool_def("tool_log_call", "Log a tool invocation", json!({
628                "type": "object",
629                "properties": {
630                    "tool_name": { "type": "string" },
631                    "session_id": { "type": "string" },
632                    "arguments": { "type": "object" },
633                    "result": { "type": "object" },
634                    "error": { "type": "string" },
635                    "latency_ms": { "type": "integer" }
636                },
637                "required": ["tool_name"]
638            })),
639            tool_def("audit_log", "Append an entry to the audit log", json!({
640                "type": "object",
641                "properties": {
642                    "action": { "type": "string" },
643                    "table_name": { "type": "string" },
644                    "record_id": { "type": "string" },
645                    "actor": { "type": "string" },
646                    "old_value": { "type": "object" },
647                    "new_value": { "type": "object" },
648                    "reason": { "type": "string" }
649                },
650                "required": ["action", "table_name", "record_id"]
651            })),
652            tool_def("audit_query_recent", "Query recent audit log entries", json!({
653                "type": "object",
654                "properties": { "limit": { "type": "integer", "default": 100 } }
655            })),
656            tool_def("context_add", "Add an entry to the context window", json!({
657                "type": "object",
658                "properties": {
659                    "session_id": { "type": "string" },
660                    "source_type": { "type": "string" },
661                    "source_id": { "type": "string" },
662                    "content_preview": { "type": "string" },
663                    "token_count": { "type": "integer" },
664                    "relevance_score": { "type": "number" },
665                    "priority": { "type": "integer" }
666                },
667                "required": ["session_id", "source_type", "source_id", "token_count"]
668            })),
669            tool_def("context_build_window", "Build a token-budgeted context window", json!({
670                "type": "object",
671                "properties": {
672                    "session_id": { "type": "string" },
673                    "max_tokens": { "type": "integer" }
674                },
675                "required": ["session_id", "max_tokens"]
676            })),
677            tool_def("context_clear", "Clear all context entries for a session", json!({
678                "type": "object",
679                "properties": { "session_id": { "type": "string" } },
680                "required": ["session_id"]
681            })),
682            tool_def("prompt_create", "Create a new prompt template version", json!({
683                "type": "object",
684                "properties": {
685                    "name": { "type": "string" },
686                    "template": { "type": "string" },
687                    "model_hint": { "type": "string" },
688                    "max_tokens": { "type": "integer" },
689                    "metadata": { "type": "object" }
690                },
691                "required": ["name", "template"]
692            })),
693            tool_def("prompt_render", "Render a prompt template with variables", json!({
694                "type": "object",
695                "properties": {
696                    "name": { "type": "string" },
697                    "vars": { "type": "object", "additionalProperties": { "type": "string" } }
698                },
699                "required": ["name"]
700            })),
701            tool_def("label_tag", "Tag a record with a classification label", json!({
702                "type": "object",
703                "properties": {
704                    "table_name": { "type": "string" },
705                    "record_id": { "type": "string" },
706                    "label": { "type": "string" },
707                    "tagged_by": { "type": "string" }
708                },
709                "required": ["table_name", "record_id", "label"]
710            })),
711            tool_def("label_untag", "Remove a label from a record", json!({
712                "type": "object",
713                "properties": {
714                    "table_name": { "type": "string" },
715                    "record_id": { "type": "string" },
716                    "label": { "type": "string" }
717                },
718                "required": ["table_name", "record_id", "label"]
719            })),
720            tool_def("label_get", "Get all labels for a record", json!({
721                "type": "object",
722                "properties": {
723                    "table_name": { "type": "string" },
724                    "record_id": { "type": "string" }
725                },
726                "required": ["table_name", "record_id"]
727            })),
728            tool_def("label_has", "Check if a record has a specific label", json!({
729                "type": "object",
730                "properties": {
731                    "table_name": { "type": "string" },
732                    "record_id": { "type": "string" },
733                    "label": { "type": "string" }
734                },
735                "required": ["table_name", "record_id", "label"]
736            })),
737            tool_def("stats", "Get database-wide statistics", json!({
738                "type": "object", "properties": {}
739            })),
740        ])
741    }
742}
743
744fn tool_def(name: &str, description: &str, input_schema: Value) -> Value {
745    json!({
746        "name": name,
747        "description": description,
748        "inputSchema": input_schema
749    })
750}
751
752fn get_str<'a>(args: &'a Value, key: &str) -> std::result::Result<&'a str, (i32, String)> {
753    args.get(key)
754        .and_then(|v| v.as_str())
755        .ok_or((-32602, format!("Missing required parameter: '{key}'")))
756}