feat(core): add global tool registry (#2003)

This commit is contained in:
Zavian Wang
2026-05-17 18:21:55 -07:00
committed by GitHub
parent ac245a0a19
commit 8896aa14d6
14 changed files with 733 additions and 5 deletions
+3 -2
View File
@@ -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).
+18 -3
View File
@@ -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
+1
View File
@@ -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",
+6
View File
@@ -173,6 +173,8 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
controllers.extend(crate::openhuman::vault::all_vault_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)
@@ -294,6 +296,7 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
schemas.extend(crate::openhuman::workspace::all_workspace_controller_schemas());
schemas.extend(crate::openhuman::vault::all_vault_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());
@@ -395,6 +398,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.",
),
+1
View File
@@ -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());
+10
View File
@@ -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",
+1
View File
@@ -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),
+1
View File
@@ -8,3 +8,4 @@ mod stdio;
mod tools;
pub use stdio::run_stdio_from_cli;
pub use tools::{tool_specs, McpToolSpec};
+1
View File
@@ -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;
+12
View File
@@ -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};
+358
View File
@@ -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<ToolRegistryList> {
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<RpcOutcome<ToolRegistryEntry>, 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<ToolRegistryEntry> {
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<String, ToolRegistryEntry>,
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<String> {
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<String>, 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::<Vec<_>>()
.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::<Vec<_>>();
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.")
);
}
}
+168
View File
@@ -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<ControllerSchema> {
vec![schemas("list"), schemas("get")]
}
/// Registered controller handlers for the `tool_registry` namespace.
pub fn all_registered_controllers() -> Vec<RegisteredController> {
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<String, Value>) -> 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<String, Value>) -> ControllerFuture {
Box::pin(async move {
let tool_id = required_tool_id(&params)?;
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<String, Value>) -> 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<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
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(&params).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")
);
}
}
+60
View File
@@ -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<String>,
/// Search/filter tags derived from the source namespace and tool purpose.
pub tags: Vec<String>,
/// 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<ToolRegistryEntry>,
}
+93
View File
@@ -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();