Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions contracts/capability/capability-registry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions contracts/capability/model-manifest.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions contracts/gui/surface-registry.v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions contracts/mcp/http-read-role-governance.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ read_role_tools:
- vox_skill_use
- vox_task_status
- vox_test_decision
- vox_tool_search
- vox_workspace_modules
5 changes: 5 additions & 0 deletions contracts/mcp/tool-registry.canonical.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions contracts/operations/catalog.v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions contracts/reports/gui-surface-coverage.v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -1269,6 +1269,7 @@
"test.all",
"test.decision",
"toestub.findings.upsert",
"tool.search",
"train",
"transfer.file",
"trust.override",
Expand Down
6 changes: 3 additions & 3 deletions contracts/reports/operations-catalog-inventory.v1.json
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 1 addition & 3 deletions crates/vox-cli-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,19 +23,18 @@ 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 }
uuid = { workspace = true, features = ["v4"] }
which = { workspace = true }

[dev-dependencies]
tempfile = { workspace = true }
vox-bounded-fs = { workspace = true }
vox-jsonschema-util = { workspace = true }
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
3 changes: 3 additions & 0 deletions crates/vox-orchestrator-mcp/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!({
Expand Down
1 change: 1 addition & 0 deletions crates/vox-orchestrator-mcp/src/input_schemas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ pub(super) fn tool_input_schema(name: &str) -> Map<String, Value> {

// ── 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)
}
Expand Down
2 changes: 2 additions & 0 deletions crates/vox-orchestrator-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
12 changes: 12 additions & 0 deletions crates/vox-orchestrator-mcp/src/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,
}

/// Mark a task completed.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
Expand Down
162 changes: 162 additions & 0 deletions crates/vox-orchestrator-mcp/src/tool_search.rs
Original file line number Diff line number Diff line change
@@ -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<String> = 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(&params.query, limit);
let tools: Vec<serde_json::Value> = 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());
}
}
Loading