Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
"""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"]
|
||||
|
||||
Reference in New Issue
Block a user