Thanks to visit codestin.com
Credit goes to github.com

Skip to content

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

Closed
ibdafna wants to merge 2 commits into
coder:mainfrom
ibdafna:fix/sanitize-workspace-mcp-tool-names
Closed

fix(coderd/x/chatd/chattool): sanitize workspace MCP tool names#26853
ibdafna wants to merge 2 commits into
coder:mainfrom
ibdafna:fix/sanitize-workspace-mcp-tool-names

Conversation

@ibdafna

@ibdafna ibdafna commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Workspace MCP tools (servers a workspace declares in its .mcp.json) take their model-facing name from the server key joined with the tool name as serverName__toolName. Two of the three MCP tool paths already sanitize that name to the provider-safe set before it reaches the model:

  • Remote MCP servers: coderd/x/chatd/mcpclient applies SanitizeToolName + truncation at tool construction.
  • AI Gateway: aibridge/mcp sanitizes for the same reason (its comment notes a single invalid name can 400 the entire request).

The workspace path did not. workspaceMCPToolInfosFromResources builds server + "__" + name and NewWorkspaceMCPTool passed it straight into fantasy.ToolInfo.Name. A server or tool name containing a character outside ^[a-zA-Z0-9_-]{1,128}$ (for example @) therefore 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 that one tool.

Fix

Sanitize and length-cap the model-facing name in NewWorkspaceMCPTool, matching the remote MCP path. The original, unsanitized serverName__toolName is retained as routingName and used when proxying the call back through the workspace agent, which splits on __ to locate the server and call the original tool. Names already within the allowed set are unchanged, so the model-facing and routing names stay identical in the common case (no behavior change for valid names).

The agent-side config parser (agent/x/agentmcp/config.go) only guards the __ separator and leading/trailing underscores, so it does not prevent these other invalid characters from reaching the model.

Test plan

  • Added TestWorkspaceMCPTool_SanitizesModelNameKeepsRoutingName:
    • @ in the name is sanitized for the model, while the original name is sent to the agent for routing
    • a valid name is left unchanged (no regression)
    • an over-limit name is truncated for the model
  • Existing TestWorkspaceMCPToolInfosFromResources and TestPinnedWorkspaceMCPTools still pass.
  • go vet and gofmt clean.

Workspace MCP tools derive their model-facing name from the .mcp.json
server key joined with the tool name (serverName__toolName). Unlike the
remote MCP path (mcpclient) and the AI Gateway path (aibridge), this name
was sent to the provider unsanitized, so a server or tool name containing
characters outside ^[a-zA-Z0-9_-]{1,128}$ (for example an "@") produced an
invalid tool name that Anthropic and Bedrock reject with HTTP 400, failing
the entire turn.

Sanitize and length-cap the name shown to the model in NewWorkspaceMCPTool,
matching mcpclient, and retain the original unsanitized name as routingName
so the workspace agent can still split it to locate the server and call the
original tool.
@github-actions github-actions Bot added the community Pull Requests and issues created by the community. label Jun 30, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 73f8dfc779

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread coderd/x/chatd/chattool/mcpworkspace.go Outdated
return &WorkspaceMCPTool{
info: fantasy.ToolInfo{
Name: tool.Name,
Name: sanitizeModelToolName(tool.Name),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid duplicate names after workspace MCP sanitization

When two accepted workspace MCP servers or tools normalize to the same string, for example server keys foo.bar and foo_bar each exposing echo, or long alphanumeric names that share the first 64 bytes, both wrappers now advertise the same Info().Name here. buildToolDefinitions sends both duplicate function names to the provider, and local execution later dispatches through a map keyed by Info().Name, so one original routingName becomes unreachable or the provider can reject the duplicate tool list; workspace tools need a uniqueness policy before replacing the model-facing name.

Useful? React with 👍 / 👎.

Sanitizing the model-facing name can map two distinct workspace MCP
servers or tools to the same string (for example server keys "foo.bar"
and "foo_bar", or names sharing the first MaxToolNameLen bytes). Before
sanitization these names were unique by construction, so the collision
is newly possible. Duplicate names would be sent to the provider, which
can reject the request, and the dispatch map keyed by Info().Name would
make one tool unreachable.

Add NewWorkspaceMCPTools, which sorts by routing name for stable output
and appends a numeric suffix to any model-facing name that collides
after sanitization, while each tool keeps its own original routing name.
pinnedWorkspaceMCPTools now uses it so every workspace tool stays
addressable and no duplicate names reach the provider.
@kylecarbs

Copy link
Copy Markdown
Member

Thanks for catching this and the thorough write-up, @ibdafna. We picked it up on our side and opened an alternative, #26928, that fixes the same bug but hoists the sanitize/truncate/collision logic into a shared aibridge/mcp helper so the workspace path, the remote MCP path (mcpclient), and the AI Gateway path stop diverging (the remote path now disambiguates colliding names instead of only warning). Same core approach as yours: sanitize the model-facing name, keep the original for agent routing.

Cross-linking so the history is connected. Really appreciate the contribution, the reproduction, and the tests here.

Posted by Coder Agents on behalf of @kylecarbs.

@kylecarbs

Copy link
Copy Markdown
Member

Update: we simplified #26928 after some internal review. It no longer touches aibridge/mcp or the remote MCP client. The fix is now self-contained in the workspace path (coderd/x/chatd/chattool), keeping the same core approach as yours: sanitize the model-facing name, keep the original for agent routing, plus set-level disambiguation for names that collide after sanitization.

Posted by Coder Agents on behalf of @kylecarbs.

kylecarbs added a commit that referenced this pull request Jul 1, 2026
## 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.

<details>
<summary>Design notes / decision log</summary>

**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.

</details>

---

_Opened by Coder Agents on behalf of @kylecarbs. Alternative to #26853._
@kylecarbs kylecarbs closed this Jul 1, 2026
@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

community Pull Requests and issues created by the community.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants