From 1ea2f2f02c093d4f79aa6eebed8add98d25162df Mon Sep 17 00:00:00 2001 From: Jonathan Camp Date: Thu, 12 Feb 2026 15:43:14 +0100 Subject: [PATCH] agent: Sanitize MCP server IDs in tool name disambiguation (#45789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release Notes: - Fixed an issue where a request could fail if an MCP server with names containing whitespace was used ## Summary When multiple MCP servers expose tools with the same name, Zed disambiguates them by prefixing the tool name with the server ID from settings.json. If the server ID contains spaces or special characters (e.g., `"Azure DevOps"`), the resulting tool name like `Azure DevOps_echo` violates Anthropic's API pattern `^[a-zA-Z0-9_-]{1,128}$`, causing API errors: > "Received an error from the Anthropic API: tools.0.custom.name: String should match pattern '^[a-zA-Z0-9_-]{1,128}$'" ## Solution Convert server IDs to snake_case (using the `heck` crate already available in the workspace) before using them as prefixes during tool name disambiguation. | Server ID in settings.json | Disambiguated Tool Name | |---------------------------|------------------------| | `"Azure DevOps"` | `azure_dev_ops_echo` | | `"My MCP Server"` | `my_mcp_server_echo` | ## Test plan - [x] Added test case for server name with spaces ("Azure DevOps") in `test_mcp_tool_truncation` - [x] Verified existing tests pass - [x] Manually tested with two MCP servers having overlapping tool names After (left), Before (right): Screenshot_20251228_163249 🤖 Generated with (some) help from [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Bennet Bo Fenner --- Cargo.lock | 1 + crates/agent/Cargo.toml | 1 + crates/agent/src/tests/mod.rs | 18 ++++++++++++++++++ crates/agent/src/thread.rs | 8 +++++--- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0eda119e6b..f08ed4cd17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,6 +179,7 @@ dependencies = [ "gpui", "gpui_tokio", "handlebars 4.5.0", + "heck 0.5.0", "html_to_markdown", "http_client", "indoc", diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 2ea713a04d..9f563cf0b1 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -38,6 +38,7 @@ futures.workspace = true git.workspace = true gpui.workspace = true handlebars = { workspace = true, features = ["rust-embed"] } +heck.workspace = true html_to_markdown.workspace = true http_client.workspace = true indoc.workspace = true diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 499d9bdd30..6434ba09a8 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -1767,6 +1767,23 @@ async fn test_mcp_tool_truncation(cx: &mut TestAppContext) { cx, ); + // Server with spaces in name - tests snake_case conversion for API compatibility + let _server4_calls = setup_context_server( + "Azure DevOps", + vec![context_server::types::Tool { + name: "echo".into(), // Also conflicts - will be disambiguated as azure_dev_ops_echo + description: None, + input_schema: serde_json::to_value(EchoTool::input_schema( + LanguageModelToolSchemaFormat::JsonSchema, + )) + .unwrap(), + output_schema: None, + annotations: None, + }], + &context_server_store, + cx, + ); + thread .update(cx, |thread, cx| { thread.send(UserMessageId::new(), ["Go"], cx) @@ -1777,6 +1794,7 @@ async fn test_mcp_tool_truncation(cx: &mut TestAppContext) { assert_eq!( tool_names_for_completion(&completion), vec![ + "azure_dev_ops_echo", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "delay", diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 26026175e6..1820aebae5 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -32,6 +32,7 @@ use futures::{ use gpui::{ App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity, }; +use heck::ToSnakeCase as _; use language_model::{ LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelImage, LanguageModelProviderId, LanguageModelRegistry, LanguageModelRequest, @@ -2466,13 +2467,14 @@ impl Thread { } // When there are duplicate tool names, disambiguate by prefixing them - // with the server ID. In the rare case there isn't enough space for the - // disambiguated tool name, keep only the last tool with this name. + // with the server ID (converted to snake_case for API compatibility). + // In the rare case there isn't enough space for the disambiguated tool + // name, keep only the last tool with this name. for (server_id, tool_name, tool) in context_server_tools { if duplicate_tool_names.contains(&tool_name) { let available = MAX_TOOL_NAME_LENGTH.saturating_sub(tool_name.len()); if available >= 2 { - let mut disambiguated = server_id.0.to_string(); + let mut disambiguated = server_id.0.to_snake_case(); disambiguated.truncate(available - 1); disambiguated.push('_'); disambiguated.push_str(&tool_name);