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