{"repo":"NousResearch/hermes-agent","public_id":"14186","display_label":"MCP tool name normalization and dispatch failures","summary":"These issues and fixes center on tool dispatch failures caused by naming mismatches—including stripped or added `mcp_` prefixes, CamelCase/PascalCase drift, and `_tool` suffix variations—between how tools are registered and how Anthropic/Claude models emit tool names. Several fixes address normalization at dispatch, registry, and OAuth round-trip layers.","subsystem":"agent","counts":{"members":11,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":2,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":15124},"resolution":{"state":"partially_resolved","by":"pr-15124,pr-31700"},"freshness":{"state":"verified","last_verified_at":"2026-05-24T22:27:45+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.17163636363636367},"fix_edges":[{"issue_number":14186,"pr_number":15124,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":14784,"pr_number":15124,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":25255,"pr_number":31700,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":14186,"title":"registry.dispatch: 'Unknown tool' error for trivially-normalizable CamelCase/_tool-suffix drift from Claude family","url":null,"state":"closed"},{"kind":"issue","number":14784,"title":"[Bug]: Tool dispatch fails with \"Unknown tool\" for CamelCase/_tool-suffix model emissions","url":null,"state":"closed"},{"kind":"issue","number":16817,"title":"Anthropic MCP PascalCase tool responses do not normalize to registered tool names","url":null,"state":"closed"},{"kind":"issue","number":25255,"title":"[Bug]: Anthropic OAuth strips `mcp_` prefix from Hermes-native MCP tool names, breaking registry lookup","url":null,"state":"closed"},{"kind":"pr","number":14188,"title":"fix(registry): normalize tool names at dispatch entry to tolerate LLM drift","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a1caec108","number":0},{"kind":"pr","value":"#15124","number":15124},{"kind":"pr","value":"#15124","number":15124}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":14786,"title":"fix(tools): add fallback normalization for CamelCase/_tool-suffix drift in tool dispatch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a1caec1088a2eb0d9dea55cc52d2e173d8395b5b","number":0},{"kind":"pr","value":"#15124","number":15124},{"kind":"pr","value":"#14784","number":14784}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":15124,"title":"fix(agent): repair CamelCase + _tool suffix tool-call emissions","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","tests/run_agent/test_repair_tool_call_name.py"]},{"kind":"pr","number":16820,"title":"fix: normalize Anthropic PascalCase MCP tool names","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#16957","number":16957}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":16952,"title":"fix: preserve tool-turn context and harden issue follow-ups","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52618","number":52618},{"kind":"pr","value":"#14185","number":14185},{"kind":"pr","value":"#14785","number":14785}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":16967,"title":"fix(agent): normalize Anthropic MCP tool names","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#16817","number":16817}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":31700,"title":"fix(anthropic-oauth): registry-aware mcp_ prefix on both ends of round-trip","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py","agent/transports/anthropic.py","scripts/release.py","tests/agent/test_anthropic_mcp_prefix_strip.py"],"fix_hunks":[{"file":"agent/anthropic_adapter.py","hunks":["@@ -2122,9 +2122,13 @@ def build_anthropic_kwargs(\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         if anthropic_tools:\n             for tool in anthropic_tools:\n-                if \"name\" in tool:\n+                if \"name\" in tool and not tool[\"name\"].startswith(_MCP_TOOL_PREFIX):\n                     tool[\"name\"] = _MCP_TOOL_PREFIX + tool[\"name\"]\n \n         # 4. Prefix tool names in message history (tool_use and tool_result blocks)"]},{"file":"agent/transports/anthropic.py","hunks":["@@ -106,7 +106,17 @@ 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-                    name = name[len(_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+                    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                 tool_calls.append(\n                     ToolCall(\n                         id=block.id,"]},{"file":"scripts/release.py","hunks":["@@ -194,6 +194,7 @@\n     \"[redacted:email]\": \"aqilaziz\",  # PR #26406 salvage (preserve native audio outside Telegram)\n     \"[redacted:email]\": \"karthikeyann\",  # PR #26609 salvage (DM-topic routing pin)\n     \"[redacted:email]\": \"kunci115\",  # PR #27098 salvage (thread-not-found retry)\n+    \"[redacted:email]\": \"hayka-pacha\",  # PR #25270 salvage (registry-aware mcp_ prefix strip)\n     \"[redacted:email]\": \"chromalinx\",  # PR #27014 salvage (commands for groups+DM)\n     \"[redacted:email]\": \"booker1207\",  # PR #25132 salvage (gate profile bots by allowed topics)\n     \"[redacted:email]\": \"kiranvk-2011\",  # PR #24815 salvage (image documents → vision)"]},{"file":"tests/agent/test_anthropic_mcp_prefix_strip.py","hunks":["@@ -0,0 +1,250 @@\n+\"\"\"Tests for GH-25255: Anthropic OAuth mcp_ prefix stripping.\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+\"\"\"\n+\n+from __future__ import annotations\n+\n+import json\n+from types import SimpleNamespace\n+from unittest.mock import patch\n+\n+import pytest\n+\n+\n+# ---------------------------------------------------------------------------\n+# Helpers\n+# ---------------------------------------------------------------------------\n+\n+def _make_tool_use_block(name: str, block_id: str = \"tc_1\", input_data: dict | None = None):\n+    \"\"\"Create a fake Anthropic tool_use content block.\"\"\"\n+    return SimpleNamespace(\n+        type=\"tool_use\",\n+        id=block_id,\n+        name=name,\n+        input=input_data or {\"query\": \"test\"},\n+    )\n+\n+\n+def _make_response(*blocks, stop_reason=\"end_turn\"):\n+    \"\"\"Create a fake Anthropic Messages response.\"\"\"\n+    return SimpleNamespace(\n+        content=list(blocks),\n+        stop_reason=stop_reason,\n+        model=\"claude-sonnet-4\",\n+        usage=SimpleNamespace(input_tokens=100, output_tokens=50),\n+    )\n+\n+\n+class _FakeRegistry:\n+    \"\"\"Minimal fake tool registry for testing prefix stripping logic.\"\"\"\n+\n+    def __init__(self, registered_names: set[str]):\n+        self._names = registered_names\n+\n+    def get_entry(self, name: str):\n+        if name in self._names:\n+            return SimpleNamespace(name=name)  # truthy = tool exists\n+        return None\n+\n+\n+# ---------------------------------------------------------------------------\n+# Tests\n+# ---------------------------------------------------------------------------\n+\n+class TestAnthropicMcpPrefixStrip:\n+    \"\"\"Verify that strip_tool_prefix only strips OAuth-injected prefixes.\"\"\"\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+        transport = self._get_transport()\n+        block = _make_tool_use_block(\"mcp_read_file\")\n+        response = _make_response(block)\n+\n+        registry = _FakeRegistry({\"read_file\", \"terminal\", \"web_search\"})\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 == \"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+\n+        The tool is registered with the full mcp_ prefix in the registry.\n+        Stripping would break registry lookup.\n+        \"\"\"\n+        transport = self._get_transport()\n+        block = _make_tool_use_block(\"mcp_composio_COMPOSIO_SEARCH_TOOLS\")\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+        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+\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+        response = _make_response(block)\n+\n+        registry = _FakeRegistry({\"read_file\"})\n+        with patch(\"tools.registry.registry\", registry):\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+\n+    def test_no_strip_when_not_mcp_prefixed(self):\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)\n+\n+        registry = _FakeRegistry({\"web_search\"})\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 == \"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+\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+        \"\"\"\n+        transport = self._get_transport()\n+        block = _make_tool_use_block(\"mcp_unknown_tool\")\n+        response = _make_response(block)\n+\n+        registry = _FakeRegistry({\"read_file\"})  # neither name registered\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+\n+    def test_mixed_tools_same_response(self):\n+        \"\"\"Both OAuth and native MCP tools in the same response.\"\"\"\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+        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 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+\n+    def test_both_stripped_and_full_registered_prefers_full(self):\n+        \"\"\"Edge case: both 'foo' and 'mcp_foo' exist in registry.\n+\n+        Keep 'mcp_foo' (the original name) since it's what the LLM requested.\n+        \"\"\"\n+        transport = self._get_transport()\n+        block = _make_tool_use_block(\"mcp_foo\")\n+        response = _make_response(block)\n+\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+\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+\n+    def _build(self, tools, is_oauth=True):\n+        from agent.anthropic_adapter import build_anthropic_kwargs\n+        return build_anthropic_kwargs(\n+            model=\"claude-sonnet-4-6\",\n+            messages=[{\"role\": \"user\", \"content\": \"Hi\"}],\n+            tools=tools,\n+            max_tokens=4096,\n+            reasoning_config=None,\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+        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+\n+    def test_oauth_does_not_double_prefix_native_mcp_tool(self):\n+        \"\"\"OAuth + already-prefixed native MCP name → left alone.\"\"\"\n+        kwargs = self._build([{\n+            \"type\": \"function\",\n+            \"function\": {\n+                \"name\": \"mcp_composio_COMPOSIO_SEARCH_TOOLS\",\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+\n+    def test_oauth_mixed_native_and_bare_tools(self):\n+        \"\"\"Mixed: native MCP preserved, bare names prefixed.\"\"\"\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+            {\"type\": \"function\", \"function\": {\"name\": \"terminal\",\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+\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+        ], is_oauth=False)\n+        names = sorted(t[\"name\"] for t in kwargs[\"tools\"])\n+        assert names == [\"mcp_composio_SEARCH\", \"read_file\"]\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}