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    let mut tools =
332        crate::runtime::agent_tools::definitions(crate::runtime::agent_tools::ToolSurface::Mcp)
333            .into_iter()
334            .map(|definition| ToolDef {
335                name: definition.name,
336                description: definition.description,
337                command_id: definition.command_id,
338                input_schema: definition.input_schema,
339            })
340            .collect::<Vec<_>>();
341    tools.extend(vec![
342        ToolDef {
343            name: "reddb_collections",
344            command_id: "collections.list",
345            description: "List the names of collections visible to the connected identity.",
346            input_schema: schema(vec![], vec![]),
347        },
348        ToolDef {
349            name: "reddb_insert_row",
350            command_id: "collections.rows.create",
351            description: "Insert a table row into a collection.",
352            input_schema: schema_with_nested(
353                vec![
354                    ("collection", string_field("Target collection name")),
355                    ("data", object_field("Object with field name/value pairs to insert")),
356                    ("metadata", object_field("Optional metadata key/value pairs")),
357                ],
358                vec!["collection", "data"],
359            ),
360        },
361        ToolDef {
362            name: "reddb_insert_node",
363            command_id: "collections.nodes.create",
364            description: "Insert a graph node into a collection.",
365            input_schema: schema_with_nested(
366                vec![
367                    ("collection", string_field("Target collection name")),
368                    ("label", string_field("Node label (identifier)")),
369                    ("node_type", string_field("Optional node type classification")),
370                    ("properties", object_field("Optional node properties as key/value pairs")),
371                    ("metadata", object_field("Optional metadata key/value pairs")),
372                ],
373                vec!["collection", "label"],
374            ),
375        },
376        ToolDef {
377            name: "reddb_insert_edge",
378            command_id: "collections.edges.create",
379            description: "Insert a graph edge between two nodes.",
380            input_schema: schema_with_nested(
381                vec![
382                    ("collection", string_field("Target collection name")),
383                    ("label", string_field("Edge label (relationship type)")),
384                    ("from", integer_field("Source node entity ID")),
385                    ("to", integer_field("Target node entity ID")),
386                    ("weight", number_field("Optional edge weight (default 1.0)")),
387                    ("properties", object_field("Optional edge properties")),
388                    ("metadata", object_field("Optional metadata key/value pairs")),
389                ],
390                vec!["collection", "label", "from", "to"],
391            ),
392        },
393        ToolDef {
394            name: "reddb_insert_vector",
395            command_id: "collections.vectors.create",
396            description: "Insert a vector embedding into a collection.",
397            input_schema: schema_with_nested(
398                vec![
399                    ("collection", string_field("Target collection name")),
400                    ("dense", number_array_field("Dense vector (array of floats)")),
401                    ("content", string_field("Optional text content associated with the vector")),
402                    ("metadata", object_field("Optional metadata key/value pairs")),
403                ],
404                vec!["collection", "dense"],
405            ),
406        },
407        ToolDef {
408            name: "reddb_insert_document",
409            command_id: "collections.documents.create",
410            description: "Insert a JSON document into a collection.",
411            input_schema: schema_with_nested(
412                vec![
413                    ("collection", string_field("Target collection name")),
414                    ("body", object_field("JSON document body")),
415                    ("metadata", object_field("Optional metadata key/value pairs")),
416                ],
417                vec!["collection", "body"],
418            ),
419        },
420        ToolDef {
421            name: "reddb_kv_get",
422            command_id: "keyed.v1.kv",
423            description: "Get a value by key from a key-value collection.",
424            input_schema: schema(
425                vec![
426                    ("collection", "string", "Collection name"),
427                    ("key", "string", "Key to retrieve"),
428                ],
429                vec!["collection", "key"],
430            ),
431        },
432        ToolDef {
433            name: "reddb_kv_set",
434            command_id: "keyed.v1.kv",
435            description: "Set a key-value pair in a collection.",
436            input_schema: schema_with_nested(
437                vec![
438                    ("collection", string_field("Collection name")),
439                    ("key", string_field("Key to set")),
440                    ("value", {
441                        let mut f = Map::new();
442                        f.insert("description".to_string(), JsonValue::String("Value to store (string, number, boolean, or null)".to_string()));
443                        JsonValue::Object(f)
444                    }),
445                    ("tags", string_array_field("Optional KV invalidation tags")),
446                    ("metadata", object_field("Optional metadata key/value pairs")),
447                ],
448                vec!["collection", "key", "value"],
449            ),
450        },
451        ToolDef {
452            name: "reddb_kv_invalidate_tags",
453            command_id: "keyed.v1.kv",
454            description: "Delete every KV entry in a collection tagged with any listed tag.",
455            input_schema: schema_with_nested(
456                vec![
457                    ("collection", string_field("Collection name")),
458                    ("tags", string_array_field("Tags to invalidate")),
459                ],
460                vec!["collection", "tags"],
461            ),
462        },
463        ToolDef {
464            name: "reddb_config_get",
465            command_id: "keyed.v1.config",
466            description: "Get a Config value without resolving SecretRef targets.",
467            input_schema: schema(
468                vec![
469                    ("collection", "string", "Config collection name"),
470                    ("key", "string", "Config key to retrieve"),
471                ],
472                vec!["collection", "key"],
473            ),
474        },
475        ToolDef {
476            name: "reddb_config_put",
477            command_id: "keyed.v1.config",
478            description: "Set a Config value. TTL and counter operations are not supported for Config.",
479            input_schema: schema_with_nested(
480                vec![
481                    ("collection", string_field("Config collection name")),
482                    ("key", string_field("Config key to set")),
483                    ("value", {
484                        let mut f = Map::new();
485                        f.insert("description".to_string(), JsonValue::String("Value to store, or an object when paired with secret_ref".to_string()));
486                        JsonValue::Object(f)
487                    }),
488                    ("secret_ref", object_field("Optional { collection, key } Vault SecretRef")),
489                    ("tags", string_array_field("Optional Config tags")),
490                ],
491                vec!["collection", "key"],
492            ),
493        },
494        ToolDef {
495            name: "reddb_config_resolve",
496            command_id: "keyed.v1.config",
497            description: "Explicitly resolve a Config SecretRef. Requires the corresponding Vault unseal permission.",
498            input_schema: schema(
499                vec![
500                    ("collection", "string", "Config collection name"),
501                    ("key", "string", "Config key to resolve"),
502                ],
503                vec!["collection", "key"],
504            ),
505        },
506        ToolDef {
507            name: "reddb_vault_get",
508            command_id: "keyed.v1.vault",
509            description: "Get Vault metadata for a secret. Does not return plaintext.",
510            input_schema: schema(
511                vec![
512                    ("collection", "string", "Vault collection name"),
513                    ("key", "string", "Vault key to retrieve metadata for"),
514                ],
515                vec!["collection", "key"],
516            ),
517        },
518        ToolDef {
519            name: "reddb_vault_put",
520            command_id: "keyed.v1.vault",
521            description: "Store a sealed Vault secret. TTL and counter operations are not supported for Vault.",
522            input_schema: schema_with_nested(
523                vec![
524                    ("collection", string_field("Vault collection name")),
525                    ("key", string_field("Vault key to set")),
526                    ("value", {
527                        let mut f = Map::new();
528                        f.insert("description".to_string(), JsonValue::String("Secret value to seal".to_string()));
529                        JsonValue::Object(f)
530                    }),
531                    ("tags", string_array_field("Optional Vault tags")),
532                ],
533                vec!["collection", "key", "value"],
534            ),
535        },
536        ToolDef {
537            name: "reddb_vault_reveal",
538            command_id: "keyed.v1.vault",
539            description: "Explicitly reveal a Vault secret to a caller with vault:reveal permission.",
540            input_schema: schema(
541                vec![("collection", "string", "Vault collection name"), ("key", "string", "Vault key to reveal")],
542                vec!["collection", "key"],
543            ),
544        },
545        ToolDef {
546            name: "reddb_vault_unseal",
547            command_id: "keyed.v1.vault",
548            description: "Explicitly unseal a Vault secret and return plaintext to an authorized caller.",
549            input_schema: schema(
550                vec![
551                    ("collection", "string", "Vault collection name"),
552                    ("key", "string", "Vault key to unseal"),
553                ],
554                vec!["collection", "key"],
555            ),
556        },
557        ToolDef {
558            name: "reddb_delete",
559            command_id: "collections.entities.delete",
560            description: "Delete an entity by ID from a collection.",
561            input_schema: schema(
562                vec![
563                    ("collection", "string", "Collection name"),
564                    ("id", "integer", "Entity ID to delete"),
565                ],
566                vec!["collection", "id"],
567            ),
568        },
569        ToolDef {
570            name: "reddb_search_vector",
571            command_id: "collections.similar",
572            description: "Search for similar vectors using cosine similarity.",
573            input_schema: schema_with_nested(
574                vec![
575                    ("collection", string_field("Collection to search in")),
576                    ("vector", number_array_field("Query vector (array of floats)")),
577                    ("k", integer_field("Number of results to return (default 10)")),
578                    ("min_score", number_field("Minimum similarity score threshold (default 0.0)")),
579                ],
580                vec!["collection", "vector"],
581            ),
582        },
583        ToolDef {
584            name: "reddb_search_text",
585            command_id: "query.text_search",
586            description: "Full-text search across collections.",
587            input_schema: schema_with_nested(
588                vec![
589                    ("query", string_field("Search query string")),
590                    ("collections", {
591                        let mut items = Map::new();
592                        items.insert("type".to_string(), JsonValue::String("string".to_string()));
593                        let mut f = Map::new();
594                        f.insert("type".to_string(), JsonValue::String("array".to_string()));
595                        f.insert("items".to_string(), JsonValue::Object(items));
596                        f.insert("description".to_string(), JsonValue::String("Optional list of collections to search".to_string()));
597                        JsonValue::Object(f)
598                    }),
599                    ("limit", integer_field("Maximum number of results (default 10)")),
600                    ("fuzzy", boolean_field("Enable fuzzy matching (default false)")),
601                ],
602                vec!["query"],
603            ),
604        },
605        ToolDef {
606            name: "reddb_health",
607            command_id: "health.live",
608            description: "Check database health and return runtime statistics.",
609            input_schema: schema(vec![], vec![]),
610        },
611        ToolDef {
612            name: "reddb_graph_traverse",
613            command_id: "graph.traverse",
614            description: "Traverse the graph from a source node using BFS or DFS.",
615            input_schema: schema_with_nested(
616                vec![
617                    ("source", string_field("Source node label to start traversal from")),
618                    ("direction", string_field("Traversal direction: 'outgoing', 'incoming', or 'both' (default 'outgoing')")),
619                    ("max_depth", integer_field("Maximum traversal depth (default 3)")),
620                    ("strategy", string_field("Traversal strategy: 'bfs' or 'dfs' (default 'bfs')")),
621                ],
622                vec!["source"],
623            ),
624        },
625        ToolDef {
626            name: "reddb_graph_shortest_path",
627            command_id: "graph.shortest_path",
628            description: "Find the shortest path between two graph nodes.",
629            input_schema: schema_with_nested(
630                vec![
631                    ("source", string_field("Source node label")),
632                    ("target", string_field("Target node label")),
633                    ("direction", string_field("Edge direction: 'outgoing', 'incoming', or 'both' (default 'outgoing')")),
634                    (
635                        "algorithm",
636                        string_field(
637                            "Path algorithm: 'bfs', 'dijkstra', 'astar', or 'bellman_ford' (default 'bfs')",
638                        ),
639                    ),
640                ],
641                vec!["source", "target"],
642            ),
643        },
644        // Auth tools
645        ToolDef {
646            name: "reddb_auth_bootstrap",
647            command_id: "auth.bootstrap",
648            description: "Bootstrap the first admin user. Only works when no users exist yet. Returns the admin user and an API key.",
649            input_schema: schema(
650                vec![
651                    ("username", "string", "Admin username"),
652                    ("password", "string", "Admin password"),
653                ],
654                vec!["username", "password"],
655            ),
656        },
657        ToolDef {
658            name: "reddb_auth_create_user",
659            command_id: "auth.users.create",
660            description: "Create a new database user with a role (admin, write, or read).",
661            input_schema: schema(
662                vec![
663                    ("username", "string", "Username for the new user"),
664                    ("password", "string", "Password for the new user"),
665                    ("role", "string", "Role: 'admin', 'write', or 'read'"),
666                ],
667                vec!["username", "password", "role"],
668            ),
669        },
670        ToolDef {
671            name: "reddb_auth_login",
672            command_id: "auth.login",
673            description: "Login with username and password. Returns a session token.",
674            input_schema: schema(
675                vec![
676                    ("username", "string", "Username"),
677                    ("password", "string", "Password"),
678                ],
679                vec!["username", "password"],
680            ),
681        },
682        ToolDef {
683            name: "reddb_auth_create_api_key",
684            command_id: "auth.api_keys.create",
685            description: "Create a persistent API key for a user.",
686            input_schema: schema(
687                vec![
688                    ("username", "string", "Username to create the key for"),
689                    ("name", "string", "Human-readable label for the key"),
690                    ("role", "string", "Role for this key: 'admin', 'write', or 'read'"),
691                ],
692                vec!["username", "name", "role"],
693            ),
694        },
695        ToolDef {
696            name: "reddb_auth_list_users",
697            command_id: "auth.users.list",
698            description: "List all database users and their roles.",
699            input_schema: schema(vec![], vec![]),
700        },
701        // Update / Scan tools
702        ToolDef {
703            name: "reddb_update",
704            command_id: "collections.entities.patch",
705            description: "Update entities in a collection matching a filter.",
706            input_schema: schema_with_nested(
707                vec![
708                    ("collection", string_field("Collection name")),
709                    ("set", object_field("Key-value pairs to update")),
710                    (
711                        "where_filter",
712                        string_field(
713                            "Optional SQL WHERE clause (e.g., \"age > 21\")",
714                        ),
715                    ),
716                ],
717                vec!["collection", "set"],
718            ),
719        },
720        ToolDef {
721            name: "reddb_scan",
722            command_id: "collections.scan",
723            description: "Scan entities from a collection with pagination.",
724            input_schema: schema_with_nested(
725                vec![
726                    ("collection", string_field("Collection to scan")),
727                    ("limit", integer_field("Maximum number of results (default 10)")),
728                    ("offset", integer_field("Number of records to skip (default 0)")),
729                ],
730                vec!["collection"],
731            ),
732        },
733        // Graph analytics tools
734        ToolDef {
735            name: "reddb_graph_centrality",
736            command_id: "graph.analytics.centrality",
737            description: "Compute centrality scores for graph nodes. Algorithms: degree, closeness, betweenness, eigenvector, pagerank.",
738            input_schema: schema_with_nested(
739                vec![(
740                    "algorithm",
741                    string_field(
742                        "Centrality algorithm: 'degree', 'closeness', 'betweenness', 'eigenvector', 'pagerank'",
743                    ),
744                )],
745                vec!["algorithm"],
746            ),
747        },
748        ToolDef {
749            name: "reddb_graph_community",
750            command_id: "graph.analytics.community",
751            description: "Detect communities in the graph. Algorithms: label_propagation, louvain.",
752            input_schema: schema_with_nested(
753                vec![
754                    (
755                        "algorithm",
756                        string_field(
757                            "Community detection algorithm: 'label_propagation' or 'louvain'",
758                        ),
759                    ),
760                    (
761                        "max_iterations",
762                        integer_field("Maximum iterations (default 100)"),
763                    ),
764                ],
765                vec!["algorithm"],
766            ),
767        },
768        ToolDef {
769            name: "reddb_graph_components",
770            command_id: "graph.analytics.components",
771            description: "Find connected components in the graph.",
772            input_schema: schema_with_nested(
773                vec![(
774                    "mode",
775                    string_field(
776                        "Component mode: 'weakly_connected' or 'strongly_connected' (default 'weakly_connected')",
777                    ),
778                )],
779                vec![],
780            ),
781        },
782        ToolDef {
783            name: "reddb_graph_cycles",
784            command_id: "graph.analytics.cycles",
785            description: "Detect cycles in the graph.",
786            input_schema: schema_with_nested(
787                vec![
788                    (
789                        "max_length",
790                        integer_field("Maximum cycle length (default 10)"),
791                    ),
792                    (
793                        "max_cycles",
794                        integer_field("Maximum number of cycles to return (default 100)"),
795                    ),
796                ],
797                vec![],
798            ),
799        },
800        ToolDef {
801            name: "reddb_graph_clustering",
802            command_id: "graph.analytics.clustering",
803            description: "Compute clustering coefficient for the graph.",
804            input_schema: schema(vec![], vec![]),
805        },
806        // DDL tools
807        ToolDef {
808            name: "reddb_create_collection",
809            command_id: "collections.create",
810            description: "Create a new collection (table) in the database.",
811            input_schema: schema(
812                vec![("name", "string", "Collection name to create")],
813                vec!["name"],
814            ),
815        },
816        ToolDef {
817            name: "reddb_drop_collection",
818            command_id: "collections.drop",
819            description: "Drop (delete) a collection from the database.",
820            input_schema: schema(
821                vec![("name", "string", "Collection name to drop")],
822                vec!["name"],
823            ),
824        },
825        // RQL knowledge tools (ADR 0061): parse a submitted query through the
826        // real `reddb-io-rql` parser so an agent learns the dialect by
827        // submitting, not by guessing.
828        ToolDef {
829            name: "reddb_rql_validate",
830            command_id: "query.contract",
831            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.",
832            input_schema: schema(
833                vec![("rql", "string", "RQL query string to validate")],
834                vec!["rql"],
835            ),
836        },
837        ToolDef {
838            name: "reddb_rql_explain",
839            command_id: "query.explain",
840            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.",
841            input_schema: schema(
842                vec![("rql", "string", "RQL query string to parse and explain")],
843                vec!["rql"],
844            ),
845        },
846        // Type knowledge tool (ADR 0061): answer a type/cast lookup from the
847        // generated reddb-io-types catalog.
848        ToolDef {
849            name: "reddb_type_of",
850            command_id: "catalog.snapshot",
851            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.",
852            input_schema: schema_with_nested(
853                vec![
854                    ("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.")),
855                    ("value", {
856                        let mut f = Map::new();
857                        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()));
858                        f.insert(
859                            "anyOf".to_string(),
860                            JsonValue::Array(vec![
861                                type_field("null"),
862                                type_field("boolean"),
863                                type_field("number"),
864                                type_field("string"),
865                                type_field("array"),
866                                type_field("object"),
867                            ]),
868                        );
869                        JsonValue::Object(f)
870                    }),
871                ],
872                vec![],
873            ),
874        },
875        // Connection knowledge tool (ADR 0061): project the canonical
876        // reddb-wire connection-string parser into agent-readable transport
877        // and topology facts.
878        ToolDef {
879            name: "reddb_explain_connection",
880            command_id: "ops.capabilities",
881            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.",
882            input_schema: schema(
883                vec![(
884                    "uri",
885                    "string",
886                    "Connection URI to explain, e.g. memory://, file:///data.rdb, reds://host, grpc://host, or https://host",
887                )],
888                vec!["uri"],
889            ),
890        },
891    ]);
892    tools
893}
894
895#[cfg(test)]
896mod tests {
897    use super::*;
898
899    #[test]
900    fn embedded_identity_authorizes_the_full_tool_catalog() {
901        for tool in all_tools() {
902            authorize_tool(&tool.name, McpIdentityPosture::EmbeddedImplicitAdmin)
903                .unwrap_or_else(|err| panic!("embedded tool {} was denied: {err}", tool.name));
904        }
905        authorize_tool(
906            crate::runtime::ai::mcp_ask_tool::TOOL_NAME,
907            McpIdentityPosture::EmbeddedImplicitAdmin,
908        )
909        .expect("embedded reddb.ask is authorized");
910    }
911
912    #[test]
913    fn tool_descriptors_document_catalog_authorization_posture() {
914        for tool in all_tools() {
915            let description = tool.descriptor_description();
916            assert!(
917                description.contains("Embedded/local mode runs as implicit admin"),
918                "embedded posture missing from {}: {}",
919                tool.name,
920                description
921            );
922            assert!(
923                description.contains("Remote mode requires credentials"),
924                "remote posture missing from {}: {}",
925                tool.name,
926                description
927            );
928            assert!(
929                description.contains(tool.command_id),
930                "canonical command id {} missing from {} descriptor",
931                tool.command_id,
932                tool.name
933            );
934        }
935
936        let ask = ask_descriptor();
937        let ask_description = ask
938            .get("description")
939            .and_then(JsonValue::as_str)
940            .expect("reddb.ask description");
941        assert!(ask_description.contains("Embedded/local mode runs as implicit admin"));
942        assert!(ask_description.contains("Remote mode requires credentials"));
943        assert!(ask_description.contains("ai.ask"));
944    }
945
946    #[test]
947    fn test_all_tools_defined() {
948        let tools = all_tools();
949        assert!(tools.len() >= 24);
950        let names: Vec<&str> = tools.iter().map(|t| t.name).collect();
951        assert!(names.contains(&"reddb_query"));
952        assert!(names.contains(&"reddb_collections"));
953        assert!(names.contains(&"reddb_insert_row"));
954        assert!(names.contains(&"reddb_insert_node"));
955        assert!(names.contains(&"reddb_insert_edge"));
956        assert!(names.contains(&"reddb_insert_vector"));
957        assert!(names.contains(&"reddb_insert_document"));
958        assert!(names.contains(&"reddb_kv_get"));
959        assert!(names.contains(&"reddb_kv_set"));
960        assert!(names.contains(&"reddb_config_get"));
961        assert!(names.contains(&"reddb_config_put"));
962        assert!(names.contains(&"reddb_config_resolve"));
963        assert!(names.contains(&"reddb_vault_get"));
964        assert!(names.contains(&"reddb_vault_put"));
965        assert!(names.contains(&"reddb_vault_reveal"));
966        assert!(names.contains(&"reddb_vault_unseal"));
967        assert!(names.contains(&"reddb_delete"));
968        assert!(names.contains(&"reddb_search_vector"));
969        assert!(names.contains(&"reddb_search_text"));
970        assert!(names.contains(&"reddb_health"));
971        assert!(names.contains(&"reddb_graph_traverse"));
972        assert!(names.contains(&"reddb_graph_shortest_path"));
973        // New tools
974        assert!(names.contains(&"reddb_update"));
975        assert!(names.contains(&"reddb_scan"));
976        assert!(names.contains(&"reddb_graph_centrality"));
977        assert!(names.contains(&"reddb_graph_community"));
978        assert!(names.contains(&"reddb_graph_components"));
979        assert!(names.contains(&"reddb_graph_cycles"));
980        assert!(names.contains(&"reddb_graph_clustering"));
981        assert!(names.contains(&"reddb_create_collection"));
982        assert!(names.contains(&"reddb_drop_collection"));
983        // RQL knowledge tools (ADR 0061).
984        assert!(names.contains(&"reddb_rql_validate"));
985        assert!(names.contains(&"reddb_rql_explain"));
986        // Type knowledge tool (ADR 0061).
987        assert!(names.contains(&"reddb_type_of"));
988        // Connection knowledge tool (ADR 0061).
989        assert!(names.contains(&"reddb_explain_connection"));
990    }
991
992    #[test]
993    fn test_rql_validate_tool_schema() {
994        let tools = all_tools();
995        let tool = tools
996            .iter()
997            .find(|t| t.name == "reddb_rql_validate")
998            .expect("reddb_rql_validate registered");
999        let props = tool
1000            .input_schema
1001            .get("properties")
1002            .and_then(|v| v.as_object())
1003            .unwrap();
1004        assert!(props.contains_key("rql"));
1005        let required = tool
1006            .input_schema
1007            .get("required")
1008            .and_then(|v| v.as_array())
1009            .unwrap();
1010        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1011        assert!(required_strs.contains(&"rql"));
1012    }
1013
1014    #[test]
1015    fn test_rql_explain_tool_schema() {
1016        let tools = all_tools();
1017        let tool = tools
1018            .iter()
1019            .find(|t| t.name == "reddb_rql_explain")
1020            .expect("reddb_rql_explain registered");
1021        let props = tool
1022            .input_schema
1023            .get("properties")
1024            .and_then(|v| v.as_object())
1025            .unwrap();
1026        assert!(props.contains_key("rql"));
1027        let required = tool
1028            .input_schema
1029            .get("required")
1030            .and_then(|v| v.as_array())
1031            .unwrap();
1032        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1033        assert!(required_strs.contains(&"rql"));
1034    }
1035
1036    #[test]
1037    fn test_tool_schemas_have_type() {
1038        for tool in all_tools() {
1039            assert_eq!(
1040                tool.input_schema.get("type").and_then(|v| v.as_str()),
1041                Some("object"),
1042                "tool '{}' schema must have type=object",
1043                tool.name,
1044            );
1045        }
1046    }
1047
1048    #[test]
1049    fn test_query_tool_schema_exposes_optional_params_array() {
1050        let tools = all_tools();
1051        let tool = tools.iter().find(|t| t.name == "reddb_query").unwrap();
1052        let props = tool
1053            .input_schema
1054            .get("properties")
1055            .and_then(|v| v.as_object())
1056            .unwrap();
1057        let params = props.get("params").and_then(|v| v.as_object()).unwrap();
1058        assert_eq!(params.get("type").and_then(|v| v.as_str()), Some("array"));
1059        let accepted_param_types = params
1060            .get("items")
1061            .and_then(|v| v.get("anyOf"))
1062            .and_then(|v| v.as_array())
1063            .expect("query params enumerate the supported JSON value types");
1064        assert_eq!(accepted_param_types.len(), 6);
1065
1066        let required = tool
1067            .input_schema
1068            .get("required")
1069            .and_then(|v| v.as_array())
1070            .unwrap();
1071        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1072        assert!(required_strs.contains(&"sql"));
1073        assert!(!required_strs.contains(&"params"));
1074    }
1075
1076    #[test]
1077    fn test_update_tool_schema() {
1078        let tools = all_tools();
1079        let tool = tools.iter().find(|t| t.name == "reddb_update").unwrap();
1080        assert_eq!(tool.name, "reddb_update");
1081        let props = tool
1082            .input_schema
1083            .get("properties")
1084            .and_then(|v| v.as_object())
1085            .unwrap();
1086        assert!(props.contains_key("collection"));
1087        assert!(props.contains_key("set"));
1088        assert!(props.contains_key("where_filter"));
1089        let required = tool
1090            .input_schema
1091            .get("required")
1092            .and_then(|v| v.as_array())
1093            .unwrap();
1094        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1095        assert!(required_strs.contains(&"collection"));
1096        assert!(required_strs.contains(&"set"));
1097        assert!(!required_strs.contains(&"where_filter"));
1098    }
1099
1100    #[test]
1101    fn test_scan_tool_schema() {
1102        let tools = all_tools();
1103        let tool = tools.iter().find(|t| t.name == "reddb_scan").unwrap();
1104        let props = tool
1105            .input_schema
1106            .get("properties")
1107            .and_then(|v| v.as_object())
1108            .unwrap();
1109        assert!(props.contains_key("collection"));
1110        assert!(props.contains_key("limit"));
1111        assert!(props.contains_key("offset"));
1112        let required = tool
1113            .input_schema
1114            .get("required")
1115            .and_then(|v| v.as_array())
1116            .unwrap();
1117        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1118        assert!(required_strs.contains(&"collection"));
1119        // limit and offset are optional
1120        assert!(!required_strs.contains(&"limit"));
1121        assert!(!required_strs.contains(&"offset"));
1122    }
1123
1124    #[test]
1125    fn test_graph_centrality_tool_schema() {
1126        let tools = all_tools();
1127        let tool = tools
1128            .iter()
1129            .find(|t| t.name == "reddb_graph_centrality")
1130            .unwrap();
1131        let props = tool
1132            .input_schema
1133            .get("properties")
1134            .and_then(|v| v.as_object())
1135            .unwrap();
1136        assert!(props.contains_key("algorithm"));
1137        let required = tool
1138            .input_schema
1139            .get("required")
1140            .and_then(|v| v.as_array())
1141            .unwrap();
1142        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1143        assert!(required_strs.contains(&"algorithm"));
1144    }
1145
1146    #[test]
1147    fn test_graph_community_tool_schema() {
1148        let tools = all_tools();
1149        let tool = tools
1150            .iter()
1151            .find(|t| t.name == "reddb_graph_community")
1152            .unwrap();
1153        let props = tool
1154            .input_schema
1155            .get("properties")
1156            .and_then(|v| v.as_object())
1157            .unwrap();
1158        assert!(props.contains_key("algorithm"));
1159        assert!(props.contains_key("max_iterations"));
1160        let required = tool
1161            .input_schema
1162            .get("required")
1163            .and_then(|v| v.as_array())
1164            .unwrap();
1165        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1166        assert!(required_strs.contains(&"algorithm"));
1167        assert!(!required_strs.contains(&"max_iterations"));
1168    }
1169
1170    #[test]
1171    fn test_graph_components_tool_schema() {
1172        let tools = all_tools();
1173        let tool = tools
1174            .iter()
1175            .find(|t| t.name == "reddb_graph_components")
1176            .unwrap();
1177        let props = tool
1178            .input_schema
1179            .get("properties")
1180            .and_then(|v| v.as_object())
1181            .unwrap();
1182        assert!(props.contains_key("mode"));
1183        let required = tool
1184            .input_schema
1185            .get("required")
1186            .and_then(|v| v.as_array())
1187            .unwrap();
1188        // mode is optional (has default)
1189        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1190        assert!(required_strs.is_empty());
1191    }
1192
1193    #[test]
1194    fn test_graph_cycles_tool_schema() {
1195        let tools = all_tools();
1196        let tool = tools
1197            .iter()
1198            .find(|t| t.name == "reddb_graph_cycles")
1199            .unwrap();
1200        let props = tool
1201            .input_schema
1202            .get("properties")
1203            .and_then(|v| v.as_object())
1204            .unwrap();
1205        assert!(props.contains_key("max_length"));
1206        assert!(props.contains_key("max_cycles"));
1207        let required = tool
1208            .input_schema
1209            .get("required")
1210            .and_then(|v| v.as_array())
1211            .unwrap();
1212        // All optional
1213        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1214        assert!(required_strs.is_empty());
1215    }
1216
1217    #[test]
1218    fn test_graph_clustering_tool_schema() {
1219        let tools = all_tools();
1220        let tool = tools
1221            .iter()
1222            .find(|t| t.name == "reddb_graph_clustering")
1223            .unwrap();
1224        let props = tool
1225            .input_schema
1226            .get("properties")
1227            .and_then(|v| v.as_object())
1228            .unwrap();
1229        // No required properties - takes no arguments
1230        assert!(props.is_empty());
1231    }
1232
1233    #[test]
1234    fn test_create_collection_tool_schema() {
1235        let tools = all_tools();
1236        let tool = tools
1237            .iter()
1238            .find(|t| t.name == "reddb_create_collection")
1239            .unwrap();
1240        let props = tool
1241            .input_schema
1242            .get("properties")
1243            .and_then(|v| v.as_object())
1244            .unwrap();
1245        assert!(props.contains_key("name"));
1246        let name_type = props
1247            .get("name")
1248            .and_then(|v| v.get("type"))
1249            .and_then(|v| v.as_str());
1250        assert_eq!(name_type, Some("string"));
1251        let required = tool
1252            .input_schema
1253            .get("required")
1254            .and_then(|v| v.as_array())
1255            .unwrap();
1256        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1257        assert!(required_strs.contains(&"name"));
1258    }
1259
1260    #[test]
1261    fn test_drop_collection_tool_schema() {
1262        let tools = all_tools();
1263        let tool = tools
1264            .iter()
1265            .find(|t| t.name == "reddb_drop_collection")
1266            .unwrap();
1267        let props = tool
1268            .input_schema
1269            .get("properties")
1270            .and_then(|v| v.as_object())
1271            .unwrap();
1272        assert!(props.contains_key("name"));
1273        let required = tool
1274            .input_schema
1275            .get("required")
1276            .and_then(|v| v.as_array())
1277            .unwrap();
1278        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1279        assert!(required_strs.contains(&"name"));
1280    }
1281
1282    #[test]
1283    fn test_type_of_tool_schema() {
1284        let tools = all_tools();
1285        let tool = tools.iter().find(|t| t.name == "reddb_type_of").unwrap();
1286        let props = tool
1287            .input_schema
1288            .get("properties")
1289            .and_then(|v| v.as_object())
1290            .unwrap();
1291        // Accepts either a type name or a literal value; neither is required so
1292        // the handler can validate "exactly one of" and surface a clear error.
1293        assert!(props.contains_key("type"));
1294        assert!(props.contains_key("value"));
1295        let required = tool
1296            .input_schema
1297            .get("required")
1298            .and_then(|v| v.as_array())
1299            .unwrap();
1300        assert!(required.is_empty());
1301    }
1302}