Skip to content

Commit eea9553

Browse files
committed
fix(anthropic): skip mcp_ prefix on outgoing tool schemas when already prefixed
Companion to the GH-25255 incoming-strip fix from @hayka-pacha. Without this, build_anthropic_kwargs unconditionally added 'mcp_' to every tool name in step 3, so a native MCP server tool registered as 'mcp_composio_X' was sent as 'mcp_mcp_composio_X' on the wire. The incoming strip only removes ONE prefix, which still worked on first call, but on subsequent calls the model pattern-matched the single-prefixed form from message history and produced names that stripped to 'composio_X' — registry miss, dispatch fail. The history-rewrite block (#4) already has this guard. Apply the same guard to the schema-rewrite block (#3) so round-trip is symmetric. Added 4 outgoing-side tests. Existing 7 incoming-side tests still pass. Author map: hayka-pacha added for PR #25270 salvage attribution. Refs GH-25255.
1 parent 2f91a84 commit eea9553

3 files changed

Lines changed: 71 additions & 1 deletion

File tree

agent/anthropic_adapter.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2122,9 +2122,13 @@ def build_anthropic_kwargs(
21222122
block["text"] = text
21232123

21242124
# 3. Prefix tool names with mcp_ (Claude Code convention)
2125+
# Skip names that already begin with the marker — native MCP server
2126+
# tools (from mcp_servers: in config.yaml) are registered under their
2127+
# full mcp_<server>_<tool> name and would double-prefix otherwise,
2128+
# breaking round-trip registry lookup in normalize_response. GH-25255.
21252129
if anthropic_tools:
21262130
for tool in anthropic_tools:
2127-
if "name" in tool:
2131+
if "name" in tool and not tool["name"].startswith(_MCP_TOOL_PREFIX):
21282132
tool["name"] = _MCP_TOOL_PREFIX + tool["name"]
21292133

21302134
# 4. Prefix tool names in message history (tool_use and tool_result blocks)

scripts/release.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@
196196
"[email protected]": "aqilaziz", # PR #26406 salvage (preserve native audio outside Telegram)
197197
"[email protected]": "karthikeyann", # PR #26609 salvage (DM-topic routing pin)
198198
"[email protected]": "kunci115", # PR #27098 salvage (thread-not-found retry)
199+
"[email protected]": "hayka-pacha", # PR #25270 salvage (registry-aware mcp_ prefix strip)
199200
"[email protected]": "chromalinx", # PR #27014 salvage (commands for groups+DM)
200201
"[email protected]": "booker1207", # PR #25132 salvage (gate profile bots by allowed topics)
201202
"[email protected]": "kiranvk-2011", # PR #24815 salvage (image documents → vision)

tests/agent/test_anthropic_mcp_prefix_strip.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,68 @@ def test_both_stripped_and_full_registered_prefers_full(self):
183183
# Both exist — the condition `get_entry(stripped) and not get_entry(name)`
184184
# is False because get_entry(name) IS truthy, so we keep the full name.
185185
assert result.tool_calls[0].name == "mcp_foo"
186+
187+
188+
class TestAnthropicOAuthOutgoingPrefix:
189+
"""Verify the outgoing-side companion fix: build_anthropic_kwargs must not
190+
double-prefix tool names that already start with ``mcp_`` (native MCP server
191+
tools registered as ``mcp_<server>_<tool>``). GH-25255."""
192+
193+
def _build(self, tools, is_oauth=True):
194+
from agent.anthropic_adapter import build_anthropic_kwargs
195+
return build_anthropic_kwargs(
196+
model="claude-sonnet-4-6",
197+
messages=[{"role": "user", "content": "Hi"}],
198+
tools=tools,
199+
max_tokens=4096,
200+
reasoning_config=None,
201+
is_oauth=is_oauth,
202+
)
203+
204+
def test_oauth_adds_prefix_to_bare_tool_name(self):
205+
"""OAuth + bare name → prefix added (existing Claude Code convention)."""
206+
kwargs = self._build([{
207+
"type": "function",
208+
"function": {"name": "read_file", "description": "x", "parameters": {}},
209+
}])
210+
names = [t["name"] for t in kwargs["tools"]]
211+
assert names == ["mcp_read_file"]
212+
213+
def test_oauth_does_not_double_prefix_native_mcp_tool(self):
214+
"""OAuth + already-prefixed native MCP name → left alone."""
215+
kwargs = self._build([{
216+
"type": "function",
217+
"function": {
218+
"name": "mcp_composio_COMPOSIO_SEARCH_TOOLS",
219+
"description": "x",
220+
"parameters": {},
221+
},
222+
}])
223+
names = [t["name"] for t in kwargs["tools"]]
224+
# Must NOT become "mcp_mcp_composio_..." — that breaks the round-trip
225+
# because normalize_response only strips ONE mcp_ prefix.
226+
assert names == ["mcp_composio_COMPOSIO_SEARCH_TOOLS"]
227+
228+
def test_oauth_mixed_native_and_bare_tools(self):
229+
"""Mixed: native MCP preserved, bare names prefixed."""
230+
kwargs = self._build([
231+
{"type": "function", "function": {"name": "read_file",
232+
"description": "x", "parameters": {}}},
233+
{"type": "function", "function": {"name": "mcp_composio_SEARCH",
234+
"description": "y", "parameters": {}}},
235+
{"type": "function", "function": {"name": "terminal",
236+
"description": "z", "parameters": {}}},
237+
])
238+
names = sorted(t["name"] for t in kwargs["tools"])
239+
assert names == ["mcp_composio_SEARCH", "mcp_read_file", "mcp_terminal"]
240+
241+
def test_non_oauth_path_untouched(self):
242+
"""Non-OAuth requests never get the prefix — schemas pass through as-is."""
243+
kwargs = self._build([
244+
{"type": "function", "function": {"name": "read_file",
245+
"description": "x", "parameters": {}}},
246+
{"type": "function", "function": {"name": "mcp_composio_SEARCH",
247+
"description": "y", "parameters": {}}},
248+
], is_oauth=False)
249+
names = sorted(t["name"] for t in kwargs["tools"])
250+
assert names == ["mcp_composio_SEARCH", "read_file"]

0 commit comments

Comments
 (0)