diff --git a/.gitignore b/.gitignore index 2d667eda6..e09e77d74 100644 --- a/.gitignore +++ b/.gitignore @@ -238,6 +238,9 @@ _bundle_ai_fixture_* # Vox GUI dev run logs gui-*.log +# Local `act` artifact server output (see .actrc --artifact-server-path) +.act-artifacts/ + # Superpowers brainstorm visual-companion scratch (local-only) .superpowers/ diff --git a/Cargo.lock b/Cargo.lock index ce0ff075c..e1e2fbe12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14366,7 +14366,6 @@ dependencies = [ "serde_json", "serde_yaml_ng", "sysinfo", - "tempfile", "tokio", "tracing", "uuid", diff --git a/contracts/capability/capability-registry.yaml b/contracts/capability/capability-registry.yaml index 58b6f8ba3..7b6516098 100644 --- a/contracts/capability/capability-registry.yaml +++ b/contracts/capability/capability-registry.yaml @@ -9307,6 +9307,22 @@ curated: mcp_tool: vox_test_decision cli_path: null parameters: null +- id: mcp.vox_tool_search + title: Tool Search + description_human: null + description_model: Search the MCP tool registry by keyword and return matching tools (name, description, input schema) for progressive tool disclosure. + intent_tags: [] + side_effect_class: null + scope_kind: null + reversible: null + requires_repo: null + requires_git: null + preferred_for_models: null + human_takeover_friendly: null + mens_planner_visible: null + mcp_tool: vox_tool_search + cli_path: null + parameters: null - id: mcp.vox_transfer_file title: Transfer File description_human: null diff --git a/contracts/capability/model-manifest.generated.json b/contracts/capability/model-manifest.generated.json index 113d48957..cc32b35da 100644 --- a/contracts/capability/model-manifest.generated.json +++ b/contracts/capability/model-manifest.generated.json @@ -5336,6 +5336,14 @@ "mcp_tool": "vox_test_decision", "title": "Test Decision" }, + { + "capability_id": "mcp.vox_tool_search", + "curated": true, + "description_model": "Search the MCP tool registry by keyword and return matching tools (name, description, input schema) for progressive tool disclosure.", + "implicit": false, + "mcp_tool": "vox_tool_search", + "title": "Tool Search" + }, { "capability_id": "mcp.vox_transfer_file", "curated": true, diff --git a/contracts/gui/surface-registry.v1.yaml b/contracts/gui/surface-registry.v1.yaml index 4cb48fe71..a3230ed66 100644 --- a/contracts/gui/surface-registry.v1.yaml +++ b/contracts/gui/surface-registry.v1.yaml @@ -585,6 +585,14 @@ surfaces: nav_group: null parent_surface: null notes: null +- view_key: null + cli_group: repl + representation_tier: none + nav_label: null + nav_icon: null + nav_group: null + parent_surface: null + notes: null - view_key: repository cli_group: repo representation_tier: live_backend diff --git a/contracts/mcp/http-read-role-governance.yaml b/contracts/mcp/http-read-role-governance.yaml index 39ccb0870..3f0909a5d 100644 --- a/contracts/mcp/http-read-role-governance.yaml +++ b/contracts/mcp/http-read-role-governance.yaml @@ -23,4 +23,5 @@ read_role_tools: - vox_skill_use - vox_task_status - vox_test_decision + - vox_tool_search - vox_workspace_modules diff --git a/contracts/mcp/tool-registry.canonical.yaml b/contracts/mcp/tool-registry.canonical.yaml index ed8547470..03aaf54f7 100644 --- a/contracts/mcp/tool-registry.canonical.yaml +++ b/contracts/mcp/tool-registry.canonical.yaml @@ -1348,6 +1348,11 @@ tools: product_lane: ai http_read_role_eligible: true tier: core +- name: vox_tool_search + description: Search the MCP tool registry by keyword and return matching tools (name, description, input schema) for progressive tool disclosure. + product_lane: ai + http_read_role_eligible: true + tier: core - name: vox_transfer_file description: Transfer ownership of a file to another agent. product_lane: ai diff --git a/contracts/operations/catalog.v1.yaml b/contracts/operations/catalog.v1.yaml index 67d6e62ee..4b8dd3adc 100644 --- a/contracts/operations/catalog.v1.yaml +++ b/contracts/operations/catalog.v1.yaml @@ -12126,6 +12126,26 @@ operations: http_read_role_eligible: false tier: core cli: null +- id: tool.search + title: Tool Search + description: Search the MCP tool registry by keyword and return matching tools (name, description, input schema) for progressive tool disclosure. + description_human: null + product_lane: ai + intent_tags: [] + side_effect_class: null + scope_kind: null + reversible: null + requires_repo: null + preferred_for_models: null + human_takeover_friendly: null + mens_planner_visible: null + canonical_name: null + latin_aliases: null + mcp: + name: vox_tool_search + http_read_role_eligible: true + tier: core + cli: null - id: train title: Train description: CLI operation `vox train` diff --git a/contracts/reports/gui-surface-coverage.v1.json b/contracts/reports/gui-surface-coverage.v1.json index 4568912ec..3545df092 100644 --- a/contracts/reports/gui-surface-coverage.v1.json +++ b/contracts/reports/gui-surface-coverage.v1.json @@ -1269,6 +1269,7 @@ "test.all", "test.decision", "toestub.findings.upsert", + "tool.search", "train", "transfer.file", "trust.override", diff --git a/contracts/reports/operations-catalog-inventory.v1.json b/contracts/reports/operations-catalog-inventory.v1.json index a27ec6609..0e972a171 100644 --- a/contracts/reports/operations-catalog-inventory.v1.json +++ b/contracts/reports/operations-catalog-inventory.v1.json @@ -1,8 +1,8 @@ { - "catalog_operations": 549, + "catalog_operations": 550, "paired_operations": 11, - "mcp_only_operations": 271, + "mcp_only_operations": 272, "cli_only_operations": 267, - "mcp_tool_count": 282, + "mcp_tool_count": 283, "cli_path_count": 278 } diff --git a/crates/vox-cli-core/Cargo.toml b/crates/vox-cli-core/Cargo.toml index 1f2aee92c..c2d8b495a 100644 --- a/crates/vox-cli-core/Cargo.toml +++ b/crates/vox-cli-core/Cargo.toml @@ -3,7 +3,6 @@ name = "vox-cli-core" description = "Shared internals for the vox CLI binary (argv parsing helpers, exit-code policy)." version.workspace = true edition.workspace = true -license.workspace = true [features] # `db` (default-on) pulls the persistence-coupled crates and enables the @@ -24,12 +23,12 @@ vox-orchestrator = { workspace = true } vox-orchestrator-driver = { workspace = true } vox-foundation = { workspace = true } vox-secrets = { workspace = true } +serde_yaml = { workspace = true } anyhow = { workspace = true } clap = { workspace = true, features = ["derive", "env"] } owo-colors = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -serde_yaml = { workspace = true } sysinfo = { workspace = true } tokio = { workspace = true, features = ["io-std", "macros", "process", "rt", "rt-multi-thread", "signal", "sync"] } tracing = { workspace = true } @@ -37,6 +36,5 @@ uuid = { workspace = true, features = ["v4"] } which = { workspace = true } [dev-dependencies] -tempfile = { workspace = true } vox-bounded-fs = { workspace = true } vox-jsonschema-util = { workspace = true } diff --git a/crates/vox-gui/ui/src/generated/surfaceRegistry.generated.ts b/crates/vox-gui/ui/src/generated/surfaceRegistry.generated.ts index 645d2ba1e..4c9ee2d85 100644 --- a/crates/vox-gui/ui/src/generated/surfaceRegistry.generated.ts +++ b/crates/vox-gui/ui/src/generated/surfaceRegistry.generated.ts @@ -83,6 +83,7 @@ export const SURFACE_REGISTRY: SurfaceRegistryEntry[] = [ { viewKey: null, cliGroup: 'remove', tier: 'none', navLabel: null, navIcon: null, navGroup: null, parentSurface: null }, { viewKey: null, cliGroup: 'repair', tier: 'none', navLabel: null, navIcon: null, navGroup: null, parentSurface: null }, { viewKey: null, cliGroup: 'repl', tier: 'none', navLabel: null, navIcon: null, navGroup: null, parentSurface: null }, + { viewKey: null, cliGroup: 'repl', tier: 'none', navLabel: null, navIcon: null, navGroup: null, parentSurface: null }, { viewKey: 'repository', cliGroup: 'repo', tier: 'live_backend', navLabel: 'Repository', navIcon: 'branch', navGroup: 'develop', parentSurface: 'workspace' }, { viewKey: 'research', cliGroup: 'research', tier: 'curated_decorator', navLabel: 'Research', navIcon: 'eye', navGroup: 'knowledge', parentSurface: 'knowledge' }, { viewKey: null, cliGroup: 'rollback', tier: 'none', navLabel: null, navIcon: null, navGroup: null, parentSurface: null }, diff --git a/crates/vox-orchestrator-mcp/src/dispatch.rs b/crates/vox-orchestrator-mcp/src/dispatch.rs index 5031705cf..9e903fd75 100644 --- a/crates/vox-orchestrator-mcp/src/dispatch.rs +++ b/crates/vox-orchestrator-mcp/src/dispatch.rs @@ -338,6 +338,9 @@ async fn handle_tool_call_inner( "vox_test_decision" => { Ok(task_tools::test_decision(state, serde_json::from_value(args)?).await) } + "vox_tool_search" => Ok(crate::tool_search::vox_tool_search(serde_json::from_value( + args, + )?)), // B3 HITL: list / resolve approvals awaiting a human decision (the // dangerous-tool gate below parks on these). "vox_pending_approvals" => Ok(crate::params::ToolResult::ok(serde_json::json!({ diff --git a/crates/vox-orchestrator-mcp/src/input_schemas.rs b/crates/vox-orchestrator-mcp/src/input_schemas.rs index b2f774084..7e8e427b3 100644 --- a/crates/vox-orchestrator-mcp/src/input_schemas.rs +++ b/crates/vox-orchestrator-mcp/src/input_schemas.rs @@ -60,6 +60,7 @@ pub(super) fn tool_input_schema(name: &str) -> Map { // ── Tasks & bulletin ───────────────────────────────────────────────── "vox_submit_task" => derived_tool_schema!(crate::params::SubmitTaskParams), + "vox_tool_search" => derived_tool_schema!(crate::params::ToolSearchParams), "vox_task_status" | "vox_cancel_task" | "vox_test_decision" => { derived_tool_schema!(crate::params::TaskStatusParams) } diff --git a/crates/vox-orchestrator-mcp/src/lib.rs b/crates/vox-orchestrator-mcp/src/lib.rs index c31d92cb6..122609963 100644 --- a/crates/vox-orchestrator-mcp/src/lib.rs +++ b/crates/vox-orchestrator-mcp/src/lib.rs @@ -88,6 +88,8 @@ pub(crate) mod text_normalization; #[cfg(feature = "toestub-gate")] pub mod toestub_tools; pub mod tool_aliases; +/// `vox_tool_search` — keyword search over the tool registry (progressive disclosure). +pub mod tool_search; /// Training-intent submission via orchestrator (Mens CLI remains canonical executor). pub mod training_tools; /// Trust rollup inspection tools (`trust_rollups` over VoxDb). diff --git a/crates/vox-orchestrator-mcp/src/params.rs b/crates/vox-orchestrator-mcp/src/params.rs index 6d884211d..f8ad7634d 100644 --- a/crates/vox-orchestrator-mcp/src/params.rs +++ b/crates/vox-orchestrator-mcp/src/params.rs @@ -321,6 +321,18 @@ pub struct TaskStatusParams { pub task_id: u64, } +/// Arguments for `vox_tool_search` (progressive tool disclosure). +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ToolSearchParams { + /// Whitespace-separated keywords matched against tool names and descriptions. + #[schemars(length(min = 1, max = 1024))] + pub query: String, + /// Maximum number of tools to return (default 10, clamped to 1..=100). + #[schemars(range(min = 1, max = 100))] + pub limit: Option, +} + /// Mark a task completed. #[derive(Debug, Deserialize, JsonSchema)] #[schemars(deny_unknown_fields)] diff --git a/crates/vox-orchestrator-mcp/src/tool_search.rs b/crates/vox-orchestrator-mcp/src/tool_search.rs new file mode 100644 index 000000000..bf1f50cc2 --- /dev/null +++ b/crates/vox-orchestrator-mcp/src/tool_search.rs @@ -0,0 +1,162 @@ +//! `vox_tool_search` — progressive tool disclosure. +//! +//! Mirrors Claude Code's MCP tool search: instead of pre-loading every tool's full +//! schema into the model's context, the model issues a keyword search and gets back +//! only the matching tools (name + description + input schema) on demand. This keeps +//! context usage low as the tool surface (500+ tools) grows. +//! +//! The ranking ([`rank_tools`]) is a pure keyword match over [`TOOL_REGISTRY`]; the +//! handler ([`vox_tool_search`]) attaches each hit's input schema from +//! [`crate::input_schemas`]. Tools remain dispatchable by name regardless of whether +//! they were surfaced here — discovery and execution are independent. + +use vox_mcp_registry::{McpToolRegistryEntry, TOOL_REGISTRY}; + +/// Per-term score when the term exactly matches an underscore-separated name segment. +const SCORE_NAME_SEGMENT_EXACT: u32 = 8; +/// Per-term score when the term is a substring of the tool name. +const SCORE_NAME_SUBSTRING: u32 = 4; +/// Per-term score when the term appears in the tool description. +const SCORE_DESCRIPTION: u32 = 1; + +/// Rank registry tools against a whitespace-separated keyword `query`. +/// +/// Each lowercased term scores per tool: exact name-segment match +/// ([`SCORE_NAME_SEGMENT_EXACT`]) > name substring ([`SCORE_NAME_SUBSTRING`]) > +/// description hit ([`SCORE_DESCRIPTION`]); term scores sum. Tools with score 0 +/// are dropped; ties break by name ascending; at most `limit` entries return. +pub fn rank_tools(query: &str, limit: usize) -> Vec<&'static McpToolRegistryEntry> { + let terms: Vec = query.split_whitespace().map(str::to_lowercase).collect(); + if terms.is_empty() { + return Vec::new(); + } + let mut scored: Vec<(u32, &'static McpToolRegistryEntry)> = TOOL_REGISTRY + .iter() + .filter_map(|entry| { + let name = entry.name.to_lowercase(); + let description = entry.description.to_lowercase(); + let score: u32 = terms + .iter() + .map(|term| { + if name.split('_').any(|seg| seg == term) { + SCORE_NAME_SEGMENT_EXACT + } else if name.contains(term.as_str()) { + SCORE_NAME_SUBSTRING + } else if description.contains(term.as_str()) { + SCORE_DESCRIPTION + } else { + 0 + } + }) + .sum(); + (score > 0).then_some((score, entry)) + }) + .collect(); + scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.name.cmp(b.1.name))); + scored.truncate(limit); + scored.into_iter().map(|(_, entry)| entry).collect() +} + +/// `vox_tool_search` handler: keyword search over the tool registry, returning +/// each hit's name, description, and input schema. +pub fn vox_tool_search(params: crate::params::ToolSearchParams) -> String { + let limit = params.limit.unwrap_or(10).clamp(1, 100) as usize; + let hits = rank_tools(¶ms.query, limit); + let tools: Vec = hits + .iter() + .map(|entry| { + serde_json::json!({ + "name": entry.name, + "description": entry.description, + "input_schema": crate::input_schemas::tool_input_schema(entry.name), + }) + }) + .collect(); + crate::params::ToolResult::ok(serde_json::json!({ + "query": params.query, + "total": tools.len(), + "tools": tools, + })) + .to_json() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ranks_exact_name_terms_highest() { + let hits = rank_tools("git status", 10); + assert!( + hits.first().map(|e| e.name) == Some("vox_git_status"), + "expected vox_git_status first, got: {:?}", + hits.first().map(|e| e.name) + ); + } + + #[test] + fn finds_tools_by_description_keyword() { + let hits = rank_tools("memory", 50); + assert!( + hits.iter().any(|e| e.name == "vox_memory_store"), + "expected a memory tool in results" + ); + } + + #[test] + fn empty_query_returns_nothing() { + assert!(rank_tools("", 10).is_empty()); + assert!(rank_tools(" ", 10).is_empty()); + } + + #[test] + fn gibberish_returns_nothing() { + assert!(rank_tools("zzqqxnotarealtoolword", 10).is_empty()); + } + + #[test] + fn respects_limit() { + let hits = rank_tools("vox", 3); + assert!(hits.len() <= 3); + } + + #[test] + fn results_are_deterministic_by_score_then_name() { + let a = rank_tools("git", 10); + let b = rank_tools("git", 10); + let names_a: Vec<_> = a.iter().map(|e| e.name).collect(); + let names_b: Vec<_> = b.iter().map(|e| e.name).collect(); + assert_eq!(names_a, names_b); + } + + #[test] + fn handler_returns_hits_with_schemas() { + let out = vox_tool_search(crate::params::ToolSearchParams { + query: "git status".to_string(), + limit: Some(5), + }); + let v: serde_json::Value = serde_json::from_str(&out).expect("valid json"); + assert_eq!(v["success"], true); + let data = &v["data"]; + assert_eq!(data["query"], "git status"); + let tools = data["tools"].as_array().expect("tools array"); + assert_eq!(data["total"], tools.len() as u64); + assert!(tools.len() <= 5); + let first = &tools[0]; + assert_eq!(first["name"], "vox_git_status"); + assert!(first["description"].is_string()); + assert!(first["input_schema"].is_object()); + } + + #[test] + fn handler_defaults_limit_and_handles_no_hits() { + let out = vox_tool_search(crate::params::ToolSearchParams { + query: "zzqqxnotarealtoolword".to_string(), + limit: None, + }); + let v: serde_json::Value = serde_json::from_str(&out).expect("valid json"); + assert_eq!(v["success"], true); + assert_eq!(v["data"]["total"], 0); + assert!(v["data"]["tools"].as_array().expect("array").is_empty()); + } +}