Skip to content

Sanitize MCP tool names for providers and map them back on dispatch - #694

Merged
dwash96 merged 2 commits into
cecli-dev:v1.6.2from
chrisnestrud:fix/mcp-tool-name-sanitization
Sep 26, 2026
Merged

dwash96 merged 2 commits into
cecli-dev:v1.6.2from
chrisnestrud:fix/mcp-tool-name-sanitization

Conversation

@chrisnestrud

Copy link
Copy Markdown

Problem

OpenAI-style providers require tools[].function.name to match
^[A-Za-z0-9_-]{1,64}$. cecli sends MCP tool names verbatim
(mcp/manager.py::_mcp_tools_to_openai_tools) and prefixes them with the server
name, so a server that legitimately advertises a dotted name makes every chat
request fail:

400 Invalid 'tools[1].function.name': string does not match pattern

A packaged MCP server that advertises browser.fetch and
browser.persisted_session.list is enough to block that provider lane
entirely, even though the server itself is healthy.

Fix

  • helpers/responses.py: add sanitize_tool_name() plus
    original_tool_name(). prefix_tool_name() now sanitizes the composed
    {server}--{tool} name, so the tool list carries only provider-safe names.
    Sanitization is idempotent.
  • coders/base_coder.py: compare sanitized names when matching an incoming call
    to its server (_find_mcp_server_for_tool), and map the sanitized name back to
    the name the server advertises in call_mcp_tool_from_session() before
    tools/call. Both the base and agent coders route through that method.

A caller that already holds the server's original name is unaffected: the
fallback returns the name unchanged.

Testing

tests/mcp/test_tool_name_sanitization.py covers sanitization (including
idempotence and the 64-character limit), the names get_tool_list() produces,
server selection for a sanitized call, and the map-back on dispatch.

Verified end to end with the packaged browser-mcp server behind a local
OpenAI-compatible endpoint that records the request payload:

  • before: browser--browser.fetch, browser--browser.persisted_session.list
  • after: browser--browser_fetch, browser--browser_persisted_session_list

and both tools dispatch to the server's original names; browser.fetch for
https://api.github.com/zen returns HTTP 200 with the page text.

Skipped: collision handling when two advertised names sanitize to the same
name. Providers reject duplicate function names, so it fails loudly rather than
silently dispatching to the wrong tool.

Providers enforce the OpenAI function-name pattern
^(?:[A-Za-z0-9_-]{1,64})$ on tools[].function.name, so an MCP server that
advertises a dotted name (browser.fetch) makes every request fail with
"Invalid 'tools[1].function.name': string does not match pattern" even though
the server itself is healthy.

Sanitize the prefixed name when the tool list is built, compare sanitized names
when matching an incoming call to its server, and map the sanitized name back to
the name the server advertises before tools/call. Sanitization is idempotent, so
a caller that already holds the original name is unaffected.
@chrisnestrud
chrisnestrud force-pushed the fix/mcp-tool-name-sanitization branch from f704959 to baaecd2 Compare September 26, 2026 00:14
@dwash96
dwash96 changed the base branch from main to v1.6.2 September 26, 2026 15:18
dwash96 added a commit that referenced this pull request Sep 26, 2026
Revert the beads/dolt entries the PR added to .gitignore so the merge
touches code only.

Replace the per-call scan in responses.original_tool_name() with
module-level dicts that cache the original/sanitized MCP tool name
pairs. register_tool_names() fills them from coder.mcp_tools inside
get_tool_list(), so dispatching a tool call is now a dict lookup
instead of iterating every advertised tool. sanitize_tool_name() also
reuses its cache.
@dwash96
dwash96 merged commit 0dbcda3 into cecli-dev:v1.6.2 Sep 26, 2026
12 checks passed
dwash96 added a commit that referenced this pull request Sep 26, 2026
Revert the beads/dolt entries the PR added to .gitignore so the merge
touches code only.

Replace the per-call scan in responses.original_tool_name() with
module-level dicts that cache the original/sanitized MCP tool name
pairs. register_tool_names() fills them from coder.mcp_tools inside
get_tool_list(), so dispatching a tool call is now a dict lookup
instead of iterating every advertised tool. sanitize_tool_name() also
reuses its cache.
@dwash96 dwash96 mentioned this pull request Sep 26, 2026
@dwash96 dwash96 mentioned this pull request Oct 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants