mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(core): add global tool registry (#2003)
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -8,3 +8,4 @@ mod stdio;
|
||||
mod tools;
|
||||
|
||||
pub use stdio::run_stdio_from_cli;
|
||||
pub use tools::{tool_specs, McpToolSpec};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
@@ -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.")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(¶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<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(¶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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user