Skip to main content

reddb_server/mcp/
tools.rs

1//! MCP tool definitions for RedDB.
2//!
3//! Each tool exposes a specific RedDB capability to AI agents with a
4//! typed JSON Schema input specification.
5
6use crate::application::{OperationContextFactory, OperationContextInput};
7use crate::json::{Map, Value as JsonValue};
8use crate::server::route_catalog::{
9    CommandAuthorizer, CommandPolicyEngine, CommandSpec, RouteAuth,
10};
11
12/// Canonical command behind the `reddb.ask` tool. That tool is built from the
13/// AI runtime's descriptor rather than from [`all_tools`], so its command id
14/// lives here instead of on a [`ToolDef`].
15pub const ASK_TOOL_COMMAND_ID: &str = "ai.ask";
16
17/// Definition of an MCP tool exposed by the RedDB server.
18pub struct ToolDef {
19    pub name: &'static str,
20    pub description: &'static str,
21    pub command_id: &'static str,
22    pub input_schema: JsonValue,
23}
24
25impl ToolDef {
26    pub fn descriptor_description(&self) -> String {
27        descriptor_description(self.name, self.description, self.command_id)
28    }
29}
30
31pub fn ask_descriptor() -> JsonValue {
32    let mut descriptor = crate::runtime::ai::mcp_ask_tool::descriptor();
33    let description = descriptor
34        .get("description")
35        .and_then(JsonValue::as_str)
36        .expect("reddb.ask descriptor has a description");
37    let decorated = descriptor_description(
38        crate::runtime::ai::mcp_ask_tool::TOOL_NAME,
39        description,
40        ASK_TOOL_COMMAND_ID,
41    );
42    let JsonValue::Object(object) = &mut descriptor else {
43        panic!("reddb.ask descriptor is an object");
44    };
45    object.insert("description".to_string(), JsonValue::String(decorated));
46    descriptor
47}
48
49fn descriptor_description(tool_name: &str, description: &str, command_id: &str) -> String {
50    let command = crate::server::command_catalog()
51        .command(command_id)
52        .unwrap_or_else(|| panic!("MCP tool {tool_name} maps to unknown command {command_id}"));
53    format!(
54        "{description}\n\nAuthorization: Embedded/local mode runs as implicit admin because the process owns the database. Remote mode requires credentials before dispatching canonical command `{}` ({}); the remote transport verifies those credentials and enforces IAM policy.",
55        command.id,
56        command_auth_label(command.auth),
57    )
58}
59
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub enum McpIdentityPosture {
62    EmbeddedImplicitAdmin,
63    RemoteCredentialsPresented,
64    RemoteCredentialsMissing,
65}
66
67struct McpCommandPolicyEngine {
68    posture: McpIdentityPosture,
69}
70
71impl CommandPolicyEngine for McpCommandPolicyEngine {
72    fn allows(
73        &self,
74        _operation_context: &crate::application::OperationContext,
75        _command: &CommandSpec,
76    ) -> bool {
77        // Remote MCP is a client-side proxy: only the remote endpoint can
78        // verify the presented credential and evaluate its IAM policies.
79        // This local catalog gate establishes the mandatory-credential
80        // posture before connector work; the forwarded transport request
81        // remains the authoritative policy decision.
82        !matches!(self.posture, McpIdentityPosture::RemoteCredentialsMissing)
83    }
84}
85
86pub fn authorize_tool(
87    tool_name: &str,
88    posture: McpIdentityPosture,
89) -> Result<&'static str, String> {
90    let command_id = if tool_name == crate::runtime::ai::mcp_ask_tool::TOOL_NAME {
91        ASK_TOOL_COMMAND_ID
92    } else {
93        all_tools()
94            .into_iter()
95            .find(|tool| tool.name == tool_name)
96            .map(|tool| tool.command_id)
97            .ok_or_else(|| format!("unknown tool: {tool_name}"))?
98    };
99    let principal = match posture {
100        McpIdentityPosture::EmbeddedImplicitAdmin => Some("mcp:embedded-admin".to_string()),
101        McpIdentityPosture::RemoteCredentialsPresented => {
102            Some("mcp:remote-credentials-presented".to_string())
103        }
104        McpIdentityPosture::RemoteCredentialsMissing => None,
105    };
106    let operation_context = OperationContextFactory::build(OperationContextInput {
107        principal,
108        ..OperationContextInput::default()
109    });
110    let engine = McpCommandPolicyEngine { posture };
111    CommandAuthorizer::new(crate::server::command_catalog(), &engine)
112        .authorize(&operation_context, command_id)
113        .map_err(|error| match posture {
114            McpIdentityPosture::RemoteCredentialsMissing => {
115                format!("remote MCP requires credentials: {error}")
116            }
117            _ => error.to_string(),
118        })?;
119    Ok(command_id)
120}
121
122fn command_auth_label(auth: RouteAuth) -> &'static str {
123    match auth {
124        RouteAuth::Undeclared => "undeclared",
125        RouteAuth::Public => "public",
126        RouteAuth::OptionalUser => "optional user",
127        RouteAuth::UserRequired => "user required",
128        RouteAuth::AdminToken => "admin token",
129        RouteAuth::OpsCapability(_) => "operations capability",
130        RouteAuth::StreamLease => "stream lease",
131    }
132}
133
134/// Definition of a read-only MCP knowledge resource (ADR 0061).
135///
136/// The body is produced lazily by `body()` from the engine's own authorities
137/// (e.g. `reddb_rql::knowledge` reads the lexer keyword set and the
138/// `reddb-io-types` function catalog), so the served text cannot drift from the
139/// engine and the resource carries no mutable state.
140pub struct ResourceDef {
141    pub uri: &'static str,
142    pub title: &'static str,
143    pub description: &'static str,
144    pub mime_type: &'static str,
145    pub body: fn() -> String,
146}
147
148/// The static set of knowledge resources `red mcp` serves. Each domain is
149/// generated from the engine's own authorities (ADR 0061): the RQL reference
150/// from `reddb-io-rql`, and the value-type catalog + multi-model map from
151/// `reddb-io-types`, and the connection model from `reddb-io-wire`.
152pub fn knowledge_resources() -> Vec<ResourceDef> {
153    vec![
154        ResourceDef {
155            uri: reddb_rql::knowledge::RESOURCE_URI,
156            title: reddb_rql::knowledge::RESOURCE_TITLE,
157            description: reddb_rql::knowledge::RESOURCE_DESCRIPTION,
158            mime_type: "text/markdown",
159            body: reddb_rql::knowledge::rql_reference_markdown,
160        },
161        ResourceDef {
162            uri: reddb_types::knowledge::RESOURCE_URI,
163            title: reddb_types::knowledge::RESOURCE_TITLE,
164            description: reddb_types::knowledge::RESOURCE_DESCRIPTION,
165            mime_type: "text/markdown",
166            body: reddb_types::knowledge::type_reference_markdown,
167        },
168        ResourceDef {
169            uri: reddb_wire::knowledge::RESOURCE_URI,
170            title: reddb_wire::knowledge::RESOURCE_TITLE,
171            description: reddb_wire::knowledge::RESOURCE_DESCRIPTION,
172            mime_type: "text/markdown",
173            body: reddb_wire::knowledge::connection_reference_markdown,
174        },
175    ]
176}
177
178/// Build a JSON Schema object from a list of field descriptors.
179fn schema(properties: Vec<(&str, &str, &str)>, required: Vec<&str>) -> JsonValue {
180    let mut props = Map::new();
181    for (name, field_type, description) in properties {
182        let mut field = Map::new();
183        field.insert(
184            "type".to_string(),
185            JsonValue::String(field_type.to_string()),
186        );
187        if !description.is_empty() {
188            field.insert(
189                "description".to_string(),
190                JsonValue::String(description.to_string()),
191            );
192        }
193        props.insert(name.to_string(), JsonValue::Object(field));
194    }
195
196    let mut obj = Map::new();
197    obj.insert("type".to_string(), JsonValue::String("object".to_string()));
198    obj.insert("properties".to_string(), JsonValue::Object(props));
199    obj.insert(
200        "required".to_string(),
201        JsonValue::Array(
202            required
203                .into_iter()
204                .map(|s| JsonValue::String(s.to_string()))
205                .collect(),
206        ),
207    );
208    obj.insert("additionalProperties".to_string(), JsonValue::Bool(false));
209    JsonValue::Object(obj)
210}
211
212/// Build a JSON Schema object that accepts items with flexible inner types.
213fn schema_with_nested(properties: Vec<(&str, JsonValue)>, required: Vec<&str>) -> JsonValue {
214    let mut props = Map::new();
215    for (name, descriptor) in properties {
216        props.insert(name.to_string(), descriptor);
217    }
218
219    let mut obj = Map::new();
220    obj.insert("type".to_string(), JsonValue::String("object".to_string()));
221    obj.insert("properties".to_string(), JsonValue::Object(props));
222    obj.insert(
223        "required".to_string(),
224        JsonValue::Array(
225            required
226                .into_iter()
227                .map(|s| JsonValue::String(s.to_string()))
228                .collect(),
229        ),
230    );
231    obj.insert("additionalProperties".to_string(), JsonValue::Bool(false));
232    JsonValue::Object(obj)
233}
234
235/// Simple string field descriptor.
236fn string_field(description: &str) -> JsonValue {
237    let mut f = Map::new();
238    f.insert("type".to_string(), JsonValue::String("string".to_string()));
239    f.insert(
240        "description".to_string(),
241        JsonValue::String(description.to_string()),
242    );
243    JsonValue::Object(f)
244}
245
246/// Simple number field descriptor.
247fn number_field(description: &str) -> JsonValue {
248    let mut f = Map::new();
249    f.insert("type".to_string(), JsonValue::String("number".to_string()));
250    f.insert(
251        "description".to_string(),
252        JsonValue::String(description.to_string()),
253    );
254    JsonValue::Object(f)
255}
256
257/// Simple integer field descriptor.
258fn integer_field(description: &str) -> JsonValue {
259    let mut f = Map::new();
260    f.insert("type".to_string(), JsonValue::String("integer".to_string()));
261    f.insert(
262        "description".to_string(),
263        JsonValue::String(description.to_string()),
264    );
265    JsonValue::Object(f)
266}
267
268/// Simple boolean field descriptor.
269fn boolean_field(description: &str) -> JsonValue {
270    let mut f = Map::new();
271    f.insert("type".to_string(), JsonValue::String("boolean".to_string()));
272    f.insert(
273        "description".to_string(),
274        JsonValue::String(description.to_string()),
275    );
276    JsonValue::Object(f)
277}
278
279fn type_field(field_type: &str) -> JsonValue {
280    let mut f = Map::new();
281    f.insert(
282        "type".to_string(),
283        JsonValue::String(field_type.to_string()),
284    );
285    JsonValue::Object(f)
286}
287
288/// Object field descriptor (accepts arbitrary JSON object).
289fn object_field(description: &str) -> JsonValue {
290    let mut f = Map::new();
291    f.insert("type".to_string(), JsonValue::String("object".to_string()));
292    f.insert(
293        "description".to_string(),
294        JsonValue::String(description.to_string()),
295    );
296    JsonValue::Object(f)
297}
298
299/// Array-of-numbers field descriptor.
300fn number_array_field(description: &str) -> JsonValue {
301    let mut items = Map::new();
302    items.insert("type".to_string(), JsonValue::String("number".to_string()));
303
304    let mut f = Map::new();
305    f.insert("type".to_string(), JsonValue::String("array".to_string()));
306    f.insert("items".to_string(), JsonValue::Object(items));
307    f.insert(
308        "description".to_string(),
309        JsonValue::String(description.to_string()),
310    );
311    JsonValue::Object(f)
312}
313
314/// Array-of-strings field descriptor.
315fn string_array_field(description: &str) -> JsonValue {
316    let mut items = Map::new();
317    items.insert("type".to_string(), JsonValue::String("string".to_string()));
318
319    let mut f = Map::new();
320    f.insert("type".to_string(), JsonValue::String("array".to_string()));
321    f.insert("items".to_string(), JsonValue::Object(items));
322    f.insert(
323        "description".to_string(),
324        JsonValue::String(description.to_string()),
325    );
326    JsonValue::Object(f)
327}
328
329/// Return all tool definitions exposed by the RedDB MCP server.
330pub fn all_tools() -> Vec<ToolDef> {
331    vec![
332        ToolDef {
333            name: "reddb_query",
334            command_id: "query.execute",
335            description: "Execute a SQL or universal query against RedDB. Supports SELECT, INSERT, UPDATE, DELETE, and graph queries (Gremlin, Cypher, SPARQL).\n\nALWAYS pass user-provided values via the `params` array using `$1`, `$2`, ... placeholders rather than interpolating them into the SQL string. Example: `{\"sql\": \"SELECT * FROM users WHERE id = $1\", \"params\": [42]}`. Interpolating user input directly is unsafe and brittle; the parameterized form is type-checked and immune to injection.",
336            input_schema: schema_with_nested(
337                vec![
338                    ("sql", string_field("SQL or universal query to execute. Use `$1`, `$2`, ... placeholders for any value that came from the user.")),
339                    ("params", {
340                        let mut items = Map::new();
341                        items.insert("description".to_string(), JsonValue::String("Bind value for the matching $N placeholder. Accepts null, boolean, number, string, array, or object.".to_string()));
342                        items.insert(
343                            "anyOf".to_string(),
344                            JsonValue::Array(vec![
345                                type_field("null"),
346                                type_field("boolean"),
347                                type_field("number"),
348                                type_field("string"),
349                                type_field("array"),
350                                type_field("object"),
351                            ]),
352                        );
353                        let mut f = Map::new();
354                        f.insert("type".to_string(), JsonValue::String("array".to_string()));
355                        f.insert("items".to_string(), JsonValue::Object(items));
356                        f.insert(
357                            "description".to_string(),
358                            JsonValue::String(
359                                "Positional bind values for `$1`, `$2`, ... in `sql`. Index 0 binds `$1`."
360                                    .to_string(),
361                            ),
362                        );
363                        JsonValue::Object(f)
364                    }),
365                ],
366                vec!["sql"],
367            ),
368        },
369        ToolDef {
370            name: "reddb_collections",
371            command_id: "collections.list",
372            description: "List all collections in the database.",
373            input_schema: schema(vec![], vec![]),
374        },
375        ToolDef {
376            name: "reddb_insert_row",
377            command_id: "collections.rows.create",
378            description: "Insert a table row into a collection.",
379            input_schema: schema_with_nested(
380                vec![
381                    ("collection", string_field("Target collection name")),
382                    ("data", object_field("Object with field name/value pairs to insert")),
383                    ("metadata", object_field("Optional metadata key/value pairs")),
384                ],
385                vec!["collection", "data"],
386            ),
387        },
388        ToolDef {
389            name: "reddb_insert_node",
390            command_id: "collections.nodes.create",
391            description: "Insert a graph node into a collection.",
392            input_schema: schema_with_nested(
393                vec![
394                    ("collection", string_field("Target collection name")),
395                    ("label", string_field("Node label (identifier)")),
396                    ("node_type", string_field("Optional node type classification")),
397                    ("properties", object_field("Optional node properties as key/value pairs")),
398                    ("metadata", object_field("Optional metadata key/value pairs")),
399                ],
400                vec!["collection", "label"],
401            ),
402        },
403        ToolDef {
404            name: "reddb_insert_edge",
405            command_id: "collections.edges.create",
406            description: "Insert a graph edge between two nodes.",
407            input_schema: schema_with_nested(
408                vec![
409                    ("collection", string_field("Target collection name")),
410                    ("label", string_field("Edge label (relationship type)")),
411                    ("from", integer_field("Source node entity ID")),
412                    ("to", integer_field("Target node entity ID")),
413                    ("weight", number_field("Optional edge weight (default 1.0)")),
414                    ("properties", object_field("Optional edge properties")),
415                    ("metadata", object_field("Optional metadata key/value pairs")),
416                ],
417                vec!["collection", "label", "from", "to"],
418            ),
419        },
420        ToolDef {
421            name: "reddb_insert_vector",
422            command_id: "collections.vectors.create",
423            description: "Insert a vector embedding into a collection.",
424            input_schema: schema_with_nested(
425                vec![
426                    ("collection", string_field("Target collection name")),
427                    ("dense", number_array_field("Dense vector (array of floats)")),
428                    ("content", string_field("Optional text content associated with the vector")),
429                    ("metadata", object_field("Optional metadata key/value pairs")),
430                ],
431                vec!["collection", "dense"],
432            ),
433        },
434        ToolDef {
435            name: "reddb_insert_document",
436            command_id: "collections.documents.create",
437            description: "Insert a JSON document into a collection.",
438            input_schema: schema_with_nested(
439                vec![
440                    ("collection", string_field("Target collection name")),
441                    ("body", object_field("JSON document body")),
442                    ("metadata", object_field("Optional metadata key/value pairs")),
443                ],
444                vec!["collection", "body"],
445            ),
446        },
447        ToolDef {
448            name: "reddb_kv_get",
449            command_id: "keyed.v1.kv",
450            description: "Get a value by key from a key-value collection.",
451            input_schema: schema(
452                vec![
453                    ("collection", "string", "Collection name"),
454                    ("key", "string", "Key to retrieve"),
455                ],
456                vec!["collection", "key"],
457            ),
458        },
459        ToolDef {
460            name: "reddb_kv_set",
461            command_id: "keyed.v1.kv",
462            description: "Set a key-value pair in a collection.",
463            input_schema: schema_with_nested(
464                vec![
465                    ("collection", string_field("Collection name")),
466                    ("key", string_field("Key to set")),
467                    ("value", {
468                        let mut f = Map::new();
469                        f.insert("description".to_string(), JsonValue::String("Value to store (string, number, boolean, or null)".to_string()));
470                        JsonValue::Object(f)
471                    }),
472                    ("tags", string_array_field("Optional KV invalidation tags")),
473                    ("metadata", object_field("Optional metadata key/value pairs")),
474                ],
475                vec!["collection", "key", "value"],
476            ),
477        },
478        ToolDef {
479            name: "reddb_kv_invalidate_tags",
480            command_id: "keyed.v1.kv",
481            description: "Delete every KV entry in a collection tagged with any listed tag.",
482            input_schema: schema_with_nested(
483                vec![
484                    ("collection", string_field("Collection name")),
485                    ("tags", string_array_field("Tags to invalidate")),
486                ],
487                vec!["collection", "tags"],
488            ),
489        },
490        ToolDef {
491            name: "reddb_config_get",
492            command_id: "keyed.v1.config",
493            description: "Get a Config value without resolving SecretRef targets.",
494            input_schema: schema(
495                vec![
496                    ("collection", "string", "Config collection name"),
497                    ("key", "string", "Config key to retrieve"),
498                ],
499                vec!["collection", "key"],
500            ),
501        },
502        ToolDef {
503            name: "reddb_config_put",
504            command_id: "keyed.v1.config",
505            description: "Set a Config value. TTL and counter operations are not supported for Config.",
506            input_schema: schema_with_nested(
507                vec![
508                    ("collection", string_field("Config collection name")),
509                    ("key", string_field("Config key to set")),
510                    ("value", {
511                        let mut f = Map::new();
512                        f.insert("description".to_string(), JsonValue::String("Value to store, or an object when paired with secret_ref".to_string()));
513                        JsonValue::Object(f)
514                    }),
515                    ("secret_ref", object_field("Optional { collection, key } Vault SecretRef")),
516                    ("tags", string_array_field("Optional Config tags")),
517                ],
518                vec!["collection", "key"],
519            ),
520        },
521        ToolDef {
522            name: "reddb_config_resolve",
523            command_id: "keyed.v1.config",
524            description: "Explicitly resolve a Config SecretRef. Requires the corresponding Vault unseal permission.",
525            input_schema: schema(
526                vec![
527                    ("collection", "string", "Config collection name"),
528                    ("key", "string", "Config key to resolve"),
529                ],
530                vec!["collection", "key"],
531            ),
532        },
533        ToolDef {
534            name: "reddb_vault_get",
535            command_id: "keyed.v1.vault",
536            description: "Get Vault metadata for a secret. Does not return plaintext.",
537            input_schema: schema(
538                vec![
539                    ("collection", "string", "Vault collection name"),
540                    ("key", "string", "Vault key to retrieve metadata for"),
541                ],
542                vec!["collection", "key"],
543            ),
544        },
545        ToolDef {
546            name: "reddb_vault_put",
547            command_id: "keyed.v1.vault",
548            description: "Store a sealed Vault secret. TTL and counter operations are not supported for Vault.",
549            input_schema: schema_with_nested(
550                vec![
551                    ("collection", string_field("Vault collection name")),
552                    ("key", string_field("Vault key to set")),
553                    ("value", {
554                        let mut f = Map::new();
555                        f.insert("description".to_string(), JsonValue::String("Secret value to seal".to_string()));
556                        JsonValue::Object(f)
557                    }),
558                    ("tags", string_array_field("Optional Vault tags")),
559                ],
560                vec!["collection", "key", "value"],
561            ),
562        },
563        ToolDef {
564            name: "reddb_vault_reveal",
565            command_id: "keyed.v1.vault",
566            description: "Explicitly reveal a Vault secret to a caller with vault:reveal permission.",
567            input_schema: schema(
568                vec![("collection", "string", "Vault collection name"), ("key", "string", "Vault key to reveal")],
569                vec!["collection", "key"],
570            ),
571        },
572        ToolDef {
573            name: "reddb_vault_unseal",
574            command_id: "keyed.v1.vault",
575            description: "Explicitly unseal a Vault secret and return plaintext to an authorized caller.",
576            input_schema: schema(
577                vec![
578                    ("collection", "string", "Vault collection name"),
579                    ("key", "string", "Vault key to unseal"),
580                ],
581                vec!["collection", "key"],
582            ),
583        },
584        ToolDef {
585            name: "reddb_delete",
586            command_id: "collections.entities.delete",
587            description: "Delete an entity by ID from a collection.",
588            input_schema: schema(
589                vec![
590                    ("collection", "string", "Collection name"),
591                    ("id", "integer", "Entity ID to delete"),
592                ],
593                vec!["collection", "id"],
594            ),
595        },
596        ToolDef {
597            name: "reddb_search_vector",
598            command_id: "collections.similar",
599            description: "Search for similar vectors using cosine similarity.",
600            input_schema: schema_with_nested(
601                vec![
602                    ("collection", string_field("Collection to search in")),
603                    ("vector", number_array_field("Query vector (array of floats)")),
604                    ("k", integer_field("Number of results to return (default 10)")),
605                    ("min_score", number_field("Minimum similarity score threshold (default 0.0)")),
606                ],
607                vec!["collection", "vector"],
608            ),
609        },
610        ToolDef {
611            name: "reddb_search_text",
612            command_id: "query.text_search",
613            description: "Full-text search across collections.",
614            input_schema: schema_with_nested(
615                vec![
616                    ("query", string_field("Search query string")),
617                    ("collections", {
618                        let mut items = Map::new();
619                        items.insert("type".to_string(), JsonValue::String("string".to_string()));
620                        let mut f = Map::new();
621                        f.insert("type".to_string(), JsonValue::String("array".to_string()));
622                        f.insert("items".to_string(), JsonValue::Object(items));
623                        f.insert("description".to_string(), JsonValue::String("Optional list of collections to search".to_string()));
624                        JsonValue::Object(f)
625                    }),
626                    ("limit", integer_field("Maximum number of results (default 10)")),
627                    ("fuzzy", boolean_field("Enable fuzzy matching (default false)")),
628                ],
629                vec!["query"],
630            ),
631        },
632        ToolDef {
633            name: "reddb_health",
634            command_id: "health.live",
635            description: "Check database health and return runtime statistics.",
636            input_schema: schema(vec![], vec![]),
637        },
638        ToolDef {
639            name: "reddb_graph_traverse",
640            command_id: "graph.traverse",
641            description: "Traverse the graph from a source node using BFS or DFS.",
642            input_schema: schema_with_nested(
643                vec![
644                    ("source", string_field("Source node label to start traversal from")),
645                    ("direction", string_field("Traversal direction: 'outgoing', 'incoming', or 'both' (default 'outgoing')")),
646                    ("max_depth", integer_field("Maximum traversal depth (default 3)")),
647                    ("strategy", string_field("Traversal strategy: 'bfs' or 'dfs' (default 'bfs')")),
648                ],
649                vec!["source"],
650            ),
651        },
652        ToolDef {
653            name: "reddb_graph_shortest_path",
654            command_id: "graph.shortest_path",
655            description: "Find the shortest path between two graph nodes.",
656            input_schema: schema_with_nested(
657                vec![
658                    ("source", string_field("Source node label")),
659                    ("target", string_field("Target node label")),
660                    ("direction", string_field("Edge direction: 'outgoing', 'incoming', or 'both' (default 'outgoing')")),
661                    (
662                        "algorithm",
663                        string_field(
664                            "Path algorithm: 'bfs', 'dijkstra', 'astar', or 'bellman_ford' (default 'bfs')",
665                        ),
666                    ),
667                ],
668                vec!["source", "target"],
669            ),
670        },
671        // Auth tools
672        ToolDef {
673            name: "reddb_auth_bootstrap",
674            command_id: "auth.bootstrap",
675            description: "Bootstrap the first admin user. Only works when no users exist yet. Returns the admin user and an API key.",
676            input_schema: schema(
677                vec![
678                    ("username", "string", "Admin username"),
679                    ("password", "string", "Admin password"),
680                ],
681                vec!["username", "password"],
682            ),
683        },
684        ToolDef {
685            name: "reddb_auth_create_user",
686            command_id: "auth.users.create",
687            description: "Create a new database user with a role (admin, write, or read).",
688            input_schema: schema(
689                vec![
690                    ("username", "string", "Username for the new user"),
691                    ("password", "string", "Password for the new user"),
692                    ("role", "string", "Role: 'admin', 'write', or 'read'"),
693                ],
694                vec!["username", "password", "role"],
695            ),
696        },
697        ToolDef {
698            name: "reddb_auth_login",
699            command_id: "auth.login",
700            description: "Login with username and password. Returns a session token.",
701            input_schema: schema(
702                vec![
703                    ("username", "string", "Username"),
704                    ("password", "string", "Password"),
705                ],
706                vec!["username", "password"],
707            ),
708        },
709        ToolDef {
710            name: "reddb_auth_create_api_key",
711            command_id: "auth.api_keys.create",
712            description: "Create a persistent API key for a user.",
713            input_schema: schema(
714                vec![
715                    ("username", "string", "Username to create the key for"),
716                    ("name", "string", "Human-readable label for the key"),
717                    ("role", "string", "Role for this key: 'admin', 'write', or 'read'"),
718                ],
719                vec!["username", "name", "role"],
720            ),
721        },
722        ToolDef {
723            name: "reddb_auth_list_users",
724            command_id: "auth.users.list",
725            description: "List all database users and their roles.",
726            input_schema: schema(vec![], vec![]),
727        },
728        // Update / Scan tools
729        ToolDef {
730            name: "reddb_update",
731            command_id: "collections.entities.patch",
732            description: "Update entities in a collection matching a filter.",
733            input_schema: schema_with_nested(
734                vec![
735                    ("collection", string_field("Collection name")),
736                    ("set", object_field("Key-value pairs to update")),
737                    (
738                        "where_filter",
739                        string_field(
740                            "Optional SQL WHERE clause (e.g., \"age > 21\")",
741                        ),
742                    ),
743                ],
744                vec!["collection", "set"],
745            ),
746        },
747        ToolDef {
748            name: "reddb_scan",
749            command_id: "collections.scan",
750            description: "Scan entities from a collection with pagination.",
751            input_schema: schema_with_nested(
752                vec![
753                    ("collection", string_field("Collection to scan")),
754                    ("limit", integer_field("Maximum number of results (default 10)")),
755                    ("offset", integer_field("Number of records to skip (default 0)")),
756                ],
757                vec!["collection"],
758            ),
759        },
760        // Graph analytics tools
761        ToolDef {
762            name: "reddb_graph_centrality",
763            command_id: "graph.analytics.centrality",
764            description: "Compute centrality scores for graph nodes. Algorithms: degree, closeness, betweenness, eigenvector, pagerank.",
765            input_schema: schema_with_nested(
766                vec![(
767                    "algorithm",
768                    string_field(
769                        "Centrality algorithm: 'degree', 'closeness', 'betweenness', 'eigenvector', 'pagerank'",
770                    ),
771                )],
772                vec!["algorithm"],
773            ),
774        },
775        ToolDef {
776            name: "reddb_graph_community",
777            command_id: "graph.analytics.community",
778            description: "Detect communities in the graph. Algorithms: label_propagation, louvain.",
779            input_schema: schema_with_nested(
780                vec![
781                    (
782                        "algorithm",
783                        string_field(
784                            "Community detection algorithm: 'label_propagation' or 'louvain'",
785                        ),
786                    ),
787                    (
788                        "max_iterations",
789                        integer_field("Maximum iterations (default 100)"),
790                    ),
791                ],
792                vec!["algorithm"],
793            ),
794        },
795        ToolDef {
796            name: "reddb_graph_components",
797            command_id: "graph.analytics.components",
798            description: "Find connected components in the graph.",
799            input_schema: schema_with_nested(
800                vec![(
801                    "mode",
802                    string_field(
803                        "Component mode: 'weakly_connected' or 'strongly_connected' (default 'weakly_connected')",
804                    ),
805                )],
806                vec![],
807            ),
808        },
809        ToolDef {
810            name: "reddb_graph_cycles",
811            command_id: "graph.analytics.cycles",
812            description: "Detect cycles in the graph.",
813            input_schema: schema_with_nested(
814                vec![
815                    (
816                        "max_length",
817                        integer_field("Maximum cycle length (default 10)"),
818                    ),
819                    (
820                        "max_cycles",
821                        integer_field("Maximum number of cycles to return (default 100)"),
822                    ),
823                ],
824                vec![],
825            ),
826        },
827        ToolDef {
828            name: "reddb_graph_clustering",
829            command_id: "graph.analytics.clustering",
830            description: "Compute clustering coefficient for the graph.",
831            input_schema: schema(vec![], vec![]),
832        },
833        // DDL tools
834        ToolDef {
835            name: "reddb_create_collection",
836            command_id: "collections.create",
837            description: "Create a new collection (table) in the database.",
838            input_schema: schema(
839                vec![("name", "string", "Collection name to create")],
840                vec!["name"],
841            ),
842        },
843        ToolDef {
844            name: "reddb_drop_collection",
845            command_id: "collections.drop",
846            description: "Drop (delete) a collection from the database.",
847            input_schema: schema(
848                vec![("name", "string", "Collection name to drop")],
849                vec!["name"],
850            ),
851        },
852        // RQL knowledge tools (ADR 0061): parse a submitted query through the
853        // real `reddb-io-rql` parser so an agent learns the dialect by
854        // submitting, not by guessing.
855        ToolDef {
856            name: "reddb_rql_validate",
857            command_id: "query.contract",
858            description: "Validate an RQL (RedDB Query Language) string by parsing it with the real reddb-io-rql parser. Returns `{ valid: true, statement }` with the statement kind on success, or `{ valid: false, error }` with a structured diagnostic (message, line, column, offset, kind, expected) on failure. Submit a query to learn the dialect instead of guessing.",
859            input_schema: schema(
860                vec![("rql", "string", "RQL query string to validate")],
861                vec!["rql"],
862            ),
863        },
864        ToolDef {
865            name: "reddb_rql_explain",
866            command_id: "query.explain",
867            description: "Explain an RQL (RedDB Query Language) string: parse it with the real reddb-io-rql parser and, when valid, also return its AST and the optimizer plan (`ast`, `optimized_ast`, `applied_passes`). Invalid input returns the same structured diagnostic as reddb_rql_validate with no AST/plan.",
868            input_schema: schema(
869                vec![("rql", "string", "RQL query string to parse and explain")],
870                vec!["rql"],
871            ),
872        },
873        // Type knowledge tool (ADR 0061): answer a type/cast lookup from the
874        // generated reddb-io-types catalog.
875        ToolDef {
876            name: "reddb_type_of",
877            command_id: "catalog.snapshot",
878            description: "Look up a RedDB value type from the generated `reddb-io-types` catalog. Given either a type name (`type`, e.g. \"INTEGER\" or the SQL alias \"int\") or a literal JSON value (`value`, e.g. 42 or true), returns the canonical type, its coercion category, the casts available out of it, and the operator overloads that accept it. Provide exactly one of `type` or `value`; `type` wins if both are given.\n\nThe answer is read live from the engine's function/operator/cast catalogs — it never drifts from the type system.",
879            input_schema: schema_with_nested(
880                vec![
881                    ("type", string_field("Type name to look up: a canonical RedDB type (e.g. \"INTEGER\", \"TEXT\") or a common SQL alias (e.g. \"int\", \"string\"), case-insensitive.")),
882                    ("value", {
883                        let mut f = Map::new();
884                        f.insert("description".to_string(), JsonValue::String("A literal JSON value (null, boolean, number, string, array, or object) whose canonical type to look up. Used only when `type` is omitted.".to_string()));
885                        f.insert(
886                            "anyOf".to_string(),
887                            JsonValue::Array(vec![
888                                type_field("null"),
889                                type_field("boolean"),
890                                type_field("number"),
891                                type_field("string"),
892                                type_field("array"),
893                                type_field("object"),
894                            ]),
895                        );
896                        JsonValue::Object(f)
897                    }),
898                ],
899                vec![],
900            ),
901        },
902        // Connection knowledge tool (ADR 0061): project the canonical
903        // reddb-wire connection-string parser into agent-readable transport
904        // and topology facts.
905        ToolDef {
906            name: "reddb_explain_connection",
907            command_id: "ops.capabilities",
908            description: "Decode a RedDB connection URI with the canonical `reddb-io-wire` parser and return its scheme, transport, mode (`embedded` or `remote`), topology, and normalized target details. Use this to learn the scheme dispatch: `memory://` is embedded ephemeral, `file://` is embedded persisted, `red://` / `reds://` are remote RedWire, and `grpc://` / `http(s)://` are remote non-RedWire transports.",
909            input_schema: schema(
910                vec![(
911                    "uri",
912                    "string",
913                    "Connection URI to explain, e.g. memory://, file:///data.rdb, reds://host, grpc://host, or https://host",
914                )],
915                vec!["uri"],
916            ),
917        },
918    ]
919}
920
921#[cfg(test)]
922mod tests {
923    use super::*;
924
925    #[test]
926    fn embedded_identity_authorizes_the_full_tool_catalog() {
927        for tool in all_tools() {
928            authorize_tool(&tool.name, McpIdentityPosture::EmbeddedImplicitAdmin)
929                .unwrap_or_else(|err| panic!("embedded tool {} was denied: {err}", tool.name));
930        }
931        authorize_tool(
932            crate::runtime::ai::mcp_ask_tool::TOOL_NAME,
933            McpIdentityPosture::EmbeddedImplicitAdmin,
934        )
935        .expect("embedded reddb.ask is authorized");
936    }
937
938    #[test]
939    fn tool_descriptors_document_catalog_authorization_posture() {
940        for tool in all_tools() {
941            let description = tool.descriptor_description();
942            assert!(
943                description.contains("Embedded/local mode runs as implicit admin"),
944                "embedded posture missing from {}: {}",
945                tool.name,
946                description
947            );
948            assert!(
949                description.contains("Remote mode requires credentials"),
950                "remote posture missing from {}: {}",
951                tool.name,
952                description
953            );
954            assert!(
955                description.contains(tool.command_id),
956                "canonical command id {} missing from {} descriptor",
957                tool.command_id,
958                tool.name
959            );
960        }
961
962        let ask = ask_descriptor();
963        let ask_description = ask
964            .get("description")
965            .and_then(JsonValue::as_str)
966            .expect("reddb.ask description");
967        assert!(ask_description.contains("Embedded/local mode runs as implicit admin"));
968        assert!(ask_description.contains("Remote mode requires credentials"));
969        assert!(ask_description.contains("ai.ask"));
970    }
971
972    #[test]
973    fn test_all_tools_defined() {
974        let tools = all_tools();
975        assert!(tools.len() >= 24);
976        let names: Vec<&str> = tools.iter().map(|t| t.name).collect();
977        assert!(names.contains(&"reddb_query"));
978        assert!(names.contains(&"reddb_collections"));
979        assert!(names.contains(&"reddb_insert_row"));
980        assert!(names.contains(&"reddb_insert_node"));
981        assert!(names.contains(&"reddb_insert_edge"));
982        assert!(names.contains(&"reddb_insert_vector"));
983        assert!(names.contains(&"reddb_insert_document"));
984        assert!(names.contains(&"reddb_kv_get"));
985        assert!(names.contains(&"reddb_kv_set"));
986        assert!(names.contains(&"reddb_config_get"));
987        assert!(names.contains(&"reddb_config_put"));
988        assert!(names.contains(&"reddb_config_resolve"));
989        assert!(names.contains(&"reddb_vault_get"));
990        assert!(names.contains(&"reddb_vault_put"));
991        assert!(names.contains(&"reddb_vault_reveal"));
992        assert!(names.contains(&"reddb_vault_unseal"));
993        assert!(names.contains(&"reddb_delete"));
994        assert!(names.contains(&"reddb_search_vector"));
995        assert!(names.contains(&"reddb_search_text"));
996        assert!(names.contains(&"reddb_health"));
997        assert!(names.contains(&"reddb_graph_traverse"));
998        assert!(names.contains(&"reddb_graph_shortest_path"));
999        // New tools
1000        assert!(names.contains(&"reddb_update"));
1001        assert!(names.contains(&"reddb_scan"));
1002        assert!(names.contains(&"reddb_graph_centrality"));
1003        assert!(names.contains(&"reddb_graph_community"));
1004        assert!(names.contains(&"reddb_graph_components"));
1005        assert!(names.contains(&"reddb_graph_cycles"));
1006        assert!(names.contains(&"reddb_graph_clustering"));
1007        assert!(names.contains(&"reddb_create_collection"));
1008        assert!(names.contains(&"reddb_drop_collection"));
1009        // RQL knowledge tools (ADR 0061).
1010        assert!(names.contains(&"reddb_rql_validate"));
1011        assert!(names.contains(&"reddb_rql_explain"));
1012        // Type knowledge tool (ADR 0061).
1013        assert!(names.contains(&"reddb_type_of"));
1014        // Connection knowledge tool (ADR 0061).
1015        assert!(names.contains(&"reddb_explain_connection"));
1016    }
1017
1018    #[test]
1019    fn test_rql_validate_tool_schema() {
1020        let tools = all_tools();
1021        let tool = tools
1022            .iter()
1023            .find(|t| t.name == "reddb_rql_validate")
1024            .expect("reddb_rql_validate registered");
1025        let props = tool
1026            .input_schema
1027            .get("properties")
1028            .and_then(|v| v.as_object())
1029            .unwrap();
1030        assert!(props.contains_key("rql"));
1031        let required = tool
1032            .input_schema
1033            .get("required")
1034            .and_then(|v| v.as_array())
1035            .unwrap();
1036        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1037        assert!(required_strs.contains(&"rql"));
1038    }
1039
1040    #[test]
1041    fn test_rql_explain_tool_schema() {
1042        let tools = all_tools();
1043        let tool = tools
1044            .iter()
1045            .find(|t| t.name == "reddb_rql_explain")
1046            .expect("reddb_rql_explain registered");
1047        let props = tool
1048            .input_schema
1049            .get("properties")
1050            .and_then(|v| v.as_object())
1051            .unwrap();
1052        assert!(props.contains_key("rql"));
1053        let required = tool
1054            .input_schema
1055            .get("required")
1056            .and_then(|v| v.as_array())
1057            .unwrap();
1058        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1059        assert!(required_strs.contains(&"rql"));
1060    }
1061
1062    #[test]
1063    fn test_tool_schemas_have_type() {
1064        for tool in all_tools() {
1065            assert_eq!(
1066                tool.input_schema.get("type").and_then(|v| v.as_str()),
1067                Some("object"),
1068                "tool '{}' schema must have type=object",
1069                tool.name,
1070            );
1071        }
1072    }
1073
1074    #[test]
1075    fn test_query_tool_schema_exposes_optional_params_array() {
1076        let tools = all_tools();
1077        let tool = tools.iter().find(|t| t.name == "reddb_query").unwrap();
1078        let props = tool
1079            .input_schema
1080            .get("properties")
1081            .and_then(|v| v.as_object())
1082            .unwrap();
1083        let params = props.get("params").and_then(|v| v.as_object()).unwrap();
1084        assert_eq!(params.get("type").and_then(|v| v.as_str()), Some("array"));
1085
1086        let required = tool
1087            .input_schema
1088            .get("required")
1089            .and_then(|v| v.as_array())
1090            .unwrap();
1091        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1092        assert!(required_strs.contains(&"sql"));
1093        assert!(!required_strs.contains(&"params"));
1094    }
1095
1096    #[test]
1097    fn test_update_tool_schema() {
1098        let tools = all_tools();
1099        let tool = tools.iter().find(|t| t.name == "reddb_update").unwrap();
1100        assert_eq!(tool.name, "reddb_update");
1101        let props = tool
1102            .input_schema
1103            .get("properties")
1104            .and_then(|v| v.as_object())
1105            .unwrap();
1106        assert!(props.contains_key("collection"));
1107        assert!(props.contains_key("set"));
1108        assert!(props.contains_key("where_filter"));
1109        let required = tool
1110            .input_schema
1111            .get("required")
1112            .and_then(|v| v.as_array())
1113            .unwrap();
1114        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1115        assert!(required_strs.contains(&"collection"));
1116        assert!(required_strs.contains(&"set"));
1117        assert!(!required_strs.contains(&"where_filter"));
1118    }
1119
1120    #[test]
1121    fn test_scan_tool_schema() {
1122        let tools = all_tools();
1123        let tool = tools.iter().find(|t| t.name == "reddb_scan").unwrap();
1124        let props = tool
1125            .input_schema
1126            .get("properties")
1127            .and_then(|v| v.as_object())
1128            .unwrap();
1129        assert!(props.contains_key("collection"));
1130        assert!(props.contains_key("limit"));
1131        assert!(props.contains_key("offset"));
1132        let required = tool
1133            .input_schema
1134            .get("required")
1135            .and_then(|v| v.as_array())
1136            .unwrap();
1137        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1138        assert!(required_strs.contains(&"collection"));
1139        // limit and offset are optional
1140        assert!(!required_strs.contains(&"limit"));
1141        assert!(!required_strs.contains(&"offset"));
1142    }
1143
1144    #[test]
1145    fn test_graph_centrality_tool_schema() {
1146        let tools = all_tools();
1147        let tool = tools
1148            .iter()
1149            .find(|t| t.name == "reddb_graph_centrality")
1150            .unwrap();
1151        let props = tool
1152            .input_schema
1153            .get("properties")
1154            .and_then(|v| v.as_object())
1155            .unwrap();
1156        assert!(props.contains_key("algorithm"));
1157        let required = tool
1158            .input_schema
1159            .get("required")
1160            .and_then(|v| v.as_array())
1161            .unwrap();
1162        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1163        assert!(required_strs.contains(&"algorithm"));
1164    }
1165
1166    #[test]
1167    fn test_graph_community_tool_schema() {
1168        let tools = all_tools();
1169        let tool = tools
1170            .iter()
1171            .find(|t| t.name == "reddb_graph_community")
1172            .unwrap();
1173        let props = tool
1174            .input_schema
1175            .get("properties")
1176            .and_then(|v| v.as_object())
1177            .unwrap();
1178        assert!(props.contains_key("algorithm"));
1179        assert!(props.contains_key("max_iterations"));
1180        let required = tool
1181            .input_schema
1182            .get("required")
1183            .and_then(|v| v.as_array())
1184            .unwrap();
1185        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1186        assert!(required_strs.contains(&"algorithm"));
1187        assert!(!required_strs.contains(&"max_iterations"));
1188    }
1189
1190    #[test]
1191    fn test_graph_components_tool_schema() {
1192        let tools = all_tools();
1193        let tool = tools
1194            .iter()
1195            .find(|t| t.name == "reddb_graph_components")
1196            .unwrap();
1197        let props = tool
1198            .input_schema
1199            .get("properties")
1200            .and_then(|v| v.as_object())
1201            .unwrap();
1202        assert!(props.contains_key("mode"));
1203        let required = tool
1204            .input_schema
1205            .get("required")
1206            .and_then(|v| v.as_array())
1207            .unwrap();
1208        // mode is optional (has default)
1209        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1210        assert!(required_strs.is_empty());
1211    }
1212
1213    #[test]
1214    fn test_graph_cycles_tool_schema() {
1215        let tools = all_tools();
1216        let tool = tools
1217            .iter()
1218            .find(|t| t.name == "reddb_graph_cycles")
1219            .unwrap();
1220        let props = tool
1221            .input_schema
1222            .get("properties")
1223            .and_then(|v| v.as_object())
1224            .unwrap();
1225        assert!(props.contains_key("max_length"));
1226        assert!(props.contains_key("max_cycles"));
1227        let required = tool
1228            .input_schema
1229            .get("required")
1230            .and_then(|v| v.as_array())
1231            .unwrap();
1232        // All optional
1233        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1234        assert!(required_strs.is_empty());
1235    }
1236
1237    #[test]
1238    fn test_graph_clustering_tool_schema() {
1239        let tools = all_tools();
1240        let tool = tools
1241            .iter()
1242            .find(|t| t.name == "reddb_graph_clustering")
1243            .unwrap();
1244        let props = tool
1245            .input_schema
1246            .get("properties")
1247            .and_then(|v| v.as_object())
1248            .unwrap();
1249        // No required properties - takes no arguments
1250        assert!(props.is_empty());
1251    }
1252
1253    #[test]
1254    fn test_create_collection_tool_schema() {
1255        let tools = all_tools();
1256        let tool = tools
1257            .iter()
1258            .find(|t| t.name == "reddb_create_collection")
1259            .unwrap();
1260        let props = tool
1261            .input_schema
1262            .get("properties")
1263            .and_then(|v| v.as_object())
1264            .unwrap();
1265        assert!(props.contains_key("name"));
1266        let name_type = props
1267            .get("name")
1268            .and_then(|v| v.get("type"))
1269            .and_then(|v| v.as_str());
1270        assert_eq!(name_type, Some("string"));
1271        let required = tool
1272            .input_schema
1273            .get("required")
1274            .and_then(|v| v.as_array())
1275            .unwrap();
1276        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1277        assert!(required_strs.contains(&"name"));
1278    }
1279
1280    #[test]
1281    fn test_drop_collection_tool_schema() {
1282        let tools = all_tools();
1283        let tool = tools
1284            .iter()
1285            .find(|t| t.name == "reddb_drop_collection")
1286            .unwrap();
1287        let props = tool
1288            .input_schema
1289            .get("properties")
1290            .and_then(|v| v.as_object())
1291            .unwrap();
1292        assert!(props.contains_key("name"));
1293        let required = tool
1294            .input_schema
1295            .get("required")
1296            .and_then(|v| v.as_array())
1297            .unwrap();
1298        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1299        assert!(required_strs.contains(&"name"));
1300    }
1301
1302    #[test]
1303    fn test_type_of_tool_schema() {
1304        let tools = all_tools();
1305        let tool = tools.iter().find(|t| t.name == "reddb_type_of").unwrap();
1306        let props = tool
1307            .input_schema
1308            .get("properties")
1309            .and_then(|v| v.as_object())
1310            .unwrap();
1311        // Accepts either a type name or a literal value; neither is required so
1312        // the handler can validate "exactly one of" and surface a clear error.
1313        assert!(props.contains_key("type"));
1314        assert!(props.contains_key("value"));
1315        let required = tool
1316            .input_schema
1317            .get("required")
1318            .and_then(|v| v.as_array())
1319            .unwrap();
1320        assert!(required.is_empty());
1321    }
1322}