{"repo":"NousResearch/hermes-agent","public_id":"28849","display_label":"Anthropic OAuth mcp_ tool-name prefix and extra-usage errors","summary":"These members all concern the `mcp_` tool-name prefix on Anthropic's OAuth request path. One issue reports it being associated with an \"out of extra usage\" 400 error on Pro/Max plans for tool-bearing requests, and two pull requests propose removing or avoiding that prefix. This describes the reported problem and proposed changes only, not whether they are confirmed or merged.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":47723,"merged_at":"2026-06-17T07:56:03+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-17T07:56:03+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":28849,"pr_number":47723,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-17T07:56:03+00:00"},{"issue_number":46675,"pr_number":47723,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-17T07:56:03+00:00"}],"members":[{"kind":"issue","number":28849,"title":"OAuth Anthropic + Pro/Max plan: mcp_ tool-name prefix triggers \"out of extra usage\" 400 on every tool-bearing   request","url":null,"state":"closed"},{"kind":"pr","number":28872,"title":"fix(anthropic): remove mcp_ tool prefix on OAuth path that triggers overage gate","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#47723","number":47723}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":47723,"title":"fix(anthropic): no single-underscore mcp_ tool names on the OAuth wire (plan-limit billing)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py","agent/transports/anthropic.py","tests/agent/test_anthropic_mcp_prefix_strip.py"],"fix_hunks":[{"file":"agent/anthropic_adapter.py","hunks":["@@ -372,7 +372,7 @@ def _detect_claude_code_version() -> str:\n \n \n _CLAUDE_CODE_SYSTEM_PREFIX = \"You are Claude Code, Anthropic's official CLI for Claude.\"\n-_MCP_TOOL_PREFIX = \"mcp_\"\n+_MCP_TOOL_PREFIX = \"mcp__\"\n \n \n def _get_claude_code_version() -> str:","@@ -2349,25 +2349,46 @@ def build_anthropic_kwargs(\n                 text = text.replace(\"Nous Research\", \"Anthropic\")\n                 block[\"text\"] = text\n \n-        # 3. Prefix tool names with mcp_ (Claude Code convention)\n-        #    Skip names that already begin with the marker — native MCP server\n-        #    tools (from mcp_servers: in config.yaml) are registered under their\n-        #    full mcp_<server>_<tool> name and would double-prefix otherwise,\n-        #    breaking round-trip registry lookup in normalize_response. GH-25255.\n+        # 3. Normalize tool names so NOTHING goes on the OAuth wire with a\n+        #    single-underscore ``mcp_`` prefix.  Anthropic's subscription/OAuth\n+        #    billing classifier treats a single-underscore ``mcp_`` tool name as\n+        #    a third-party-app fingerprint and rejects the request with HTTP 400\n+        #    \"Third-party apps now draw from extra usage, not plan limits\"\n+        #    (verified empirically: a single ``mcp_foo`` tool flips a request\n+        #    from plan-billing to the extra-usage lane; ``mcp__foo`` is accepted).\n+        #\n+        #    Two cases, both must land on the double-underscore ``mcp__`` form:\n+        #      a) bare Hermes-native tools (``read_file``)  -> ``mcp__read_file``\n+        #      b) native MCP server tools registered under their full\n+        #         single-underscore ``mcp_<server>_<tool>`` name\n+        #         (``mcp_linear_get_issue``) -> ``mcp__linear_get_issue``\n+        #    Case (b) is the gap that the bare ``mcp_``->``mcp__`` constant swap\n+        #    left open: those tools were *skipped* and stayed single-underscore,\n+        #    so any session with an MCP server configured still tripped the\n+        #    classifier. normalize_response reverses both forms via registry\n+        #    lookup so the dispatcher still sees the original name. GH-25255.\n+        def _to_oauth_wire_name(name: str) -> str:\n+            if name.startswith(\"mcp__\"):\n+                return name  # already correct, don't double-prefix\n+            if name.startswith(\"mcp_\"):\n+                # single-underscore native MCP tool -> promote to double\n+                return \"mcp__\" + name[len(\"mcp_\"):]\n+            return _MCP_TOOL_PREFIX + name  # bare name -> mcp__<name>\n+\n         if anthropic_tools:\n             for tool in anthropic_tools:\n-                if \"name\" in tool and not tool[\"name\"].startswith(_MCP_TOOL_PREFIX):\n-                    tool[\"name\"] = _MCP_TOOL_PREFIX + tool[\"name\"]\n+                if \"name\" in tool:\n+                    tool[\"name\"] = _to_oauth_wire_name(tool[\"name\"])\n \n-        # 4. Prefix tool names in message history (tool_use and tool_result blocks)\n+        # 4. Apply the same normalization to tool names in message history\n+        #    (tool_use blocks) so replayed turns match the wire names above.\n         for msg in anthropic_messages:\n             content = msg.get(\"content\")\n             if isinstance(content, list):\n                 for block in content:\n                     if isinstance(block, dict):\n                         if block.get(\"type\") == \"tool_use\" and \"name\" in block:\n-                            if not block[\"name\"].startswith(_MCP_TOOL_PREFIX):\n-                                block[\"name\"] = _MCP_TOOL_PREFIX + block[\"name\"]\n+                            block[\"name\"] = _to_oauth_wire_name(block[\"name\"])\n                         elif block.get(\"type\") == \"tool_result\" and \"tool_use_id\" in block:\n                             pass  # tool_result uses ID, not name\n "]},{"file":"agent/transports/anthropic.py","hunks":["@@ -88,7 +88,7 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:\n         from agent.transports.types import ToolCall\n \n         strip_tool_prefix = kwargs.get(\"strip_tool_prefix\", False)\n-        _MCP_PREFIX = \"mcp_\"\n+        _MCP_PREFIX = \"mcp__\"\n \n         text_parts = []\n         reasoning_parts = []","@@ -132,17 +132,25 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:\n             elif block.type == \"tool_use\":\n                 name = block.name\n                 if strip_tool_prefix and name.startswith(_MCP_PREFIX):\n-                    stripped = name[len(_MCP_PREFIX):]\n-                    # Only strip the mcp_ prefix for OAuth-injected tools\n-                    # (where Hermes adds the prefix when sending to Anthropic\n-                    # and must remove it on the way back).  Native MCP server\n-                    # tools (from mcp_servers: in config.yaml) are registered\n-                    # in the tool registry under their FULL mcp_<server>_<tool>\n-                    # name and must NOT be stripped.  GH-25255.\n+                    # On the OAuth wire every tool carries a double-underscore\n+                    # ``mcp__`` prefix (added in build_anthropic_kwargs to avoid\n+                    # Anthropic's single-underscore third-party classifier).\n+                    # Reverse it back to the name the registry/dispatcher knows.\n+                    # Two original forms map onto the same ``mcp__`` wire name:\n+                    #   ``mcp__read_file``       <- bare native tool ``read_file``\n+                    #   ``mcp__linear_get_issue`` <- MCP server tool\n+                    #                                ``mcp_linear_get_issue``\n+                    # Resolve by registry lookup, preferring whichever original\n+                    # is actually registered; never rewrite a name the LLM used\n+                    # that already resolves natively. GH-25255.\n                     from tools.registry import registry as _tool_registry\n-                    if (_tool_registry.get_entry(stripped)\n-                            and not _tool_registry.get_entry(name)):\n-                        name = stripped\n+                    if not _tool_registry.get_entry(name):\n+                        bare = name[len(_MCP_PREFIX):]            # read_file\n+                        single = \"mcp_\" + bare                    # mcp_read_file / mcp_linear_get_issue\n+                        if _tool_registry.get_entry(single):\n+                            name = single\n+                        elif _tool_registry.get_entry(bare):\n+                            name = bare\n                 tool_calls.append(\n                     ToolCall(\n                         id=block.id,"]},{"file":"tests/agent/test_anthropic_mcp_prefix_strip.py","hunks":["@@ -1,9 +1,16 @@\n-\"\"\"Tests for GH-25255: Anthropic OAuth mcp_ prefix stripping.\n+\"\"\"Tests for GH-25255: Anthropic OAuth ``mcp__`` tool-name round-trip.\n \n-When strip_tool_prefix=True (Anthropic OAuth path), the transport must only\n-strip the ``mcp_`` prefix from OAuth-injected tools, NOT from Hermes-native\n-MCP server tools that are registered under their full ``mcp_<server>_<tool>``\n-name in the tool registry.\n+Anthropic's subscription/OAuth billing classifier treats a **single-underscore**\n+``mcp_`` tool name as a third-party-app fingerprint and rejects the request with\n+HTTP 400 \"Third-party apps now draw from extra usage, not plan limits\".  So on\n+the OAuth wire NOTHING may carry a single-underscore ``mcp_`` prefix:\n+\n+  * bare native tools            ``read_file``            -> ``mcp__read_file``\n+  * native MCP server tools      ``mcp_linear_get_issue`` -> ``mcp__linear_get_issue``\n+\n+``normalize_response`` reverses the ``mcp__`` wire name back to whatever the tool\n+registry knows (the single-underscore ``mcp_<server>_<tool>`` form for MCP server\n+tools, or the bare name for native tools) so the dispatcher is unaffected.\n \"\"\"\n \n from __future__ import annotations","@@ -12,7 +19,6 @@\n from unittest.mock import patch\n \n \n-\n # ---------------------------------------------------------------------------\n # Helpers\n # ---------------------------------------------------------------------------","@@ -38,7 +44,7 @@ def _make_response(*blocks, stop_reason=\"end_turn\"):\n \n \n class _FakeRegistry:\n-    \"\"\"Minimal fake tool registry for testing prefix stripping logic.\"\"\"\n+    \"\"\"Minimal fake tool registry for testing prefix round-trip logic.\"\"\"\n \n     def __init__(self, registered_names: set[str]):\n         self._names = registered_names","@@ -50,25 +56,20 @@ def get_entry(self, name: str):\n \n \n # ---------------------------------------------------------------------------\n-# Tests\n+# Response side: mcp__ wire name -> registry name\n # ---------------------------------------------------------------------------\n \n class TestAnthropicMcpPrefixStrip:\n-    \"\"\"Verify that strip_tool_prefix only strips OAuth-injected prefixes.\"\"\"\n+    \"\"\"Verify strip_tool_prefix reverses the ``mcp__`` wire prefix correctly.\"\"\"\n \n     def _get_transport(self):\n         from agent.transports.anthropic import AnthropicTransport\n         return AnthropicTransport()\n \n-    def test_strips_prefix_for_oauth_injected_tool(self):\n-        \"\"\"OAuth tools: mcp_read_file -> read_file (stripped).\n-\n-        The tool was registered as 'read_file' in the registry.\n-        Anthropic sees 'mcp_read_file' because Hermes adds the prefix.\n-        On response, we must strip it back to 'read_file'.\n-        \"\"\"\n+    def test_strips_prefix_for_oauth_injected_native_tool(self):\n+        \"\"\"``mcp__read_file`` -> ``read_file`` (bare native tool).\"\"\"\n         transport = self._get_transport()\n-        block = _make_tool_use_block(\"mcp_read_file\")\n+        block = _make_tool_use_block(\"mcp__read_file\")\n         response = _make_response(block)\n \n         registry = _FakeRegistry({\"read_file\", \"terminal\", \"web_search\"})","@@ -78,31 +79,29 @@ def test_strips_prefix_for_oauth_injected_tool(self):\n         assert len(result.tool_calls) == 1\n         assert result.tool_calls[0].name == \"read_file\"\n \n-    def test_preserves_native_mcp_server_tool_name(self):\n-        \"\"\"Native MCP tools: mcp_composio_SEARCH -> mcp_composio_SEARCH (kept).\n+    def test_restores_single_underscore_mcp_server_tool(self):\n+        \"\"\"``mcp__linear_get_issue`` -> ``mcp_linear_get_issue`` (MCP server tool).\n \n-        The tool is registered with the full mcp_ prefix in the registry.\n-        Stripping would break registry lookup.\n+        MCP server tools are registered under their full single-underscore\n+        ``mcp_<server>_<tool>`` name, but they MUST go on the OAuth wire as\n+        double-underscore to dodge the classifier.  The response side restores\n+        the single-underscore registry name so dispatch still resolves.\n         \"\"\"\n         transport = self._get_transport()\n-        block = _make_tool_use_block(\"mcp_composio_COMPOSIO_SEARCH_TOOLS\")\n+        block = _make_tool_use_block(\"mcp__linear_get_issue\")\n         response = _make_response(block)\n \n-        registry = _FakeRegistry({\n-            \"mcp_composio_COMPOSIO_SEARCH_TOOLS\",\n-            \"mcp_composio_COMPOSIO_GET_TOOL_SCHEMAS\",\n-            \"read_file\",\n-        })\n+        registry = _FakeRegistry({\"mcp_linear_get_issue\", \"read_file\"})\n         with patch(\"tools.registry.registry\", registry):\n             result = transport.normalize_response(response, strip_tool_prefix=True)\n \n         assert len(result.tool_calls) == 1\n-        assert result.tool_calls[0].name == \"mcp_composio_COMPOSIO_SEARCH_TOOLS\"\n+        assert result.tool_calls[0].name == \"mcp_linear_get_issue\"\n \n     def test_no_strip_when_flag_false(self):\n         \"\"\"When strip_tool_prefix=False, names are never modified.\"\"\"\n         transport = self._get_transport()\n-        block = _make_tool_use_block(\"mcp_read_file\")\n+        block = _make_tool_use_block(\"mcp__read_file\")\n         response = _make_response(block)\n \n         registry = _FakeRegistry({\"read_file\"})","@@ -110,10 +109,10 @@ def test_no_strip_when_flag_false(self):\n             result = transport.normalize_response(response, strip_tool_prefix=False)\n \n         assert len(result.tool_calls) == 1\n-        assert result.tool_calls[0].name == \"mcp_read_file\"\n+        assert result.tool_calls[0].name == \"mcp__read_file\"\n \n     def test_no_strip_when_not_mcp_prefixed(self):\n-        \"\"\"Non-mcp_ names are untouched regardless of strip flag.\"\"\"\n+        \"\"\"Non-``mcp__`` names are untouched regardless of strip flag.\"\"\"\n         transport = self._get_transport()\n         block = _make_tool_use_block(\"web_search\")\n         response = _make_response(block)","@@ -125,68 +124,61 @@ def test_no_strip_when_not_mcp_prefixed(self):\n         assert len(result.tool_calls) == 1\n         assert result.tool_calls[0].name == \"web_search\"\n \n-    def test_preserves_name_when_neither_in_registry(self):\n-        \"\"\"When neither stripped nor full name is in registry, keep full name.\n+    def test_preserves_name_when_no_original_in_registry(self):\n+        \"\"\"Neither the single-underscore nor bare original is registered.\n \n-        Safety fallback: if we can't determine the type, prefer the full name\n-        since it's what the LLM was told about.\n+        Safety fallback: keep the full ``mcp__`` name the LLM was told about.\n         \"\"\"\n         transport = self._get_transport()\n-        block = _make_tool_use_block(\"mcp_unknown_tool\")\n+        block = _make_tool_use_block(\"mcp__unknown_tool\")\n         response = _make_response(block)\n \n-        registry = _FakeRegistry({\"read_file\"})  # neither name registered\n+        registry = _FakeRegistry({\"read_file\"})  # no matching original\n         with patch(\"tools.registry.registry\", registry):\n             result = transport.normalize_response(response, strip_tool_prefix=True)\n \n         assert len(result.tool_calls) == 1\n-        assert result.tool_calls[0].name == \"mcp_unknown_tool\"\n+        assert result.tool_calls[0].name == \"mcp__unknown_tool\"\n \n-    def test_mixed_tools_same_response(self):\n-        \"\"\"Both OAuth and native MCP tools in the same response.\"\"\"\n+    def test_mixed_native_and_mcp_server_tools_same_response(self):\n+        \"\"\"A bare native tool and an MCP server tool, both wired as ``mcp__``.\"\"\"\n         transport = self._get_transport()\n-        block1 = _make_tool_use_block(\"mcp_read_file\", block_id=\"tc_1\")\n-        block2 = _make_tool_use_block(\"mcp_composio_SEARCH\", block_id=\"tc_2\")\n-        block3 = _make_tool_use_block(\"mcp_composio_SEARCH\", block_id=\"tc_3\")  # also registered natively\n-        response = _make_response(block1, block2, block3)\n-\n-        registry = _FakeRegistry({\n-            \"read_file\",  # OAuth-injected\n-            \"mcp_composio_SEARCH\",  # native MCP\n-        })\n+        block1 = _make_tool_use_block(\"mcp__read_file\", block_id=\"tc_1\")\n+        block2 = _make_tool_use_block(\"mcp__linear_get_issue\", block_id=\"tc_2\")\n+        response = _make_response(block1, block2)\n+\n+        registry = _FakeRegistry({\"read_file\", \"mcp_linear_get_issue\"})\n         with patch(\"tools.registry.registry\", registry):\n             result = transport.normalize_response(response, strip_tool_prefix=True)\n \n-        assert len(result.tool_calls) == 3\n-        # OAuth tool: stripped\n+        assert len(result.tool_calls) == 2\n         assert result.tool_calls[0].name == \"read_file\"\n-        # Native MCP: preserved (both stripped and full are registered, full wins)\n-        assert result.tool_calls[1].name == \"mcp_composio_SEARCH\"\n-        assert result.tool_calls[2].name == \"mcp_composio_SEARCH\"\n+        assert result.tool_calls[1].name == \"mcp_linear_get_issue\"\n \n-    def test_both_stripped_and_full_registered_prefers_full(self):\n-        \"\"\"Edge case: both 'foo' and 'mcp_foo' exist in registry.\n+    def test_prefers_full_wire_name_when_it_resolves_directly(self):\n+        \"\"\"If the ``mcp__`` wire name itself is registered, keep it as-is.\n \n-        Keep 'mcp_foo' (the original name) since it's what the LLM requested.\n+        Defensive: never rewrite a name that already resolves natively.\n         \"\"\"\n         transport = self._get_transport()\n-        block = _make_tool_use_block(\"mcp_foo\")\n+        block = _make_tool_use_block(\"mcp__foo\")\n         response = _make_response(block)\n \n-        registry = _FakeRegistry({\"foo\", \"mcp_foo\"})\n+        registry = _FakeRegistry({\"foo\", \"mcp__foo\"})\n         with patch(\"tools.registry.registry\", registry):\n             result = transport.normalize_response(response, strip_tool_prefix=True)\n \n         assert len(result.tool_calls) == 1\n-        # Both exist — the condition `get_entry(stripped) and not get_entry(name)`\n-        # is False because get_entry(name) IS truthy, so we keep the full name.\n-        assert result.tool_calls[0].name == \"mcp_foo\"\n+        assert result.tool_calls[0].name == \"mcp__foo\"\n \n \n+# ---------------------------------------------------------------------------\n+# Request side: registry name -> mcp__ wire name (no single-underscore leaks)\n+# ---------------------------------------------------------------------------\n+\n class TestAnthropicOAuthOutgoingPrefix:\n-    \"\"\"Verify the outgoing-side companion fix: build_anthropic_kwargs must not\n-    double-prefix tool names that already start with ``mcp_`` (native MCP server\n-    tools registered as ``mcp_<server>_<tool>``). GH-25255.\"\"\"\n+    \"\"\"build_anthropic_kwargs must emit ZERO single-underscore ``mcp_`` names on\n+    the OAuth wire — bare names and MCP server names both land on ``mcp__``.\"\"\"\n \n     def _build(self, tools, is_oauth=True):\n         from agent.anthropic_adapter import build_anthropic_kwargs","@@ -199,50 +191,65 @@ def _build(self, tools, is_oauth=True):\n             is_oauth=is_oauth,\n         )\n \n-    def test_oauth_adds_prefix_to_bare_tool_name(self):\n-        \"\"\"OAuth + bare name → prefix added (existing Claude Code convention).\"\"\"\n+    def test_oauth_adds_double_prefix_to_bare_tool_name(self):\n+        \"\"\"OAuth + bare name -> ``mcp__`` prefix added.\"\"\"\n         kwargs = self._build([{\n             \"type\": \"function\",\n             \"function\": {\"name\": \"read_file\", \"description\": \"x\", \"parameters\": {}},\n         }])\n-        names = [t[\"name\"] for t in kwargs[\"tools\"]]\n-        assert names == [\"mcp_read_file\"]\n+        assert [t[\"name\"] for t in kwargs[\"tools\"]] == [\"mcp__read_file\"]\n \n-    def test_oauth_does_not_double_prefix_native_mcp_tool(self):\n-        \"\"\"OAuth + already-prefixed native MCP name → left alone.\"\"\"\n+    def test_oauth_promotes_single_underscore_mcp_server_tool(self):\n+        \"\"\"OAuth + ``mcp_<server>_<tool>`` -> promoted to double underscore.\n+\n+        This is the gap left by the bare constant swap: MCP server tools used\n+        to be *skipped* and went on the wire single-underscore, still tripping\n+        the classifier.  They must become ``mcp__`` and NOT be double-prefixed.\n+        \"\"\"\n         kwargs = self._build([{\n             \"type\": \"function\",\n             \"function\": {\n-                \"name\": \"mcp_composio_COMPOSIO_SEARCH_TOOLS\",\n+                \"name\": \"mcp_linear_get_issue\",\n                 \"description\": \"x\",\n                 \"parameters\": {},\n             },\n         }])\n         names = [t[\"name\"] for t in kwargs[\"tools\"]]\n-        # Must NOT become \"mcp_mcp_composio_...\" — that breaks the round-trip\n-        # because normalize_response only strips ONE mcp_ prefix.\n-        assert names == [\"mcp_composio_COMPOSIO_SEARCH_TOOLS\"]\n+        assert names == [\"mcp__linear_get_issue\"]\n+        # never double-prefixed\n+        assert not any(n.startswith(\"mcp__mcp_\") for n in names)\n+\n+    def test_oauth_already_double_prefixed_left_alone(self):\n+        \"\"\"OAuth + already-``mcp__`` name -> unchanged (no triple underscore).\"\"\"\n+        kwargs = self._build([{\n+            \"type\": \"function\",\n+            \"function\": {\"name\": \"mcp__already\", \"description\": \"x\", \"parameters\": {}},\n+        }])\n+        assert [t[\"name\"] for t in kwargs[\"tools\"]] == [\"mcp__already\"]\n \n-    def test_oauth_mixed_native_and_bare_tools(self):\n-        \"\"\"Mixed: native MCP preserved, bare names prefixed.\"\"\"\n+    def test_oauth_no_single_underscore_mcp_on_wire(self):\n+        \"\"\"Mixed set: every wire name is bare-free of single-underscore mcp_.\"\"\"\n         kwargs = self._build([\n             {\"type\": \"function\", \"function\": {\"name\": \"read_file\",\n-                                               \"description\": \"x\", \"parameters\": {}}},\n-            {\"type\": \"function\", \"function\": {\"name\": \"mcp_composio_SEARCH\",\n-                                               \"description\": \"y\", \"parameters\": {}}},\n+                                              \"description\": \"x\", \"parameters\": {}}},\n+            {\"type\": \"function\", \"function\": {\"name\": \"mcp_linear_get_issue\",\n+                                              \"description\": \"y\", \"parameters\": {}}},\n             {\"type\": \"function\", \"function\": {\"name\": \"terminal\",\n-                                               \"description\": \"z\", \"parameters\": {}}},\n+                                              \"description\": \"z\", \"parameters\": {}}},\n         ])\n         names = sorted(t[\"name\"] for t in kwargs[\"tools\"])\n-        assert names == [\"mcp_composio_SEARCH\", \"mcp_read_file\", \"mcp_terminal\"]\n+        assert names == [\"mcp__linear_get_issue\", \"mcp__read_file\", \"mcp__terminal\"]\n+        # The core invariant: NOTHING single-underscore reaches the wire.\n+        for n in names:\n+            assert not (n.startswith(\"mcp_\") and not n.startswith(\"mcp__\"))\n \n     def test_non_oauth_path_untouched(self):\n         \"\"\"Non-OAuth requests never get the prefix — schemas pass through as-is.\"\"\"\n         kwargs = self._build([\n             {\"type\": \"function\", \"function\": {\"name\": \"read_file\",\n-                                               \"description\": \"x\", \"parameters\": {}}},\n-            {\"type\": \"function\", \"function\": {\"name\": \"mcp_composio_SEARCH\",\n-                                               \"description\": \"y\", \"parameters\": {}}},\n+                                              \"description\": \"x\", \"parameters\": {}}},\n+            {\"type\": \"function\", \"function\": {\"name\": \"mcp_linear_get_issue\",\n+                                              \"description\": \"y\", \"parameters\": {}}},\n         ], is_oauth=False)\n         names = sorted(t[\"name\"] for t in kwargs[\"tools\"])\n-        assert names == [\"mcp_composio_SEARCH\", \"read_file\"]\n+        assert names == [\"mcp_linear_get_issue\", \"read_file\"]\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}