agent: Sanitize MCP server IDs in tool name disambiguation (#45789)

Jonathan Camp , Claude Opus 4.5 , and Bennet Bo Fenner created

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):

<img width="2880" height="1800" alt="Screenshot_20251228_163249"
src="https://github.com/user-attachments/assets/09c4e8f0-e282-4620-9db3-3e2c7d428d15"
/>


🤖 Generated with (some) help from [Claude
Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>

Change summary

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(-)

Detailed changes

Cargo.lock 🔗

@@ -179,6 +179,7 @@ dependencies = [
  "gpui",
  "gpui_tokio",
  "handlebars 4.5.0",
+ "heck 0.5.0",
  "html_to_markdown",
  "http_client",
  "indoc",

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

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",

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);