Files
mcp/rust/tests/test_tool_surface.rs
zeekay 630ec216d3 feat(rust): tools/list in one order, and --tools to serve fewer
tools/list appended the registered tools in HashMap order, which is
different in every process: two starts of the same binary listed
different orders (checked: three runs, three orders), so a client's
prompt cache over the tool schemas could not hold between sessions. The
registered tools now follow the built-ins sorted by name.

--tools fs,code,exec lists and serves only those. A client is charged
for every schema it is shown, each turn: the whole list is 34 tools and
38 KB, about 9.5K tokens; fs, code and exec together are 4.6 KB, about
1.1K, and byte for byte the same on every start. A name the server does
not carry fails at startup and lists what it does; a call outside the
list answers Unknown tool, like any other name.

ToolsConfig's flags were never read and are left alone.
2026-10-14 13:40:31 -07:00

69 lines
2.8 KiB
Rust

//! The tool surface a client sees: the same order every start, and as small as asked.
use hanzo_mcp::ToolRegistry;
use serde_json::Value;
fn names(registry: &ToolRegistry) -> Vec<String> {
registry
.get_definitions()
.iter()
.map(|d| d["name"].as_str().unwrap().to_string())
.collect()
}
#[test]
fn the_list_has_one_order_however_many_times_it_is_built() {
let first = names(&ToolRegistry::with_defaults());
for _ in 0..20 {
assert_eq!(first, names(&ToolRegistry::with_defaults()));
}
// The built-ins keep their fixed order; the registered tools follow, by name.
let custom = &first[16..];
let mut sorted = custom.to_vec();
sorted.sort();
assert_eq!(custom, sorted.as_slice());
assert_eq!(&first[..4], ["exec", "fs", "plan", "think"]);
}
#[test]
fn restricting_lists_and_serves_only_what_was_named() {
let mut registry = ToolRegistry::with_defaults();
registry.restrict(["fs", "code", "exec"]).unwrap();
assert_eq!(names(&registry), ["exec", "fs", "code"]);
assert_eq!(registry.list(), ["code", "exec", "fs"]);
}
#[tokio::test]
async fn a_tool_outside_the_list_is_unknown_not_hidden() {
let mut registry = ToolRegistry::with_defaults();
registry.restrict(["fs"]).unwrap();
let denied = registry.execute("exec", serde_json::json!({"action": "help"})).await.unwrap();
assert!(!denied.success);
assert_eq!(denied.error.as_deref(), Some("Unknown tool: exec"));
assert!(registry.execute("fs", serde_json::json!({"action": "help"})).await.unwrap().success);
// A registered tool is held to the list too.
let denied = registry.execute("code_search", serde_json::json!({"query": "x"})).await.unwrap();
assert_eq!(denied.error.as_deref(), Some("Unknown tool: code_search"));
}
#[test]
fn an_unknown_or_empty_list_is_an_error() {
let mut registry = ToolRegistry::with_defaults();
let err = registry.restrict(["fs", "serach"]).unwrap_err().to_string();
assert!(err.starts_with("unknown tool \"serach\""), "{err}");
assert!(ToolRegistry::with_defaults().restrict(["", " "]).is_err());
assert!(ToolRegistry::with_defaults().restrict(Vec::<String>::new()).is_err());
// A failed restrict leaves the registry whole.
assert!(registry.list().len() > 20);
}
/// What a client is charged each turn: the schemas of the tools it is shown. The search,
/// exec and code tools together stay under 4K tokens (16 KB at four bytes a token).
#[test]
fn the_native_tools_fit_in_four_thousand_tokens() {
let mut registry = ToolRegistry::with_defaults();
registry.restrict(["fs", "code", "exec", "kai_decide"]).unwrap();
let bytes: usize = registry.get_definitions().iter().map(|d| serde_json::to_string::<Value>(d).unwrap().len()).sum();
assert!(bytes < 16_000, "{bytes} bytes");
}