🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

fix(coderd/x/chatd): sanitize workspace MCP tool names - #26928

Merged
kylecarbs merged 2 commits into
mainfrom
fix/mcp-tool-name-sanitizer
Jul 1, 2026
Merged

fix(coderd/x/chatd): sanitize workspace MCP tool names#26928
kylecarbs merged 2 commits into
mainfrom
fix/mcp-tool-name-sanitizer

Conversation

@kylecarbs

@kylecarbs kylecarbs commented Jul 1, 2026

Copy link
Copy Markdown
Member

Summary

Workspace MCP tools (servers a workspace declares in .mcp.json) take their model-facing name from the server key joined with the tool name as serverName__toolName. That name reached the model unsanitized, so a server or tool name containing a character outside ^[a-zA-Z0-9_-]{1,128}$ (for example @) produced an invalid tool name. Anthropic and Bedrock reject the whole request with HTTP 400:

tools.N.custom.name: String should match pattern '^[a-zA-Z0-9_-]{1,128}$'

which fails the entire turn, not just the one tool. The remote MCP path (mcpclient) and the AI Gateway path (aibridge/mcp) already sanitize; the workspace path did not.

Alternative to #26853 (thanks @ibdafna for the report and repro).

Fix

Sanitize and length-cap the model-facing name, and keep the original serverName__toolName as a routingName the workspace agent uses to reach the original server and tool. NewWorkspaceMCPTools builds a whole set and disambiguates names that collide after sanitization (for example server keys foo.bar and foo_bar both exposing echo) so every tool stays addressable in the model's name-keyed dispatch map. Names already within the allowed set are unchanged, so there is no behavior change for valid names.

The sanitizer is local to coderd/x/chatd/chattool; the fix does not touch the aibridge package or the remote MCP client.

Changes

  • coderd/x/chatd/chattool/mcpworkspace.go: local provider-safe sanitizer + length cap, routingName for the agent proxy, and NewWorkspaceMCPTools for set-level collision disambiguation.
  • coderd/x/chatd/chatd.go: build the pinned workspace tool set via NewWorkspaceMCPTools.

Why sanitize here (not at .mcp.json / agent parse)?

The agent uses serverName__toolName to route to the real downstream server (it splits on __ and calls the original tool name), so sanitizing at parse time would break routing or merely relocate the original->sanitized mapping. Sanitization is also a provider constraint the agent has no knowledge of, and coderd/agent version skew means coderd must sanitize at its own boundary regardless. The model-facing boundary in chatd is the right place.

Test plan

  • @ in a name is sanitized for the model while the original routes to the agent; a valid name is unchanged; an over-length name is truncated; colliding names in a set are disambiguated while each still routes to its own original name.
  • go build, go vet, golangci-lint, and go test ./coderd/x/chatd/chattool/... pass locally.
Design notes / decision log

Constraint that drives the design. The tool name is both the identifier shown to the model (and the key the model layer dispatches tool calls by) and, for the workspace path, the string the agent splits on __ to route back to the original server and tool. Those roles conflict once sanitization changes the name, so the name is sanitized for the model while the unsanitized form is kept as routingName.

Options considered.

  1. Chosen: sanitize in the workspace path only, with helpers local to chattool. Smallest blast radius; no new cross-package dependency. This matches the shape of the other MCP paths (mcpclient keeps originalName + configID) without sharing code.
  2. Sanitize at .mcp.json parse time or in the agent. Rejected: breaks routing (the agent needs the original name), pushes a provider concern into the agent, and coderd must still defend its own boundary because the agent and coderd version independently. Tool names also come from the downstream server at list time, not from .mcp.json, so parsing cannot fully validate them.
  3. Extract a shared sanitize/truncate/dedupe helper into aibridge/mcp and adopt it in mcpclient too (so the remote path also gains collision disambiguation). This DRYs all paths, but it grows chatd's coupling to the aibridge subsystem and expands scope/behavior/tests in the remote path for what is a workspace-path bug. Left out deliberately to keep this change minimal and self-contained; it can be a separate refactor.
  4. Sanitize once at the provider serialization boundary (chat loop). The only truly generic spot, but the model dispatches by name, so it needs a reverse (sanitized -> original) mapping and set-wide collision handling in the model layer. Larger, riskier change.

Notes.

  • The workspace path defines its own sanitizer ([^a-zA-Z0-9_-] -> _) and a maxModelToolNameLen = 64 constant that mirrors the strictest provider limit (OpenAI 64, Bedrock 128), rather than importing aibridge/mcp, so it carries no new dependency.
  • The set builder sorts before assigning suffixes so disambiguation is stable across turns.

Opened by Coder Agents on behalf of @kylecarbs. Alternative to #26853.

Workspace MCP tools take their model-facing name from the server key
joined with the tool name as serverName__toolName. That name reached
the model unsanitized, so a server or tool name with a character
outside ^[a-zA-Z0-9_-]{1,128}$ (for example "@") produced an invalid
tool name and Anthropic/Bedrock rejected the whole request with HTTP
400, failing the entire turn.

Sanitize and length-cap the model-facing name while keeping the
original serverName__toolName as the routing name the workspace agent
uses to reach the original server and tool. This matches the remote
MCP (mcpclient) and AI Gateway (aibridge/mcp) paths.

To avoid a fourth copy of the sanitize/truncate/dedupe logic, extract
two shared helpers into aibridge/mcp:

- SanitizeAndTruncateToolName: sanitize then cap at MaxToolNameLen.
- DisambiguateToolName: resolve post-sanitization collisions with a
  numeric suffix so every tool stays addressable in the model's
  name-keyed dispatch map.

The workspace path and mcpclient both use the helpers. mcpclient now
disambiguates colliding names instead of only logging that one tool
will be unreachable.

Alternative fix for #26853, which sanitizes only the
workspace path with path-local helpers.
Drop the shared aibridge/mcp helper and the mcpclient changes from the
previous commit. Sanitizing/truncating and collision disambiguation now
live entirely in coderd/x/chatd/chattool, so the fix touches neither the
aibridge package nor the remote MCP client. The workspace path defines
its own provider-safe sanitizer (mirroring the ^[a-zA-Z0-9_-] limit),
carrying no new cross-package dependency.
@kylecarbs kylecarbs changed the title fix: sanitize workspace MCP tool names via shared aibridge/mcp helper fix(coderd/x/chatd): sanitize workspace MCP tool names Jul 1, 2026
@kylecarbs
kylecarbs enabled auto-merge (squash) July 1, 2026 18:29
@kylecarbs
kylecarbs merged commit 58f70b4 into main Jul 1, 2026
32 of 33 checks passed
@kylecarbs
kylecarbs deleted the fix/mcp-tool-name-sanitizer branch July 1, 2026 18:34
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 1, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants