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