Files
aiturk-hermes-ide/tests/agent/test_anthropic_mcp_prefix_strip.py

326 lines
14 KiB
Python

"""Tests for GH-25255: Anthropic OAuth ``mcp__`` tool-name round-trip.
Anthropic's subscription/OAuth billing classifier treats a **single-underscore**
``mcp_`` tool name as a third-party-app fingerprint and rejects the request with
HTTP 400 "Third-party apps now draw from extra usage, not plan limits". So on
the OAuth wire NOTHING may carry a single-underscore ``mcp_`` prefix:
* bare native tools ``read_file`` -> ``mcp__read_file``
* native MCP server tools ``mcp_linear_get_issue`` -> ``mcp__linear_get_issue``
``normalize_response`` reverses the ``mcp__`` wire name back to whatever the tool
registry knows (the single-underscore ``mcp_<server>_<tool>`` form for MCP server
tools, or the bare name for native tools) so the dispatcher is unaffected.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_tool_use_block(name: str, block_id: str = "tc_1", input_data: dict | None = None):
"""Create a fake Anthropic tool_use content block."""
return SimpleNamespace(
type="tool_use",
id=block_id,
name=name,
input=input_data or {"query": "test"},
)
def _make_response(*blocks, stop_reason="end_turn"):
"""Create a fake Anthropic Messages response."""
return SimpleNamespace(
content=list(blocks),
stop_reason=stop_reason,
model="claude-sonnet-4",
usage=SimpleNamespace(input_tokens=100, output_tokens=50),
)
class _FakeRegistry:
"""Minimal fake tool registry for testing prefix round-trip logic."""
def __init__(self, registered_names: set[str]):
self._names = registered_names
def get_entry(self, name: str):
if name in self._names:
return SimpleNamespace(name=name) # truthy = tool exists
return None
# ---------------------------------------------------------------------------
# Response side: mcp__ wire name -> registry name
# ---------------------------------------------------------------------------
class TestAnthropicMcpPrefixStrip:
"""Verify strip_tool_prefix reverses the ``mcp__`` wire prefix correctly."""
def _get_transport(self):
from agent.transports.anthropic import AnthropicTransport
return AnthropicTransport()
def test_strips_prefix_for_oauth_injected_native_tool(self):
"""``mcp__read_file`` -> ``read_file`` (bare native tool)."""
transport = self._get_transport()
block = _make_tool_use_block("mcp__read_file")
response = _make_response(block)
registry = _FakeRegistry({"read_file", "terminal", "web_search"})
with patch("tools.registry.registry", registry):
result = transport.normalize_response(response, strip_tool_prefix=True)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "read_file"
def test_no_strip_when_flag_false(self):
"""When strip_tool_prefix=False, names are never modified."""
transport = self._get_transport()
block = _make_tool_use_block("mcp__read_file")
response = _make_response(block)
registry = _FakeRegistry({"read_file"})
with patch("tools.registry.registry", registry):
result = transport.normalize_response(response, strip_tool_prefix=False)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "mcp__read_file"
class TestAnthropicOAuthAliasRoundTrip:
"""#65365: session_search / memory schemas alone deterministically trip
Anthropic's OAuth billing classifier (verified live via the
anthropic-ratelimit-unified-* response headers, see issue comments).
Both are aliased to neutral wire names; normalize_response must reverse
the mapping so the dispatcher still sees the real tool."""
def _get_transport(self):
from agent.transports.anthropic import AnthropicTransport
return AnthropicTransport()
def test_oauth_session_search_alias_round_trips_to_registry_name(self):
transport = self._get_transport()
block = _make_tool_use_block("mcp__chat_history_lookup")
response = _make_response(block)
registry = _FakeRegistry({"session_search"})
with patch("tools.registry.registry", registry):
result = transport.normalize_response(response, strip_tool_prefix=True)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "session_search"
def test_oauth_memory_alias_round_trips_to_registry_name(self):
transport = self._get_transport()
block = _make_tool_use_block("mcp__context_notes")
response = _make_response(block)
registry = _FakeRegistry({"memory"})
with patch("tools.registry.registry", registry):
result = transport.normalize_response(response, strip_tool_prefix=True)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "memory"
def test_registered_tool_wins_over_oauth_alias(self):
"""A real tool actually registered under the wire name keeps
GH-25255 precedence — the alias must not hijack it."""
transport = self._get_transport()
block = _make_tool_use_block("mcp__chat_history_lookup")
response = _make_response(block)
registry = _FakeRegistry({"mcp_chat_history_lookup", "session_search"})
with patch("tools.registry.registry", registry):
result = transport.normalize_response(response, strip_tool_prefix=True)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "mcp_chat_history_lookup"
# ---------------------------------------------------------------------------
# Request side: registry name -> mcp__ wire name (no single-underscore leaks)
# ---------------------------------------------------------------------------
class TestAnthropicOAuthOutgoingPrefix:
"""build_anthropic_kwargs must emit ZERO single-underscore ``mcp_`` names on
the OAuth wire — bare names and MCP server names both land on ``mcp__``."""
def _build(self, tools, is_oauth=True, messages=None, tool_choice=None):
from agent.anthropic_adapter import build_anthropic_kwargs
return build_anthropic_kwargs(
model="claude-sonnet-4-6",
messages=messages or [{"role": "user", "content": "Hi"}],
tools=tools,
max_tokens=4096,
reasoning_config=None,
tool_choice=tool_choice,
is_oauth=is_oauth,
)
def test_oauth_promotes_single_underscore_mcp_server_tool(self):
"""OAuth + ``mcp_<server>_<tool>`` -> promoted to double underscore.
This is the gap left by the bare constant swap: MCP server tools used
to be *skipped* and went on the wire single-underscore, still tripping
the classifier. They must become ``mcp__`` and NOT be double-prefixed.
"""
kwargs = self._build([{
"type": "function",
"function": {
"name": "mcp_linear_get_issue",
"description": "x",
"parameters": {},
},
}])
names = [t["name"] for t in kwargs["tools"]]
assert names == ["mcp__linear_get_issue"]
# never double-prefixed
assert not any(n.startswith("mcp__mcp_") for n in names)
def test_oauth_no_single_underscore_mcp_on_wire(self):
"""Mixed set: every wire name is bare-free of single-underscore mcp_."""
kwargs = self._build([
{"type": "function", "function": {"name": "read_file",
"description": "x", "parameters": {}}},
{"type": "function", "function": {"name": "mcp_linear_get_issue",
"description": "y", "parameters": {}}},
{"type": "function", "function": {"name": "terminal",
"description": "z", "parameters": {}}},
])
names = sorted(t["name"] for t in kwargs["tools"])
assert names == ["mcp__linear_get_issue", "mcp__read_file", "mcp__terminal"]
# The core invariant: NOTHING single-underscore reaches the wire.
for n in names:
assert not (n.startswith("mcp_") and not n.startswith("mcp__"))
# ---------------------------------------------------------------------------
# #65365: session_search / memory OAuth billing-classifier trigger
# ---------------------------------------------------------------------------
class TestAnthropicOAuthClassifierAlias:
"""OAuth must alias the two schemas issue #65365 isolated as independent,
deterministic triggers (session_search alone, memory alone) — in tool
name, tool description, system-prompt prose, and named tool_choice."""
def _build(self, tools, is_oauth=True, messages=None, tool_choice=None):
from agent.anthropic_adapter import build_anthropic_kwargs
return build_anthropic_kwargs(
model="claude-sonnet-4-6",
messages=messages or [{"role": "user", "content": "Hi"}],
tools=tools,
max_tokens=4096,
reasoning_config=None,
tool_choice=tool_choice,
is_oauth=is_oauth,
)
def _tool(self, name, description="x"):
return {"type": "function", "function": {"name": name, "description": description, "parameters": {}}}
def test_oauth_aliases_session_search_and_memory_names(self):
kwargs = self._build([
self._tool("session_search", "Use session_search to recall prior chats."),
self._tool("memory", "Persist notes with memory."),
])
names = sorted(t["name"] for t in kwargs["tools"])
assert names == ["mcp__chat_history_lookup", "mcp__context_notes"]
def test_oauth_aliases_session_search_in_tool_description_not_memory(self):
"""session_search is prose-safe (unambiguous token); memory is
ordinary English and must NOT be rewritten in free text — only its
tool name is aliased."""
kwargs = self._build([
self._tool("session_search", "Call session_search to recall prior chats."),
self._tool("memory", "Persist notes; uses working memory internally."),
])
by_name = {t["name"]: t["description"] for t in kwargs["tools"]}
assert "chat_history_lookup" in by_name["mcp__chat_history_lookup"]
assert "session_search" not in by_name["mcp__chat_history_lookup"]
assert "memory" in by_name["mcp__context_notes"] # untouched prose
def test_oauth_aliases_session_search_in_system_prompt_prose(self):
kwargs = self._build(
[self._tool("session_search")],
messages=[
{"role": "system", "content": "When relevant, use session_search to recall it."},
{"role": "user", "content": "Hi"},
],
)
system_text = "\n".join(
b["text"] for b in kwargs["system"] if isinstance(b, dict) and b.get("type") == "text"
)
assert "chat_history_lookup" in system_text
assert "session_search" not in system_text
def test_oauth_does_not_alias_longer_identifier_containing_token(self):
"""Word-boundary matching: a path like tools/session_search_tool.py
must survive untouched, not become chat_history_lookup_tool.py."""
kwargs = self._build(
[self._tool("session_search")],
messages=[
{"role": "system", "content": "See tools/session_search_tool.py for details."},
{"role": "user", "content": "Hi"},
],
)
system_text = "\n".join(
b["text"] for b in kwargs["system"] if isinstance(b, dict) and b.get("type") == "text"
)
assert "tools/session_search_tool.py" in system_text
def test_oauth_tool_choice_named_alias_matches_wire_name(self):
"""The gap left open by prior alias work: a forced tool_choice must
be normalized through the same alias + mcp__ prefix as tools[], or
(a) the literal trigger string still reaches the wire and (b) the
name no longer matches any entry in tools[]."""
kwargs = self._build(
[self._tool("session_search")],
tool_choice="session_search",
)
assert kwargs["tool_choice"] == {"type": "tool", "name": "mcp__chat_history_lookup"}
assert kwargs["tool_choice"]["name"] in {t["name"] for t in kwargs["tools"]}
def test_oauth_tool_choice_bare_name_still_gets_mcp_prefix(self):
"""Non-aliased tool_choice names still need the mcp__ prefix under
OAuth (pre-existing GH-25255 invariant, now routed consistently)."""
kwargs = self._build(
[self._tool("read_file")],
tool_choice="read_file",
)
assert kwargs["tool_choice"] == {"type": "tool", "name": "mcp__read_file"}
def test_oauth_skips_alias_on_wire_name_collision(self):
"""If a real (e.g. MCP server) tool already owns the alias's wire
name, session_search must keep its own name rather than collide —
two identical tool names is a hard 400, strictly worse than #65365."""
kwargs = self._build([
self._tool("session_search"),
self._tool("mcp_chat_history_lookup"),
])
names = sorted(t["name"] for t in kwargs["tools"])
assert names == ["mcp__chat_history_lookup", "mcp__session_search"]
def test_api_key_path_never_aliases(self):
"""The alias is OAuth-only — API-key requests must be byte-identical
to before this fix."""
kwargs = self._build(
[self._tool("session_search", "Use session_search to recall prior chats.")],
is_oauth=False,
)
assert kwargs["tools"][0]["name"] == "session_search"
assert "session_search" in kwargs["tools"][0]["description"]