MCP structured content handling and preservation bugs

NousResearch/hermes-agent · cluster 5874 · mcp

Data as of

Nothing open on GitHub. Fix merged in #5979. Check it covers your case.

The short answer

0open issues
0open PRs
5items grouped
Known?
Yes. 5 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #5979. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #5979. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and PRs concern bugs where MCP clients or tool adapters discard or fail to combine structuredContent and text content in tool call results. Multiple fix attempts address preserving and merging both content types when present.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #5979 fix(mcp): prefer structuredContent over text + no_mcp sentinel (salvage #5898 + #5841)

    merged to main

    files: hermes_cli/tools_config.py, tests/hermes_cli/test_tools_config.py, tests/tools/test_mcp_structured_content.py, tools/mcp_tool.py

    Show the diff
    • hermes_cli/tools_config.py
      @@ -554,6 +554,7 @@ def _get_platform_tools(
           # MCP servers are expected to be available on all platforms by default.
           # If the platform explicitly lists one or more MCP server names, treat that
           # as an allowlist. Otherwise include every globally enabled MCP server.
      +    # Special sentinel: "no_mcp" in the toolset list disables all MCP servers.
           mcp_servers = config.get("mcp_servers") or {}
           enabled_mcp_servers = {
               name
      @@ -561,10 +562,15 @@ def _get_platform_tools(
               if isinstance(server_cfg, dict)
               and _parse_enabled_flag(server_cfg.get("enabled", True), default=True)
           }
      -    explicit_mcp_servers = explicit_passthrough & enabled_mcp_servers
      -    enabled_toolsets.update(explicit_passthrough - enabled_mcp_servers)
      +    # Allow "no_mcp" sentinel to opt out of all MCP servers for this platform
      +    if "no_mcp" in toolset_names:
      +        explicit_mcp_servers = set()
      +        enabled_toolsets.update(explicit_passthrough - enabled_mcp_servers - {"no_mcp"})
      +    else:
      +        explicit_mcp_servers = explicit_passthrough & enabled_mcp_servers
      +        enabled_toolsets.update(explicit_passthrough - enabled_mcp_servers)
           if include_default_mcp_servers:
      -        if explicit_mcp_servers:
      +        if explicit_mcp_servers or "no_mcp" in toolset_names:
                   enabled_toolsets.update(explicit_mcp_servers)
               else:
                   enabled_toolsets.update(enabled_mcp_servers)
    • tests/hermes_cli/test_tools_config.py
      @@ -72,6 +72,45 @@ def test_get_platform_tools_keeps_enabled_mcp_servers_with_explicit_builtin_sele
           assert "web-search-prime" in enabled
       
       
      +def test_get_platform_tools_no_mcp_sentinel_excludes_all_mcp_servers():
      +    """The 'no_mcp' sentinel in platform_toolsets excludes all MCP servers."""
      +    config = {
      +        "platform_toolsets": {"cli": ["web", "terminal", "no_mcp"]},
      +        "mcp_servers": {
      +            "exa": {"url": "https://mcp.exa.ai/mcp"},
      +            "web-search-prime": {"url": "https://api.z.ai/api/mcp/web_search_prime/mcp"},
      +        },
      +    }
      +
      +    enabled = _get_platform_tools(config, "cli")
      +
      +    assert "web" in enabled
      +    assert "terminal" in enabled
      +    assert "exa" not in enabled
      +    assert "web-search-prime" not in enabled
      +    assert "no_mcp" not in enabled
      +
      +
      +def test_get_platform_tools_no_mcp_sentinel_does_not_affect_other_platforms():
      +    """The 'no_mcp' sentinel only affects the platform it's configured on."""
      +    config = {
      +        "platform_toolsets": {
      +            "api_server": ["web", "terminal", "no_mcp"],
      +        },
      +        "mcp_servers": {
      +            "exa": {"url": "https://mcp.exa.ai/mcp"},
      +        },
      +    }
      +
      +    # api_server should exclude MCP
      +    api_enabled = _get_platform_tools(config, "api_server")
      +    assert "exa" not in api_enabled
      +
      +    # cli (not configured with no_mcp) should include MCP
      +    cli_enabled = _get_platform_tools(config, "cli")
      +    assert "exa" in cli_enabled
      +
      +
       def test_toolset_has_keys_for_vision_accepts_codex_auth(tmp_path, monkeypatch):
           monkeypatch.setenv("HERMES_HOME", str(tmp_path))
           (tmp_path / "auth.json").write_text(
    • tests/tools/test_mcp_structured_content.py
      @@ -0,0 +1,111 @@
      +"""Tests for MCP tool structuredContent preservation."""
      +
      +import asyncio
      +import json
      +from types import SimpleNamespace
      +from unittest.mock import AsyncMock, MagicMock, patch
      +
      +import pytest
      +
      +from tools import mcp_tool
      +
      +
      +class _FakeContentBlock:
      +    """Minimal content block with .text and .type attributes."""
      +
      +    def __init__(self, text: str, block_type: str = "text"):
      +        self.text = text
      +        self.type = block_type
      +
      +
      +class _FakeCallToolResult:
      +    """Minimal CallToolResult stand-in.
      +
      +    Uses camelCase ``structuredContent`` / ``isError`` to match the real
      +    MCP SDK Pydantic model (``mcp.types.CallToolResult``).
      +    """
      +
      +    def __init__(self, content, is_error=False, structuredContent=None):
      +        self.content = content
      +        self.isError = is_error
      +        self.structuredContent = structuredContent
      +
      +
      +def _fake_run_on_mcp_loop(coro, timeout=30):
      +    """Run an MCP coroutine directly in a fresh event loop."""
      +    loop = asyncio.new_event_loop()
      +    try:
      +        return loop.run_until_complete(coro)
      +    finally:
      +        loop.close()
      +
      +
      [redacted:email]
      +def _patch_mcp_server():
      +    """Patch _servers and the MCP event loop so _make_tool_handler can run."""
      +    fake_session = MagicMock()
      +    fake_server = SimpleNamespace(session=fake_session)
      +    with patch.dict(mcp_tool._servers, {"test-server": fake_server}), \
      +         patch("tools.mcp_tool._run_on_mcp_loop", side_effect=_fake_run_on_mcp_loop):
      +        yield fake_session
      +
      +
      +class TestStructuredContentPreservation:
      +    """Ensure structuredContent from CallToolResult is forwarded."""
      +
      +    def test_text_only_result(self, _patch_mcp_server):
      +        """When no structuredContent, result is text-only (existing behaviour)."""
      +        session = _patch_mcp_server
      +        session.call_tool = AsyncMock(
      +            return_value=_FakeCallToolResult(
      +                content=[_FakeContentBlock("hello")],
      +            )
      +        )
      +        handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0)
      +        raw = handler({})
      +        data = json.loads(raw)
      +        assert data == {"result": "hello"}
      +
      +    def test_structured_content_is_the_result(self, _patch_mcp_server):
      +        """When structuredContent is present, it becomes the result directly."""
      +        session = _patch_mcp_server
      +        payload = {"value": "secret-123", "revealed": True}
      +        session.call_tool = AsyncMock(
      +            return_value=_FakeCallToolResult(
      +                content=[_FakeContentBlock("OK")],
      +                structuredContent=payload,
      +            )
      +        )
      +        handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0)
      +        raw = handler({})
      +        data = json.loads(raw)
      +        assert data["result"] == payload
      +
      +    def test_structured_content_none_falls_back_to_text(self, _patch_mcp_server):
      +        """When structuredContent is explicitly None, fall back to text."""
      +        session = _patch_mcp_server
      +        session.call_tool = AsyncMock(
      +            return_value=_FakeCallToolResult(
      +                content=[_FakeContentBlock("done")],
      +                structuredContent=None,
      +            )
      +        )
      +        handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0)
      +        raw = handler({})
      +        data = json.loads(raw)
      +        assert data == {"result": "done"}
      +
      +    def test_empty_text_with_structured_content(self, _patch_mcp_server):
      +        """When content blocks are empty but structuredContent exists."""
      +        session = _patch_mcp_server
      +        payload = {"status": "ok", "data": [1, 2, 3]}
      +        session.call_tool = AsyncMock(
      +            return_value=_FakeCallToolResult(
      +                content=[],
      +                structuredContent=payload,
      +            )
      +        )
      +        handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0)
      +        raw = handler({})
      +        data = json.loads(raw)
      +        assert data["result"] == payload
    • tools/mcp_tool.py
      @@ -1253,7 +1253,13 @@ async def _call():
                   for block in (result.content or []):
                       if hasattr(block, "text"):
                           parts.append(block.text)
      -            return json.dumps({"result": "\n".join(parts) if parts else ""})
      +            text_result = "\n".join(parts) if parts else ""
      +
      +            # Prefer structuredContent (machine-readable JSON) over plain text
      +            structured = getattr(result, "structuredContent", None)
      +            if structured is not None:
      +                return json.dumps({"result": structured})
      +            return json.dumps({"result": text_result})
       
               try:
                   return _run_on_mcp_loop(_call(), timeout=tool_timeout)

2 PRs closed without merging

From the close comments: closed without comment 1 · merged via another change 1

  • #5898 fix(mcp): preserve structured_content in tool call results · merged via another change → #5979
  • #7048 fix(mcp): preserve text content when structuredContent is present · closed without comment
Closed issues · 2
  • Closed#5874[Bug]: MCP tool adapter drops structuredContent and only forwards content text
  • Closed#7043[Bug]: MCP client discards text content when structuredContent is present
How this cluster was grouped
  • Link evidence (partly structural links): 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.34: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
mcp
Merged PRs
#5979
Closed issues
2
Attempts
2 PRs closed without merging
Fix link checked
8 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.