# NousResearch/hermes-agent · MCP tool name normalization and dispatch failures

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 11 member(s): 0 open issue(s), 4 closed issue(s), 0 open PR(s), 2 merged PR(s), 5 PR(s) closed without merging.
- A fix was merged to main in PR #15124. Verify it addresses your case.
- **Recommended:** read PR #15124 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-15124,pr-31700`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.17`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #14186: registry.dispatch: 'Unknown tool' error for trivially-normalizable CamelCase/_tool-suffix drift from Claude family
- #14784: \[Bug\]: Tool dispatch fails with "Unknown tool" for CamelCase/_tool-suffix model emissions
- #16817: Anthropic MCP PascalCase tool responses do not normalize to registered tool names
- #25255: \[Bug\]: Anthropic OAuth strips \`mcp_\` prefix from Hermes-native MCP tool names, breaking registry lookup

## Prior & competing PRs

- **#15124: fix(agent): repair CamelCase + _tool suffix tool-call emissions**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `run_agent.py`, `tests/run_agent/test_repair_tool_call_name.py`
- **#16967: fix(agent): normalize Anthropic MCP tool names**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#16817`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#31700: fix(anthropic-oauth): registry-aware mcp_ prefix on both ends of round-trip**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/anthropic_adapter.py`, `agent/transports/anthropic.py`, `scripts/release.py`, `tests/agent/test_anthropic_mcp_prefix_strip.py`
  - fix hunks:
  - `agent/anthropic_adapter.py`

    ```diff
    @@ -2122,9 +2122,13 @@ def build_anthropic_kwargs(
                     block["text"] = text
     
             # 3. Prefix tool names with mcp_ (Claude Code convention)
    +        #    Skip names that already begin with the marker — native MCP server
    +        #    tools (from mcp_servers: in config.yaml) are registered under their
    +        #    full mcp_<server>_<tool> name and would double-prefix otherwise,
    +        #    breaking round-trip registry lookup in normalize_response. GH-25255.
             if anthropic_tools:
                 for tool in anthropic_tools:
    -                if "name" in tool:
    +                if "name" in tool and not tool["name"].startswith(_MCP_TOOL_PREFIX):
                         tool["name"] = _MCP_TOOL_PREFIX + tool["name"]
     
             # 4. Prefix tool names in message history (tool_use and tool_result blocks)
    ```
  - `agent/transports/anthropic.py`

    ```diff
    @@ -106,7 +106,17 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
                 elif block.type == "tool_use":
                     name = block.name
                     if strip_tool_prefix and name.startswith(_MCP_PREFIX):
    -                    name = name[len(_MCP_PREFIX):]
    +                    stripped = name[len(_MCP_PREFIX):]
    +                    # Only strip the mcp_ prefix for OAuth-injected tools
    +                    # (where Hermes adds the prefix when sending to Anthropic
    +                    # and must remove it on the way back).  Native MCP server
    +                    # tools (from mcp_servers: in config.yaml) are registered
    +                    # in the tool registry under their FULL mcp_<server>_<tool>
    +                    # name and must NOT be stripped.  GH-25255.
    +                    from tools.registry import registry as _tool_registry
    +                    if (_tool_registry.get_entry(stripped)
    +                            and not _tool_registry.get_entry(name)):
    +                        name = stripped
                     tool_calls.append(
                         ToolCall(
                             id=block.id,
    ```
  - `scripts/release.py`

    ```diff
    @@ -194,6 +194,7 @@
         "[redacted:email]": "aqilaziz",  # PR #26406 salvage (preserve native audio outside Telegram)
         "[redacted:email]": "karthikeyann",  # PR #26609 salvage (DM-topic routing pin)
         "[redacted:email]": "kunci115",  # PR #27098 salvage (thread-not-found retry)
    +    "[redacted:email]": "hayka-pacha",  # PR #25270 salvage (registry-aware mcp_ prefix strip)
         "[redacted:email]": "chromalinx",  # PR #27014 salvage (commands for groups+DM)
         "[redacted:email]": "booker1207",  # PR #25132 salvage (gate profile bots by allowed topics)
         "[redacted:email]": "kiranvk-2011",  # PR #24815 salvage (image documents → vision)
    ```
  - `tests/agent/test_anthropic_mcp_prefix_strip.py`

    ```diff
    @@ -0,0 +1,250 @@
    +"""Tests for GH-25255: Anthropic OAuth mcp_ prefix stripping.
    +
    +When strip_tool_prefix=True (Anthropic OAuth path), the transport must only
    +strip the ``mcp_`` prefix from OAuth-injected tools, NOT from Hermes-native
    +MCP server tools that are registered under their full ``mcp_<server>_<tool>``
    +name in the tool registry.
    +"""
    +
    +from __future__ import annotations
    +
    +import json
    +from types import SimpleNamespace
    +from unittest.mock import patch
    +
    +import pytest
    +
    +
    +# ---------------------------------------------------------------------------
    +# 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 stripping 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
    +
    +
    +# ---------------------------------------------------------------------------
    +# Tests
    +# ---------------------------------------------------------------------------
    +
    +class TestAnthropicMcpPrefixStrip:
    +    """Verify that strip_tool_prefix only strips OAuth-injected prefixes."""
    +
    +    def _get_transport(self):
    +        from agent.transports.anthropic import AnthropicTransport
    +        return AnthropicTransport()
    +
    +    def test_strips_prefix_for_oauth_injected_tool(self):
    +        """OAuth tools: mcp_read_file -> read_file (stripped).
    +
    +        The tool was registered as 'read_file' in the registry.
    +        Anthropic sees 'mcp_read_file' because Hermes adds the prefix.
    +        On response, we must strip it back to 'read_file'.
    +        """
    +        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_preserves_native_mcp_server_tool_name(self):
    +        """Native MCP tools: mcp_composio_SEARCH -> mcp_composio_SEARCH (kept).
    +
    +        The tool is registered with the full mcp_ prefix in the registry.
    +        Stripping would break registry lookup.
    +        """
    +        transport = self._get_transport()
    +        block = _make_tool_use_block("mcp_composio_COMPOSIO_SEARCH_TOOLS")
    +        response = _make_response(block)
    +
    +        registry = _FakeRegistry({
    +            "mcp_composio_COMPOSIO_SEARCH_TOOLS",
    +            "mcp_composio_COMPOSIO_GET_TOOL_SCHEMAS",
    +            "read_file",
    +        })
    +        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_composio_COMPOSIO_SEARCH_TOOLS"
    +
    +    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"
    +
    +    def test_no_strip_when_not_mcp_prefixed(self):
    +        """Non-mcp_ names are untouched regardless of strip flag."""
    +        transport = self._get_transport()
    +        block = _make_tool_use_block("web_search")
    +        response = _make_response(block)
    +
    +        registry = _FakeRegistry({"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 == "web_search"
    +
    +    def test_preserves_name_when_neither_in_registry(self):
    +        """When neither stripped nor full name is in registry, keep full name.
    +
    +        Safety fallback: if we can't determine the type, prefer the full name
    +        since it's what the LLM was told about.
    +        """
    +        transport = self._get_transport()
    +        block = _make_tool_use_block("mcp_unknown_tool")
    +        response = _make_response(block)
    +
    +        registry = _FakeRegistry({"read_file"})  # neither name registered
    +        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_unknown_tool"
    +
    +    def test_mixed_tools_same_response(self):
    +        """Both OAuth and native MCP tools in the same response."""
    +        transport = self._get_transport()
    +        block1 = _make_tool_use_block("mcp_read_file", block_id="tc_1")
    +        block2 = _make_tool_use_block("mcp_composio_SEARCH", block_id="tc_2")
    +        block3 = _make_tool_use_block("mcp_composio_SEARCH", block_id="tc_3")  # also registered natively
    +        response = _make_response(block1, block2, block3)
    +
    +        registry = _FakeRegistry({
    +            "read_file",  # OAuth-injected
    +            "mcp_composio_SEARCH",  # native MCP
    +        })
    +        with patch("tools.registry.registry", registry):
    +            result = transport.normalize_response(response, strip_tool_prefix=True)
    +
    +        assert len(result.tool_calls) == 3
    +        # OAuth tool: stripped
    +        assert result.tool_calls[0].name == "read_file"
    +        # Native MCP: preserved (both stripped and full are registered, full wins)
    +        assert result.tool_calls[1].name == "mcp_composio_SEARCH"
    +        assert result.tool_calls[2].name == "mcp_composio_SEARCH"
    +
    +    def test_both_stripped_and_full_registered_prefers_full(self):
    +        """Edge case: both 'foo' and 'mcp_foo' exist in registry.
    +
    +        Keep 'mcp_foo' (the original name) since it's what the LLM requested.
    +        """
    +        transport = self._get_transport()
    +        block = _make_tool_use_block("mcp_foo")
    +        response = _make_response(block)
    +
    +        registry = _FakeRegistry({"foo", "mcp_foo"})
    +        with patch("tools.registry.registry", registry):
    +            result = transport.normalize_response(response, strip_tool_prefix=True)
    +
    +        assert len(result.tool_calls) == 1
    +        # Both exist — the condition `get_entry(stripped) and not get_entry(name)`
    +        # is False because get_entry(name) IS truthy, so we keep the full name.
    +        assert result.tool_calls[0].name == "mcp_foo"
    +
    +
    +class TestAnthropicOAuthOutgoingPrefix:
    +    """Verify the outgoing-side companion fix: build_anthropic_kwargs must not
    +    double-prefix tool names that already start with ``mcp_`` (native MCP server
    +    tools registered as ``mcp_<server>_<tool>``). GH-25255."""
    +
    +    def _build(self, tools, is_oauth=True):
    +        from agent.anthropic_adapter import build_anthropic_kwargs
    +        return build_anthropic_kwargs(
    +            model="claude-sonnet-4-6",
    +            messages=[{"role": "user", "content": "Hi"}],
    +            tools=tools,
    +            max_tokens=4096,
    +            reasoning_config=None,
    +            is_oauth=is_oauth,
    +        )
    +
    +    def test_oauth_adds_prefix_to_bare_tool_name(self):
    +        """OAuth + bare name → prefix added (existing Claude Code convention)."""
    +        kwargs = self._build([{
    +            "type": "function",
    +            "function": {"name": "read_file", "description": "x", "parameters": {}},
    +        }])
    +        names = [t["name"] for t in kwargs["tools"]]
    +        assert names == ["mcp_read_file"]
    +
    +    def test_oauth_does_not_double_prefix_native_mcp_tool(self):
    +        """OAuth + already-prefixed native MCP name → left alone."""
    +        kwargs = self._build([{
    +            "type": "function",
    +            "function": {
    +                "name": "mcp_composio_COMPOSIO_SEARCH_TOOLS",
    +                "description": "x",
    +                "parameters": {},
    +            },
    +        }])
    +        names = [t["name"] for t in kwargs["tools"]]
    +        # Must NOT become "mcp_mcp_composio_..." — that breaks the round-trip
    +        # because normalize_response only strips ONE mcp_ prefix.
    +        assert names == ["mcp_composio_COMPOSIO_SEARCH_TOOLS"]
    +
    +    def test_oauth_mixed_native_and_bare_tools(self):
    +        """Mixed: native MCP preserved, bare names prefixed."""
    +        kwargs = self._build([
    +            {"type": "function", "function": {"name": "read_file",
    +                                               "description": "x", "parameters": {}}},
    +            {"type": "function", "function": {"name": "mcp_composio_SEARCH",
    +                                               "description": "y", "parameters": {}}},
    +            {"type": "function", "function": {"name": "terminal",
    +                                               "description": "z", "parameters": {}}},
    +        ])
    +        names = sorted(t["name"] for t in kwargs["tools"])
    +        assert names == ["mcp_composio_SEARCH", "mcp_read_file", "mcp_terminal"]
    +
    +    def test_non_oauth_path_untouched(self):
    +        """Non-OAuth requests never get the prefix — schemas pass through as-is."""
    +        kwargs = self._build([
    +            {"type": "function", "function": {"name": "read_file",
    +                                               "description": "x", "parameters": {}}},
    +            {"type": "function", "function": {"name": "mcp_composio_SEARCH",
    +                                               "description": "y", "parameters": {}}},
    +        ], is_oauth=False)
    +        names = sorted(t["name"] for t in kwargs["tools"])
    +        assert names == ["mcp_composio_SEARCH", "read_file"]
    ```
- **#14188: fix(registry): normalize tool names at dispatch entry to tolerate LLM drift**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `a1caec108`, `#15124`, `#15124`
- **#14786: fix(tools): add fallback normalization for CamelCase/_tool-suffix drift in tool dispatch**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `a1caec1088a2eb0d9dea55cc52d2e173d8395b5b`, `#15124`, `#14784`
- **#16820: fix: normalize Anthropic PascalCase MCP tool names**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#16957`
- **#16952: fix: preserve tool-turn context and harden issue follow-ups**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#52618`, `#14185`, `#14785`

## Fix status

- Merged to main in PR #15124 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-24`.
- Subsystem: `agent`
