test(mcp): add comprehensive unit tests for MCP manager and helpers (#2441)

* test(mcp): add comprehensive unit tests for MCP manager and helpers

Add 36 unit tests covering:
- InMemoryMcpClient: tool/resource registration, listing, calling, error cases
- McpManager: start_all (ready/failed/cancelled), list_tools with filters, call_tool,
  call_qualified_tool, unregister, stop, list_resources, read_resource, sandbox state
- Tool filter: allow/deny semantics, deny-overrides-allow
- Helper functions: sanitize_component, qualify_tool_name, parse_qualified_tool_name,
  parse_server_from_uri
- JsonRpcError: code correctness, envelope formatting
- Serialization: McpServerConfig defaults, McpStartupStatus snake_case

* style: fix cargo fmt formatting

* fix(mcp): preserve truncated qualified tool calls

---------

Co-authored-by: Hu Qiantao <huqiantao@HudeMacBook-Air.local>
Co-authored-by: Hunter B <hmbown@gmail.com>
This commit is contained in:
HUQIANTAO
2026-06-01 02:01:20 +08:00
committed by GitHub
parent 7519914e15
commit 46134e5bb5
+457 -10
View File
@@ -318,8 +318,31 @@ impl McpManager {
qualified_tool_name: &str,
arguments: Value,
) -> Result<Value> {
let (server_name, tool_name) = parse_qualified_tool_name(qualified_tool_name)
.with_context(|| format!("invalid qualified MCP tool name: {qualified_tool_name}"))?;
let parsed = parse_qualified_tool_name(qualified_tool_name)
.with_context(|| format!("invalid qualified MCP tool name: {qualified_tool_name}"));
if let Ok((server_name, tool_name)) = &parsed
&& self.clients.contains_key(server_name)
&& let Ok(result) = self.call_tool(server_name, tool_name, arguments.clone())
{
return Ok(result);
}
for (server_name, (_, filter)) in &self.configs {
let Some(client) = self.clients.get(server_name) else {
continue;
};
for tool in client.list_tools()? {
if !allowed_by_filter(&tool.tool_name, filter) {
continue;
}
if qualify_tool_name(server_name, &tool.tool_name) == qualified_tool_name {
return client.call_tool(&tool.tool_name, arguments);
}
}
}
let (server_name, tool_name) = parsed?;
self.call_tool(&server_name, &tool_name, arguments)
}
@@ -392,18 +415,29 @@ fn sanitize_component(value: &str) -> String {
}
fn qualify_tool_name(server: &str, tool: &str) -> String {
let mut name = format!(
"mcp__{}__{}",
sanitize_component(server),
sanitize_component(tool)
);
let server = sanitize_component(server);
let tool = sanitize_component(tool);
let mut name = format!("mcp__{server}__{tool}");
if name.len() > 64 {
let mut hasher = DefaultHasher::new();
name.hash(&mut hasher);
let hash = format!("{:x}", hasher.finish());
name.truncate(48);
name.push('_');
name.push_str(&hash[..12]);
let suffix = format!("_{}", &hash[..12]);
let component_budget = 64 - "mcp__".len() - "__".len() - suffix.len();
let mut server_len = server.len().min(component_budget / 2);
let mut tool_len = tool.len().min(component_budget - server_len);
let remaining = component_budget - server_len - tool_len;
if remaining > 0 {
let server_extra = (server.len() - server_len).min(remaining);
server_len += server_extra;
tool_len += (tool.len() - tool_len).min(remaining - server_extra);
}
name = format!(
"mcp__{}__{}{}",
&server[..server_len],
&tool[..tool_len],
suffix
);
}
name
}
@@ -957,3 +991,416 @@ impl JsonRpcError {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── InMemoryMcpClient ──────────────────────────────────────────────
#[test]
fn in_memory_client_list_tools_returns_registered() {
let client = InMemoryMcpClient::default()
.with_tool("echo", json!({"output": "hi"}))
.with_tool("greet", json!({"msg": "hello"}));
let tools = client.list_tools().unwrap();
assert_eq!(tools.len(), 2);
let names: Vec<&str> = tools.iter().map(|t| t.tool_name.as_str()).collect();
assert!(names.contains(&"echo"));
assert!(names.contains(&"greet"));
}
#[test]
fn in_memory_client_call_tool_returns_value() {
let client = InMemoryMcpClient::default().with_tool("echo", json!({"output": "hi"}));
let result = client.call_tool("echo", json!({})).unwrap();
assert_eq!(result["output"], "hi");
}
#[test]
fn in_memory_client_call_tool_errors_on_missing() {
let client = InMemoryMcpClient::default();
let err = client.call_tool("nope", json!({})).unwrap_err();
assert!(err.to_string().contains("not found"));
}
#[test]
fn in_memory_client_list_resources_returns_registered() {
let client = InMemoryMcpClient::default()
.with_resource("mcp://s/health", json!({"ok": true}))
.with_resource("mcp://s/caps", json!({"tools": []}));
let resources = client.list_resources().unwrap();
assert_eq!(resources.len(), 2);
}
#[test]
fn in_memory_client_read_resource_returns_value() {
let client =
InMemoryMcpClient::default().with_resource("mcp://s/health", json!({"ok": true}));
let result = client.read_resource("mcp://s/health").unwrap();
assert_eq!(result["ok"], true);
}
#[test]
fn in_memory_client_read_resource_errors_on_missing() {
let client = InMemoryMcpClient::default();
let err = client.read_resource("mcp://s/nope").unwrap_err();
assert!(err.to_string().contains("not found"));
}
// ── McpManager ─────────────────────────────────────────────────────
fn make_server_config(name: &str) -> McpServerConfig {
McpServerConfig {
name: name.to_string(),
command: "test".to_string(),
args: vec![],
env: HashMap::new(),
enabled: true,
}
}
#[test]
fn manager_start_all_marks_ready_for_registered_clients() {
let mut manager = McpManager::default();
manager.register_server(
make_server_config("s1"),
ToolFilter::default(),
Box::new(InMemoryMcpClient::default().with_tool("t", json!(null))),
);
let mut events = Vec::new();
let summary = manager.start_all(|e| events.push(e));
assert_eq!(summary.ready, vec!["s1"]);
assert!(summary.failed.is_empty());
assert!(events.iter().any(|event| {
event.server_name == "s1" && event.status == McpStartupStatus::Starting
}));
assert!(
events.iter().any(|event| {
event.server_name == "s1" && event.status == McpStartupStatus::Ready
})
);
}
#[test]
fn manager_start_all_marks_failed_when_client_missing() {
let mut manager = McpManager::default();
manager.register_server(
make_server_config("s1"),
ToolFilter::default(),
Box::new(InMemoryMcpClient::default()),
);
manager.stop_server("s1").unwrap();
let summary = manager.start_all(|_| {});
assert!(summary.ready.is_empty());
assert_eq!(summary.failed.len(), 1);
assert_eq!(summary.failed[0].server_name, "s1");
}
#[test]
fn manager_start_all_cancels_disabled_servers() {
let mut manager = McpManager::default();
let mut cfg = make_server_config("s1");
cfg.enabled = false;
manager.register_server(
cfg,
ToolFilter::default(),
Box::new(InMemoryMcpClient::default()),
);
let summary = manager.start_all(|_| {});
assert!(summary.ready.is_empty());
assert_eq!(summary.cancelled, vec!["s1"]);
}
#[test]
fn manager_list_tools_applies_filter() {
let mut manager = McpManager::default();
let client = InMemoryMcpClient::default()
.with_tool("allowed", json!(null))
.with_tool("denied", json!(null));
manager.register_server(
make_server_config("s1"),
ToolFilter {
allow: vec!["allowed".to_string()],
deny: vec![],
},
Box::new(client),
);
let tools = manager.list_tools().unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].tool_name, "allowed");
}
#[test]
fn manager_list_tools_deny_overrides_allow() {
let mut manager = McpManager::default();
let client = InMemoryMcpClient::default()
.with_tool("a", json!(null))
.with_tool("b", json!(null));
manager.register_server(
make_server_config("s1"),
ToolFilter {
allow: vec!["a".to_string(), "b".to_string()],
deny: vec!["b".to_string()],
},
Box::new(client),
);
let tools = manager.list_tools().unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].tool_name, "a");
}
#[test]
fn manager_call_tool_delegates_to_client() {
let mut manager = McpManager::default();
manager.register_server(
make_server_config("s1"),
ToolFilter::default(),
Box::new(InMemoryMcpClient::default().with_tool("t", json!({"v": 42}))),
);
let result = manager.call_tool("s1", "t", json!({})).unwrap();
assert_eq!(result["v"], 42);
}
#[test]
fn manager_call_tool_errors_on_missing_server() {
let manager = McpManager::default();
let err = manager.call_tool("nope", "t", json!({})).unwrap_err();
assert!(err.to_string().contains("not available"));
}
#[test]
fn manager_call_qualified_tool_parses_name() {
let mut manager = McpManager::default();
manager.register_server(
make_server_config("my_server"),
ToolFilter::default(),
Box::new(InMemoryMcpClient::default().with_tool("my_tool", json!({"ok": true}))),
);
let result = manager
.call_qualified_tool("mcp__my_server__my_tool", json!({}))
.unwrap();
assert_eq!(result["ok"], true);
}
#[test]
fn manager_call_qualified_tool_handles_truncated_names() {
let long_server = "server".repeat(20);
let long_tool = "tool".repeat(20);
let mut manager = McpManager::default();
manager.register_server(
make_server_config(&long_server),
ToolFilter::default(),
Box::new(InMemoryMcpClient::default().with_tool(&long_tool, json!({"ok": true}))),
);
let tools = manager.list_tools().unwrap();
let qualified = &tools[0].qualified_name;
assert!(qualified.len() <= 64);
assert!(parse_qualified_tool_name(qualified).is_ok());
let result = manager.call_qualified_tool(qualified, json!({})).unwrap();
assert_eq!(result["ok"], true);
}
#[test]
fn manager_unregister_removes_server() {
let mut manager = McpManager::default();
manager.register_server(
make_server_config("s1"),
ToolFilter::default(),
Box::new(InMemoryMcpClient::default()),
);
manager.unregister_server("s1").unwrap();
assert!(manager.configs.is_empty());
}
#[test]
fn manager_unregister_errors_on_unknown() {
let mut manager = McpManager::default();
let err = manager.unregister_server("nope").unwrap_err();
assert!(err.to_string().contains("not registered"));
}
#[test]
fn manager_stop_server_errors_on_unknown() {
let mut manager = McpManager::default();
let err = manager.stop_server("nope").unwrap_err();
assert!(err.to_string().contains("not running"));
}
#[test]
fn manager_list_resources_returns_from_clients() {
let mut manager = McpManager::default();
manager.register_server(
make_server_config("s1"),
ToolFilter::default(),
Box::new(
InMemoryMcpClient::default().with_resource("mcp://s1/health", json!({"ok": true})),
),
);
let resources = manager.list_resources().unwrap();
assert_eq!(resources.len(), 1);
assert_eq!(resources[0].server_name, "s1");
}
#[test]
fn manager_read_resource_delegates() {
let mut manager = McpManager::default();
manager.register_server(
make_server_config("s1"),
ToolFilter::default(),
Box::new(
InMemoryMcpClient::default().with_resource("mcp://s1/health", json!({"ok": true})),
),
);
let result = manager.read_resource("s1", "mcp://s1/health").unwrap();
assert_eq!(result["ok"], true);
}
#[test]
fn manager_update_sandbox_state_returns_notices() {
let mut manager = McpManager::default();
manager.register_server(
make_server_config("s1"),
ToolFilter::default(),
Box::new(InMemoryMcpClient::default()),
);
let notices = manager.update_sandbox_state("strict", "/tmp").unwrap();
assert_eq!(notices.len(), 1);
assert_eq!(notices[0]["server_name"], "s1");
}
// ── Tool filter ────────────────────────────────────────────────────
#[test]
fn allowed_by_filter_empty_allow_permits_all() {
let filter = ToolFilter {
allow: vec![],
deny: vec![],
};
assert!(allowed_by_filter("anything", &filter));
}
#[test]
fn allowed_by_filter_deny_blocks() {
let filter = ToolFilter {
allow: vec![],
deny: vec!["danger".to_string()],
};
assert!(!allowed_by_filter("danger", &filter));
assert!(allowed_by_filter("safe", &filter));
}
#[test]
fn allowed_by_filter_allow_only_permits_listed() {
let filter = ToolFilter {
allow: vec!["a".to_string()],
deny: vec![],
};
assert!(allowed_by_filter("a", &filter));
assert!(!allowed_by_filter("b", &filter));
}
// ── Helper functions ───────────────────────────────────────────────
#[test]
fn sanitize_component_lowercases_and_replaces_specials() {
assert_eq!(sanitize_component("My-Server.Name"), "my_server_name");
assert_eq!(sanitize_component("ABC123"), "abc123");
}
#[test]
fn qualify_tool_name_produces_mcp_prefix() {
let name = qualify_tool_name("server", "tool");
assert!(name.starts_with("mcp__server__tool"));
}
#[test]
fn qualify_tool_name_truncates_long_names() {
let long_server = "a".repeat(100);
let name = qualify_tool_name(&long_server, "tool");
assert!(name.len() <= 64);
assert!(parse_qualified_tool_name(&name).is_ok());
}
#[test]
fn parse_qualified_tool_name_round_trip() {
let qualified = qualify_tool_name("my_server", "my_tool");
let (server, tool) = parse_qualified_tool_name(&qualified).unwrap();
assert_eq!(server, "my_server");
assert_eq!(tool, "my_tool");
}
#[test]
fn parse_qualified_tool_name_rejects_missing_prefix() {
let err = parse_qualified_tool_name("not_mcp__server__tool").unwrap_err();
assert!(err.to_string().contains("missing mcp__ prefix"));
}
#[test]
fn parse_qualified_tool_name_rejects_empty_segments() {
let err = parse_qualified_tool_name("mcp____tool").unwrap_err();
assert!(err.to_string().contains("missing server segment"));
}
#[test]
fn parse_server_from_uri_extracts_server() {
assert_eq!(
parse_server_from_uri("mcp://my-server/capabilities"),
Some("my-server".to_string())
);
}
#[test]
fn parse_server_from_uri_returns_none_for_invalid() {
assert!(parse_server_from_uri("http://not-mcp").is_none());
assert!(parse_server_from_uri("mcp:///path").is_none());
}
// ── JsonRpcError ───────────────────────────────────────────────────
#[test]
fn jsonrpc_error_codes_are_correct() {
assert_eq!(JsonRpcError::parse_error("").code, -32700);
assert_eq!(JsonRpcError::invalid_request("").code, -32600);
assert_eq!(JsonRpcError::method_not_found("x").code, -32601);
assert_eq!(JsonRpcError::invalid_params("").code, -32602);
assert_eq!(JsonRpcError::internal("").code, -32603);
}
#[test]
fn jsonrpc_result_produces_valid_envelope() {
let result = jsonrpc_result(Some(json!(1)), json!({"ok": true}));
assert_eq!(result["jsonrpc"], "2.0");
assert_eq!(result["id"], 1);
assert_eq!(result["result"]["ok"], true);
}
#[test]
fn jsonrpc_error_produces_valid_envelope() {
let err = jsonrpc_error(Some(json!(2)), JsonRpcError::invalid_params("bad"));
assert_eq!(err["jsonrpc"], "2.0");
assert_eq!(err["id"], 2);
assert_eq!(err["error"]["code"], -32602);
}
// ── McpServerConfig serialization ──────────────────────────────────
#[test]
fn mcp_server_config_defaults_enabled_to_true() {
let json = json!({"name": "s", "command": "cmd"});
let config: McpServerConfig = serde_json::from_value(json).unwrap();
assert!(config.enabled);
assert!(config.args.is_empty());
assert!(config.env.is_empty());
}
#[test]
fn mcp_startup_status_serializes_with_snake_case() {
let status = McpStartupStatus::Failed {
error: "oops".to_string(),
};
let json = serde_json::to_value(&status).unwrap();
assert_eq!(json["failed"]["error"], "oops");
}
}