mcp: reject colliding tool names

This commit is contained in:
Keisuke Hirata 2026-06-20 17:40:03 +09:00
parent 66fa9d55a1
commit 0080c5b3d4
No known key found for this signature in database

View File

@ -1,4 +1,4 @@
use std::collections::HashSet; use std::collections::BTreeMap;
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
@ -62,7 +62,6 @@ async fn discover_server_tools(spec: McpStdioServerSpec) -> ProtocolProviderCont
let declaration = provider_declaration(&spec.name, None); let declaration = provider_declaration(&spec.name, None);
let mut contribution = ProtocolProviderContribution::ready(declaration.clone()); let mut contribution = ProtocolProviderContribution::ready(declaration.clone());
let server_namespace = sanitize_segment(&spec.name); let server_namespace = sanitize_segment(&spec.name);
let mut seen_names = HashSet::new();
let mut client = match McpStdioClient::connect(spec, McpStdioLimits::default()).await { let mut client = match McpStdioClient::connect(spec, McpStdioLimits::default()).await {
Ok(client) => client, Ok(client) => client,
@ -128,7 +127,6 @@ async fn discover_server_tools(spec: McpStdioServerSpec) -> ProtocolProviderCont
server_namespace, server_namespace,
server_version, server_version,
list, list,
&mut seen_names,
); );
contribution contribution
} }
@ -139,8 +137,10 @@ fn normalize_listed_tools(
server_namespace: String, server_namespace: String,
server_version: Option<String>, server_version: Option<String>,
list: ListToolsResult, list: ListToolsResult,
seen_names: &mut HashSet<String>,
) -> ProtocolProviderContribution { ) -> ProtocolProviderContribution {
let mut candidates = Vec::new();
let mut name_counts = BTreeMap::<String, usize>::new();
for tool in list.tools { for tool in list.tools {
match mcp_tool_contribution( match mcp_tool_contribution(
&declaration, &declaration,
@ -149,20 +149,30 @@ fn normalize_listed_tools(
tool, tool,
) { ) {
Ok((name, tool_contribution)) => { Ok((name, tool_contribution)) => {
if !seen_names.insert(name.clone()) { *name_counts.entry(name.clone()).or_default() += 1;
contribution = candidates.push((name, tool_contribution));
contribution.with_diagnostic(FeatureDiagnostic::error(bounded_diagnostic(
format!("duplicate MCP tool name `{name}` after namespacing; skipped"),
)));
continue;
}
contribution = contribution.with_tool(tool_contribution);
} }
Err(message) => { Err(message) => {
contribution = contribution.with_diagnostic(FeatureDiagnostic::error(message)); contribution = contribution.with_diagnostic(FeatureDiagnostic::error(message));
} }
} }
} }
for (name, count) in &name_counts {
if *count > 1 {
contribution = contribution.with_diagnostic(FeatureDiagnostic::error(bounded_diagnostic(
format!(
"duplicate MCP tool name `{name}` after namespacing ({count} definitions); all colliding definitions skipped"
),
)));
}
}
for (name, tool_contribution) in candidates {
if name_counts.get(&name).copied().unwrap_or_default() == 1 {
contribution = contribution.with_tool(tool_contribution);
}
}
contribution contribution
} }
@ -557,13 +567,13 @@ mod tests {
} }
#[test] #[test]
fn duplicate_names_after_normalization_are_diagnostic_only() { fn duplicate_names_after_normalization_are_not_model_visible() {
let declaration = provider_declaration("demo", None); let declaration = provider_declaration("demo", None);
let mut seen = HashSet::new();
let list = ListToolsResult { let list = ListToolsResult {
tools: vec![ tools: vec![
mcp_tool("search-files", "one", json!({"type":"object"})), mcp_tool("search-files", "one", json!({"type":"object"})),
mcp_tool("search files", "two", json!({"type":"object"})), mcp_tool("search files", "two", json!({"type":"object"})),
mcp_tool("unique", "three", json!({"type":"object"})),
], ],
next_cursor: None, next_cursor: None,
meta: None, meta: None,
@ -575,16 +585,32 @@ mod tests {
"demo".to_string(), "demo".to_string(),
None, None,
list, list,
&mut seen,
); );
assert_eq!(seen.len(), 1);
assert!( assert!(
contribution contribution
.diagnostics .diagnostics
.iter() .iter()
.any(|diag| diag.severity == FeatureDiagnosticSeverity::Error .any(|diag| diag.severity == FeatureDiagnosticSeverity::Error
&& diag.message.contains("duplicate")) && diag.message.contains("duplicate")
&& diag.message.contains("all colliding definitions skipped"))
); );
let mut feature = McpStdioToolFeature::new();
feature.add_contribution(contribution);
let mut pending_tools = Vec::new();
let mut hook_builder = HookRegistryBuilder::default();
FeatureRegistryBuilder::new()
.with_module(feature)
.install_into_pending(&mut pending_tools, &mut hook_builder);
let names: Vec<_> = pending_tools
.iter()
.map(|definition| {
let (meta, _) = definition();
meta.name
})
.collect();
assert!(!names.iter().any(|name| name == "Mcp_demo_search_files"));
assert!(names.iter().any(|name| name == "Mcp_demo_unique"));
} }
#[test] #[test]