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 0eda119e6bafe7017516c254d270d7a26d533f65..f08ed4cd173f7aa68ad62b99a0c3f4692fde60e2 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 2ea713a04d66b6e351cdb163318c39498bb412c3..9f563cf0b1b009a496d36a6f090b0f4b476433a7 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 499d9bdd30d50b236023e872805acaf6680f75ee..6434ba09a872a4674d53450606834a5b2923436b 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 26026175e68f3fecd20d21441bb7f1e41a438207..1820aebae547afa1a01968bb5d160b34503e9e1e 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);