Skip to content

fix(mcp): first-wins tool routing to stop silent cross-server name shadowing - #53

Merged
chrishayuk merged 2 commits into
mainfrom
fix/mcp-tool-shadowing
Jul 30, 2026
Merged

fix(mcp): first-wins tool routing to stop silent cross-server name shadowing#53
chrishayuk merged 2 commits into
mainfrom
fix/mcp-tool-shadowing

Conversation

@chrishayuk

Copy link
Copy Markdown
Collaborator

Summary

StreamManager mapped each MCP tool name to whichever server advertised it last, with no collision check, warning, or log. When two configured servers exposed the same tool name (common short names like read_file, search, query collide easily, or a malicious/compromised server picks a name deliberately), the later server silently captured every future unpinned call_tool for that name — leaking the caller's arguments to, and returning fabricated results from, the wrong server. Reachable through mcp-cli's default chat tool-calling loop, which never passes server_name.

Verified against the current code with a live PoC on the real StreamManager.

Fix — first-wins + loud warning

  • Registration is now first-wins: the first server to advertise a tool name owns default (unpinned) routing.
  • A later server advertising the same name is ignored for routing and logged with a prominent warning — it can no longer silently take over an existing name.
  • The shadowed tool stays reachable deliberately via call_tool(name, server_name=...).
  • New introspection: get_servers_for_tool(name) and get_tool_collisions().
  • All four init paths route through one _register_tools helper (previously duplicated).

Tests

  • New tests/mcp/test_stream_manager_shadowing.py (8 tests): first-wins ownership, collision warning, no-warning-without-collision, reconnect no-op, unpinned stays with first server, pinned reaches shadowed server, reset clears state.
  • Full suite: 3319 passed, 19 skipped. mypy clean.

Bumps to 0.26.0 (adds public API + changes routing behavior).

🤖 Generated with Claude Code

…adowing

StreamManager mapped each MCP tool name to whichever server registered it last,
with no collision check. A second server advertising a name already provided by
an earlier server (malicious, compromised, or just reusing a common name like
read_file) silently captured every future unpinned call_tool for that name — no
warning, leaking arguments to and returning results from the wrong server.

Registration is now first-wins: the first server to advertise a name owns default
routing; a colliding later server is ignored for routing and logged with a
prominent warning; the shadowed tool stays reachable via call_tool(name,
server_name=...). Adds get_servers_for_tool() and get_tool_collisions() to
inspect collisions. Bumps to 0.26.0.

Signed-off-by: chris hay <chris.hay@uk.ibm.com>
…down race

shutdown() offloaded pool.shutdown(wait=False) to a thread-pool executor guarded
by a 1s wait_for. On a busy event loop the executor could fail to run before the
timeout fired, so the pool was cancelled without ever being shut down — an
intermittent 'shutdown called 0 times' test failure and a real pool-leak risk.
shutdown(wait=False) is non-blocking, so call it directly, matching the other
cleanup paths in this module.

Signed-off-by: chris hay <chris.hay@uk.ibm.com>
@chrishayuk
chrishayuk merged commit 596b3d0 into main Jul 30, 2026
10 checks passed
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.

1 participant