diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index c77583c58d..5b3e5b1bfd 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -394,6 +394,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil | 11.1.2 | Actionable Item Extraction | VU | `app/src/components/intelligence/__tests__/utils.test.ts` (this PR) | ✅ | Was ❌ | | 11.1.3 | Analyze Trigger | WD | `app/test/e2e/specs/insights-dashboard.spec.ts` mounts the route (this PR); explicit analyze-handler invocation TBD | 🟡 | Route mounts and search/filter UI assert — full analyze trigger flow tracked as follow-up | | 11.1.4 | MCP stdio server | RU | `src/openhuman/mcp_server/` | ✅ | Read-only initialize/tools/list/tools/call plus stdio framing; binary smoke in PR validation | +| 11.1.5 | Global tool registry | RI | `src/openhuman/tool_registry/`, `tests/json_rpc_e2e.rs` | ✅ | Read-only MCP/controller discovery with routes, schemas, version, allowed agents, and health | ### 11.2 Insights Dashboard @@ -476,11 +477,11 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil | Status | Count | | ---------------- | ------------------------------------------------ | -| ✅ Covered | 64 | +| ✅ Covered | 65 | | 🟡 Partial | 27 | | ❌ Missing | 27 | | 🚫 Manual smoke | 11 | -| **Total leaves** | **129 explicit + nested = 200 product features** | +| **Total leaves** | **130 explicit + nested = 201 product features** | PR-A delta: 13 leaves moved from ❌ → ✅ via 5 WDIO specs + 2 Vitest + 1 Rust integration test. Remaining gaps tracked under sub-issues #965 (process), #966 (docs), #967 (tools), #968 (auth/perm), #969 (settings), #970 (rewards), #971 (manual smoke). diff --git a/gitbooks/developing/mcp-server.md b/gitbooks/developing/mcp-server.md index aaad82a156..c74b8f38cd 100644 --- a/gitbooks/developing/mcp-server.md +++ b/gitbooks/developing/mcp-server.md @@ -36,6 +36,21 @@ accepts optional `source_kinds`, `source_ids`, `entity_ids`, `since_ms`, `until_ms`, `query`, `k`, and `offset`. `tree.top_entities` accepts optional `kind` and `k`. `tree.list_sources` accepts an optional `user_email_hint`. +## Tool Registry + +The HTTP JSON-RPC server also exposes a read-only global tool registry for +agents and dashboards that need discovery metadata without opening an MCP stdio +session: + +| RPC method | Purpose | +| --- | --- | +| `openhuman.tool_registry_list` | List MCP stdio tools and controller-backed tools with stable `tool_id`, route, version, input/output schemas, allowed agents, tags, enabled state, and health. | +| `openhuman.tool_registry_get` | Return one registry entry by `tool_id`, for example `memory.search` or `tools.web_search`. | + +The registry is discovery-only. It does not change tool dispatch or permission +checks; MCP calls still go through `tools/call`, and controller-backed tools +still route through their existing JSON-RPC methods. + ## Smoke Test ```bash @@ -46,9 +61,9 @@ printf '%s\n' \ | openhuman-core mcp ``` -The response should include `capabilities.tools` from `initialize` and all six -tool names from `tools/list`. A successful run writes exactly two compact JSON -response lines to stdout; the `notifications/initialized` message is a +The response should include `capabilities.tools` from `initialize` and the +curated tool names from `tools/list`. A successful run writes exactly two compact +JSON response lines to stdout; the `notifications/initialized` message is a notification and has no response. ```text diff --git a/scripts/feature-ids.json b/scripts/feature-ids.json index e086c50d7e..7f9d7e2576 100644 --- a/scripts/feature-ids.json +++ b/scripts/feature-ids.json @@ -120,6 +120,7 @@ "11.1.2", "11.1.3", "11.1.4", + "11.1.5", "11.2.1", "11.2.2", "11.2.3", diff --git a/src/core/all.rs b/src/core/all.rs index 7185a5ed3b..2d6500db68 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -171,6 +171,8 @@ fn build_registered_controllers() -> Vec { controllers.extend(crate::openhuman::workspace::all_workspace_registered_controllers()); // Skill tool registry controllers.extend(crate::openhuman::tools::all_tools_registered_controllers()); + // Unified read-only registry across MCP stdio tools and controller-backed tools + controllers.extend(crate::openhuman::tool_registry::all_tool_registry_registered_controllers()); // Document and knowledge graph storage controllers.extend(crate::openhuman::memory::all_memory_registered_controllers()); // Memory tree ingestion layer (#707 — canonicalised chunks with provenance) @@ -291,6 +293,7 @@ fn build_declared_controller_schemas() -> Vec { schemas.extend(crate::openhuman::skills::all_skills_controller_schemas()); schemas.extend(crate::openhuman::workspace::all_workspace_controller_schemas()); schemas.extend(crate::openhuman::tools::all_tools_controller_schemas()); + schemas.extend(crate::openhuman::tool_registry::all_tool_registry_controller_schemas()); schemas.extend(crate::openhuman::memory::all_memory_controller_schemas()); schemas.extend(crate::openhuman::memory::all_memory_tree_controller_schemas()); schemas.extend(crate::openhuman::memory::all_retrieval_controller_schemas()); @@ -392,6 +395,9 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> { "referral" => Some("Referral codes, stats, and apply flows via the hosted backend API."), "billing" => Some("Subscription plan, payment links, and credit top-up via the backend."), "team" => Some("Team member management, invites, and role changes via the backend."), + "tool_registry" => Some( + "Read-only discovery for MCP stdio tools and controller-backed tools, including routes, schemas, version, allowed agents, and health.", + ), "test" => Some( "E2E test support — wipe sidecar state in-place between specs.", ), diff --git a/src/core/all_tests.rs b/src/core/all_tests.rs index c3c3ec0668..140a037e43 100644 --- a/src/core/all_tests.rs +++ b/src/core/all_tests.rs @@ -110,6 +110,7 @@ fn namespace_description_known_namespaces() { assert!(namespace_description("config").is_some()); assert!(namespace_description("health").is_some()); assert!(namespace_description("security").is_some()); + assert!(namespace_description("tool_registry").is_some()); assert!(namespace_description("voice").is_some()); assert!(namespace_description("webhooks").is_some()); assert!(namespace_description("notification").is_some()); diff --git a/src/openhuman/about_app/catalog.rs b/src/openhuman/about_app/catalog.rs index a04c8a52f5..2a47f64d6e 100644 --- a/src/openhuman/about_app/catalog.rs +++ b/src/openhuman/about_app/catalog.rs @@ -276,6 +276,16 @@ const CAPABILITIES: &[Capability] = &[ status: CapabilityStatus::Beta, privacy: LOCAL_RAW, }, + Capability { + id: "intelligence.tool_registry", + name: "Tool Registry", + domain: "intelligence", + category: CapabilityCategory::Intelligence, + description: "Discover OpenHuman's MCP stdio tools and controller-backed tools from one local registry, including versions, routes, input/output schemas, allowed agents, and health state.", + how_to: "Call openhuman.tool_registry_list over core JSON-RPC, or openhuman.tool_registry_get with a tool_id such as memory.search.", + status: CapabilityStatus::Beta, + privacy: LOCAL_RAW, + }, Capability { id: "intelligence.orchestrator_worker_thread", name: "Worker Thread Delegation", diff --git a/src/openhuman/about_app/catalog_tests.rs b/src/openhuman/about_app/catalog_tests.rs index 28f707a034..33eb453927 100644 --- a/src/openhuman/about_app/catalog_tests.rs +++ b/src/openhuman/about_app/catalog_tests.rs @@ -101,6 +101,7 @@ fn catalog_includes_additional_user_facing_surfaces() { "meet.join_call", "meet_agent.live_loop", "intelligence.mcp_server", + "intelligence.tool_registry", ] { assert!( ids.contains(expected), diff --git a/src/openhuman/mcp_server/mod.rs b/src/openhuman/mcp_server/mod.rs index 7ee737a486..14b67a0f6c 100644 --- a/src/openhuman/mcp_server/mod.rs +++ b/src/openhuman/mcp_server/mod.rs @@ -8,3 +8,4 @@ mod stdio; mod tools; pub use stdio::run_stdio_from_cli; +pub use tools::{tool_specs, McpToolSpec}; diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 6d3d85a8a6..7dbe1234d8 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -72,6 +72,7 @@ pub mod text_input; pub mod threads; pub mod todos; pub mod tokenjuice; +pub mod tool_registry; pub mod tool_timeout; pub mod tools; pub mod tree_summarizer; diff --git a/src/openhuman/tool_registry/mod.rs b/src/openhuman/tool_registry/mod.rs new file mode 100644 index 0000000000..2116419f67 --- /dev/null +++ b/src/openhuman/tool_registry/mod.rs @@ -0,0 +1,12 @@ +//! Unified read-only tool registry for discovery across OpenHuman tool surfaces. + +pub mod ops; +mod schemas; +mod types; + +pub use ops::{get_tool, list_tools, registry_entries}; +pub use schemas::{ + all_controller_schemas as all_tool_registry_controller_schemas, + all_registered_controllers as all_tool_registry_registered_controllers, +}; +pub use types::{ToolRegistryEntry, ToolRegistryHealth, ToolRegistryList, ToolRegistryTransport}; diff --git a/src/openhuman/tool_registry/ops.rs b/src/openhuman/tool_registry/ops.rs new file mode 100644 index 0000000000..91c5da9711 --- /dev/null +++ b/src/openhuman/tool_registry/ops.rs @@ -0,0 +1,358 @@ +use std::collections::BTreeMap; + +use serde_json::{json, Map, Value}; + +use crate::core::all; +use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; +use crate::openhuman::mcp_server::McpToolSpec; +use crate::rpc::RpcOutcome; + +use super::types::{ + ToolRegistryEntry, ToolRegistryHealth, ToolRegistryList, ToolRegistryTransport, +}; + +const REGISTRY_ENTRY_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Return the current read-only tool registry snapshot. +pub fn list_tools() -> RpcOutcome { + let tools = registry_entries(); + log::debug!( + "[tool_registry] list_tools completed entries={}", + tools.len() + ); + RpcOutcome::new(ToolRegistryList { tools }, vec![]) +} + +/// Look up one registry entry by stable `tool_id`. +pub fn get_tool(tool_id: &str) -> Result, String> { + let normalized = tool_id.trim(); + if normalized.is_empty() { + return Err("tool_id must be a non-empty string".to_string()); + } + + let tool = registry_entries() + .into_iter() + .find(|entry| entry.tool_id == normalized) + .ok_or_else(|| format!("tool not found in registry: {normalized}"))?; + + log::debug!( + "[tool_registry] get_tool completed tool_id={} transport={:?}", + tool.tool_id, + tool.transport + ); + Ok(RpcOutcome::new(tool, vec![])) +} + +/// Build sorted registry entries from the current MCP and controller metadata. +pub fn registry_entries() -> Vec { + let mut entries = BTreeMap::new(); + + for spec in crate::openhuman::mcp_server::tool_specs() { + let entry = mcp_tool_entry(spec); + insert_registry_entry(&mut entries, entry, "mcp_stdio"); + } + + for schema in crate::openhuman::tools::all_tools_controller_schemas() { + let entry = controller_tool_entry(&schema); + insert_registry_entry(&mut entries, entry, "controller"); + } + + entries.into_values().collect() +} + +fn insert_registry_entry( + entries: &mut BTreeMap, + entry: ToolRegistryEntry, + source: &str, +) { + let key = entry.tool_id.clone(); + assert!( + entries.insert(key.clone(), entry).is_none(), + "duplicate tool_id in registry: {key} from {source}" + ); +} + +fn mcp_tool_entry(spec: McpToolSpec) -> ToolRegistryEntry { + let tool_id = spec.name.to_string(); + ToolRegistryEntry { + tool_id: tool_id.clone(), + name: spec.name.to_string(), + title: spec.title.to_string(), + description: spec.description.to_string(), + version: REGISTRY_ENTRY_VERSION.to_string(), + transport: ToolRegistryTransport::McpStdio, + route: json!({ + "protocol": "mcp", + "method": "tools/call", + "tool": spec.name, + "rpc_method": spec.rpc_method, + }), + input_schema: spec.input_schema, + output_schema: mcp_output_schema(), + allowed_agents: vec!["*".to_string()], + tags: tags_for_tool_id(&tool_id, "mcp"), + enabled: true, + health: ToolRegistryHealth::Available, + } +} + +fn controller_tool_entry(schema: &ControllerSchema) -> ToolRegistryEntry { + let tool_id = schema.method_name(); + ToolRegistryEntry { + tool_id: tool_id.clone(), + name: tool_id.clone(), + title: title_from_function(schema.function), + description: schema.description.to_string(), + version: REGISTRY_ENTRY_VERSION.to_string(), + transport: ToolRegistryTransport::JsonRpc, + route: json!({ + "protocol": "json_rpc", + "method": all::rpc_method_name(schema), + "controller": schema.method_name(), + }), + input_schema: schema_fields_to_json_schema(&schema.inputs), + output_schema: schema_fields_to_json_schema(&schema.outputs), + allowed_agents: vec!["*".to_string()], + tags: tags_for_tool_id(&tool_id, "controller"), + enabled: true, + health: ToolRegistryHealth::Available, + } +} + +fn schema_fields_to_json_schema(fields: &[FieldSchema]) -> Value { + let mut properties = Map::new(); + let mut required = Vec::new(); + + for field in fields { + properties.insert(field.name.to_string(), field_schema_to_json(field)); + if field.required { + required.push(Value::String(field.name.to_string())); + } + } + + json!({ + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": false, + }) +} + +fn field_schema_to_json(field: &FieldSchema) -> Value { + let mut schema = type_schema_to_json(&field.ty); + match schema.as_object_mut() { + Some(object) => { + object.insert( + "description".to_string(), + Value::String(field.comment.to_string()), + ); + } + None => { + schema = json!({ + "description": field.comment, + "anyOf": [schema], + }); + } + } + schema +} + +fn type_schema_to_json(ty: &TypeSchema) -> Value { + match ty { + TypeSchema::Bool => json!({ "type": "boolean" }), + TypeSchema::I64 | TypeSchema::U64 => json!({ "type": "integer" }), + TypeSchema::F64 => json!({ "type": "number" }), + TypeSchema::String => json!({ "type": "string" }), + TypeSchema::Json => json!({}), + TypeSchema::Bytes => json!({ "type": "string", "contentEncoding": "base64" }), + TypeSchema::Array(inner) => json!({ + "type": "array", + "items": type_schema_to_json(inner), + }), + TypeSchema::Map(inner) => json!({ + "type": "object", + "additionalProperties": type_schema_to_json(inner), + }), + TypeSchema::Option(inner) => json!({ + "anyOf": [ + type_schema_to_json(inner), + { "type": "null" } + ], + }), + TypeSchema::Enum { variants } => json!({ + "type": "string", + "enum": variants, + }), + TypeSchema::Object { fields } => schema_fields_to_json_schema(fields), + TypeSchema::Ref(name) => json!({ + "$ref": format!("#/$defs/{name}"), + }), + } +} + +fn mcp_output_schema() -> Value { + json!({ + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "isError": { "type": "boolean" } + }, + "additionalProperties": true, + }) +} + +fn tags_for_tool_id(tool_id: &str, source: &str) -> Vec { + let mut tags = vec![source.to_string()]; + if let Some(namespace) = tool_id.split('.').next() { + push_unique(&mut tags, namespace); + } + if tool_id.contains("search") || tool_id.contains("recall") { + push_unique(&mut tags, "retrieval"); + } + if tool_id.contains("memory") || tool_id.contains("tree") { + push_unique(&mut tags, "memory"); + } + tags +} + +fn push_unique(tags: &mut Vec, tag: &str) { + if !tag.is_empty() && !tags.iter().any(|existing| existing == tag) { + tags.push(tag.to_string()); + } +} + +fn title_from_function(function: &str) -> String { + function + .split('_') + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()), + None => String::new(), + } + }) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::{FieldSchema, TypeSchema}; + + #[test] + fn registry_entries_include_mcp_and_controller_tools() { + let entries = registry_entries(); + + let memory_search = entries + .iter() + .find(|entry| entry.tool_id == "memory.search") + .expect("memory.search mcp tool"); + assert_eq!(memory_search.transport, ToolRegistryTransport::McpStdio); + assert_eq!(memory_search.route["method"], json!("tools/call")); + assert_eq!(memory_search.health, ToolRegistryHealth::Available); + + let web_search = entries + .iter() + .find(|entry| entry.tool_id == "tools.web_search") + .expect("tools.web_search controller tool"); + assert_eq!(web_search.transport, ToolRegistryTransport::JsonRpc); + assert_eq!( + web_search.route["method"], + json!("openhuman.tools_web_search") + ); + assert_eq!(web_search.input_schema["type"], json!("object")); + } + + #[test] + fn registry_entries_are_unique_and_sorted_by_tool_id() { + let entries = registry_entries(); + let ids = entries + .iter() + .map(|entry| entry.tool_id.as_str()) + .collect::>(); + let mut sorted = ids.clone(); + sorted.sort_unstable(); + sorted.dedup(); + + assert_eq!(ids, sorted); + } + + #[test] + #[should_panic(expected = "duplicate tool_id in registry")] + fn insert_registry_entry_panics_on_duplicate_tool_id() { + let mut entries = BTreeMap::new(); + let entry = ToolRegistryEntry { + tool_id: "duplicate.tool".to_string(), + name: "duplicate.tool".to_string(), + title: "Duplicate Tool".to_string(), + description: "Test duplicate entry.".to_string(), + version: REGISTRY_ENTRY_VERSION.to_string(), + transport: ToolRegistryTransport::JsonRpc, + route: json!({}), + input_schema: json!({}), + output_schema: json!({}), + allowed_agents: vec!["*".to_string()], + tags: vec!["test".to_string()], + enabled: true, + health: ToolRegistryHealth::Available, + }; + + insert_registry_entry(&mut entries, entry.clone(), "first"); + insert_registry_entry(&mut entries, entry, "second"); + } + + #[test] + fn get_tool_trims_and_returns_exact_entry() { + let outcome = get_tool(" memory.search ").expect("registry lookup"); + assert_eq!(outcome.value.tool_id, "memory.search"); + } + + #[test] + fn get_tool_rejects_blank_id() { + let err = get_tool(" ").expect_err("blank id should fail"); + assert!(err.contains("non-empty")); + } + + #[test] + fn get_tool_reports_unknown_id() { + let err = get_tool("missing.tool").expect_err("unknown id should fail"); + assert!(err.contains("missing.tool")); + } + + #[test] + fn controller_json_schema_marks_required_and_optional_fields() { + let schema = schema_fields_to_json_schema(&[ + FieldSchema { + name: "query", + ty: TypeSchema::String, + comment: "Query text.", + required: true, + }, + FieldSchema { + name: "max_results", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: "Optional cap.", + required: false, + }, + ]); + + assert_eq!(schema["required"], json!(["query"])); + assert_eq!(schema["properties"]["query"]["type"], json!("string")); + assert_eq!( + schema["properties"]["max_results"]["anyOf"][0]["type"], + json!("integer") + ); + assert_eq!( + schema["properties"]["max_results"]["description"], + json!("Optional cap.") + ); + } +} diff --git a/src/openhuman/tool_registry/schemas.rs b/src/openhuman/tool_registry/schemas.rs new file mode 100644 index 0000000000..4094b651c4 --- /dev/null +++ b/src/openhuman/tool_registry/schemas.rs @@ -0,0 +1,168 @@ +use serde_json::{Map, Value}; + +use crate::core::all::{ControllerFuture, RegisteredController}; +use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; +use crate::rpc::RpcOutcome; + +/// Declared controller schemas for the `tool_registry` namespace. +pub fn all_controller_schemas() -> Vec { + vec![schemas("list"), schemas("get")] +} + +/// Registered controller handlers for the `tool_registry` namespace. +pub fn all_registered_controllers() -> Vec { + vec![ + RegisteredController { + schema: schemas("list"), + handler: handle_list, + }, + RegisteredController { + schema: schemas("get"), + handler: handle_get, + }, + ] +} + +/// Return the schema for one `tool_registry` function. +pub fn schemas(function: &str) -> ControllerSchema { + match function { + "list" => ControllerSchema { + namespace: "tool_registry", + function: "list", + description: "List the unified read-only tool registry across MCP stdio tools and controller-backed tools.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "tools", + ty: TypeSchema::Array(Box::new(TypeSchema::Json)), + comment: "Registry entries with tool id, version, route, input/output schemas, tags, enabled state, allowed agents, and health.", + required: true, + }], + }, + "get" => ControllerSchema { + namespace: "tool_registry", + function: "get", + description: "Look up one tool registry entry by stable tool_id.", + inputs: vec![FieldSchema { + name: "tool_id", + ty: TypeSchema::String, + comment: "Stable registry id, for example `memory.search` or `tools.web_search`.", + required: true, + }], + outputs: vec![FieldSchema { + name: "tool", + ty: TypeSchema::Json, + comment: "One registry entry.", + required: true, + }], + }, + _ => ControllerSchema { + namespace: "tool_registry", + function: "unknown", + description: "Unknown tool registry controller function.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "error", + ty: TypeSchema::String, + comment: "Lookup error details.", + required: true, + }], + }, + } +} + +fn handle_list(params: Map) -> ControllerFuture { + Box::pin(async move { + log::debug!( + "[tool_registry] rpc list requested param_count={}", + params.len() + ); + to_json(crate::openhuman::tool_registry::ops::list_tools()) + }) +} + +fn handle_get(params: Map) -> ControllerFuture { + Box::pin(async move { + let tool_id = required_tool_id(¶ms)?; + log::debug!("[tool_registry] rpc get requested tool_id={tool_id}"); + to_json(crate::openhuman::tool_registry::ops::get_tool(tool_id)?) + }) +} + +fn required_tool_id(params: &Map) -> Result<&str, String> { + params + .get("tool_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "tool_id must be a non-empty string".to_string()) +} + +fn to_json(outcome: RpcOutcome) -> Result { + outcome.into_cli_compatible_json() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn schemas_cover_registered_controllers() { + let schemas = all_controller_schemas(); + let controllers = all_registered_controllers(); + + assert_eq!(schemas.len(), 2); + assert_eq!(controllers.len(), 2); + assert_eq!(schemas[0].function, controllers[0].schema.function); + assert_eq!(schemas[1].function, controllers[1].schema.function); + } + + #[test] + fn list_schema_has_no_inputs() { + let schema = schemas("list"); + assert_eq!(schema.namespace, "tool_registry"); + assert_eq!(schema.function, "list"); + assert!(schema.inputs.is_empty()); + assert_eq!(schema.outputs[0].name, "tools"); + } + + #[test] + fn get_schema_requires_tool_id() { + let schema = schemas("get"); + assert_eq!(schema.inputs[0].name, "tool_id"); + assert!(schema.inputs[0].required); + } + + #[test] + fn required_tool_id_rejects_wrong_type() { + let mut params = Map::new(); + params.insert("tool_id".to_string(), json!(10)); + + let err = required_tool_id(¶ms).expect_err("numeric id should fail"); + assert!(err.contains("non-empty string")); + } + + #[tokio::test] + async fn handle_list_returns_registry_object() { + let value = handle_list(Map::new()).await.expect("list json"); + let tools = value + .get("tools") + .and_then(Value::as_array) + .expect("tools array"); + assert!(tools + .iter() + .any(|tool| { tool.get("tool_id").and_then(Value::as_str) == Some("memory.search") })); + } + + #[tokio::test] + async fn handle_get_returns_one_registry_entry() { + let mut params = Map::new(); + params.insert("tool_id".to_string(), json!("tools.web_search")); + + let value = handle_get(params).await.expect("get json"); + assert_eq!( + value.get("tool_id").and_then(Value::as_str), + Some("tools.web_search") + ); + } +} diff --git a/src/openhuman/tool_registry/types.rs b/src/openhuman/tool_registry/types.rs new file mode 100644 index 0000000000..37a4a25096 --- /dev/null +++ b/src/openhuman/tool_registry/types.rs @@ -0,0 +1,60 @@ +use serde::Serialize; +use serde_json::Value; + +/// Serialized discovery metadata for one OpenHuman tool surface. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ToolRegistryEntry { + /// Stable unique registry id, such as `memory.search` or `tools.web_search`. + pub tool_id: String, + /// Machine-readable tool name exposed by the source surface. + pub name: String, + /// Human-readable display title. + pub title: String, + /// Short description suitable for agents and dashboards. + pub description: String, + /// Registry entry schema/version marker, currently the core crate version. + pub version: String, + /// Transport used to call the tool. + pub transport: ToolRegistryTransport, + /// Transport-specific route metadata. + pub route: Value, + /// JSON Schema for accepted input parameters. + pub input_schema: Value, + /// JSON Schema for the successful output shape. + pub output_schema: Value, + /// Agent ids allowed to discover/use the tool; `*` means unrestricted in this MVP. + pub allowed_agents: Vec, + /// Search/filter tags derived from the source namespace and tool purpose. + pub tags: Vec, + /// Whether the tool is currently enabled in the static registry. + pub enabled: bool, + /// Current health state for discovery consumers. + pub health: ToolRegistryHealth, +} + +/// Transport family used to route a registry entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolRegistryTransport { + /// Existing HTTP JSON-RPC controller method. + JsonRpc, + /// Existing stdio Model Context Protocol `tools/call` surface. + McpStdio, +} + +/// Health state exposed by the registry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolRegistryHealth { + /// The tool is statically registered and available for discovery. + Available, + /// Health cannot currently be determined. + Unknown, +} + +/// Response payload for `openhuman.tool_registry_list`. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ToolRegistryList { + /// Sorted registry entries. + pub tools: Vec, +} diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index d1a82d1058..1bb6d1e07b 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -894,6 +894,99 @@ fn ensure_test_rpc_auth() { }); } +#[tokio::test] +async fn json_rpc_tool_registry_lists_and_gets_entries() { + let _env_lock = json_rpc_e2e_env_lock(); + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{rpc_addr}"); + + let list = post_json_rpc(&rpc_base, 1848_1, "openhuman.tool_registry_list", json!({})).await; + let list_result = assert_no_jsonrpc_error(&list, "tool_registry_list"); + let tools = list_result + .get("tools") + .and_then(Value::as_array) + .expect("tool registry list should return tools array"); + + let memory_search = tools + .iter() + .find(|tool| tool.get("tool_id").and_then(Value::as_str) == Some("memory.search")) + .expect("registry should include memory.search"); + assert_eq!( + memory_search.get("transport").and_then(Value::as_str), + Some("mcp_stdio") + ); + assert_eq!( + memory_search + .get("route") + .and_then(|route| route.get("method")) + .and_then(Value::as_str), + Some("tools/call") + ); + assert!(memory_search.get("input_schema").is_some()); + assert!(memory_search.get("output_schema").is_some()); + + let controller_tool = tools + .iter() + .find(|tool| tool.get("tool_id").and_then(Value::as_str) == Some("tools.web_search")) + .expect("registry should include tools.web_search"); + assert_eq!( + controller_tool.get("transport").and_then(Value::as_str), + Some("json_rpc") + ); + assert_eq!( + controller_tool + .get("route") + .and_then(|route| route.get("method")) + .and_then(Value::as_str), + Some("openhuman.tools_web_search") + ); + assert_eq!( + controller_tool.get("health").and_then(Value::as_str), + Some("available") + ); + + let get = post_json_rpc( + &rpc_base, + 1848_2, + "openhuman.tool_registry_get", + json!({ "tool_id": "tools.web_search" }), + ) + .await; + let get_result = assert_no_jsonrpc_error(&get, "tool_registry_get"); + assert_eq!( + get_result.get("tool_id").and_then(Value::as_str), + Some("tools.web_search") + ); + assert_eq!( + get_result + .get("input_schema") + .and_then(|schema| schema.get("properties")) + .and_then(|properties| properties.get("query")) + .and_then(|query| query.get("type")) + .and_then(Value::as_str), + Some("string") + ); + + let missing = post_json_rpc( + &rpc_base, + 1848_3, + "openhuman.tool_registry_get", + json!({ "tool_id": "missing.tool" }), + ) + .await; + let missing_error = assert_jsonrpc_error(&missing, "tool_registry_get missing"); + assert!( + missing_error + .get("message") + .and_then(Value::as_str) + .unwrap_or_default() + .contains("tool not found"), + "unexpected missing-tool error: {missing_error}" + ); + + rpc_join.abort(); +} + #[tokio::test] async fn json_rpc_protocol_auth_and_agent_hello() { let _env_lock = json_rpc_e2e_env_lock();