Files
hermes-agent/tests/tools/test_mcp_structured_content.py
T
Teknium c031fec365 Port from MoonshotAI/kimi-code#2596/#2600: surface MCP tool-result _meta to the model, minus protocol-reserved keys
MCP tool results carry a server _meta mapping (exposed as .meta by the
Python SDK) alongside structuredContent. Servers return namespaced
machine-readable contracts there (validated payloads, browser-handoff
URLs); Hermes previously dropped the field entirely, so that data was
invisible to the agent.

Now _meta is included in the JSON tool output, after filtering
protocol-reserved keys per the MCP spec's key-name rules: a prefix is
reserved when a modelcontextprotocol or mcp label is followed by at
least one more label (modelcontextprotocol.io/..., tools.mcp.com/...).
Vendor namespaces with a trailing reserved word (com.example.mcp/...)
and unprefixed keys pass through. Non-serializable metadata drops the
extras rather than failing the call.
2026-08-16 22:08:18 -07:00

218 lines
8.2 KiB
Python

"""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, meta=None):
self.content = content
self.isError = is_error
self.structuredContent = structuredContent
# Real SDK exposes the wire ``_meta`` field as ``.meta`` (Pydantic alias).
self.meta = meta
def _fake_run_on_mcp_loop(coro_or_factory, timeout=30):
coro = coro_or_factory() if callable(coro_or_factory) else coro_or_factory
"""Run an MCP coroutine directly in a fresh event loop."""
loop = asyncio.new_event_loop()
try:
# `_rpc_lock` must be created inside the loop that awaits it, or asyncio
# raises "attached to a different loop". Build it here and attach it to
# whatever fake server is currently registered under _servers.
async def _install_lock_and_run():
for srv in list(mcp_tool._servers.values()):
if getattr(srv, "_rpc_lock", None) is None:
srv._rpc_lock = asyncio.Lock()
return await coro
return loop.run_until_complete(_install_lock_and_run())
finally:
loop.close()
@pytest.fixture
def _patch_mcp_server():
"""Patch _servers and the MCP event loop so _make_tool_handler can run."""
fake_session = MagicMock()
# `_rpc_lock` is acquired by _make_tool_handler's call path (mcp_tool.py
# ~L2008) to serialize JSON-RPC against the server — build it inside the
# fresh loop that _fake_run_on_mcp_loop spins up, not at fixture import.
fake_server = SimpleNamespace(session=fake_session, _rpc_lock=None)
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_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
class TestMetaPassthrough:
"""Server ``_meta`` is surfaced, minus protocol-reserved keys.
Ported from MoonshotAI/kimi-code#2596/#2600.
"""
def test_vendor_meta_passes_through(self, _patch_mcp_server):
session = _patch_mcp_server
session.call_tool = AsyncMock(
return_value=_FakeCallToolResult(
content=[_FakeContentBlock("done")],
meta={"com.example/handoff": {"url": "https://x"}},
)
)
handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0)
data = json.loads(handler({}))
assert data["result"] == "done"
assert data["_meta"] == {"com.example/handoff": {"url": "https://x"}}
def test_reserved_meta_keys_dropped(self, _patch_mcp_server):
session = _patch_mcp_server
session.call_tool = AsyncMock(
return_value=_FakeCallToolResult(
content=[_FakeContentBlock("done")],
meta={
"modelcontextprotocol.io/progress": 1,
"tools.mcp.com/trace": "x",
"com.example.mcp/vendor": "keep", # trailing mcp label = vendor ns
"unprefixed": "keep",
},
)
)
handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0)
data = json.loads(handler({}))
assert data["_meta"] == {
"com.example.mcp/vendor": "keep",
"unprefixed": "keep",
}
def test_all_reserved_meta_omits_field(self, _patch_mcp_server):
session = _patch_mcp_server
session.call_tool = AsyncMock(
return_value=_FakeCallToolResult(
content=[_FakeContentBlock("done")],
meta={"mcp.io/internal": True},
)
)
handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0)
data = json.loads(handler({}))
assert data == {"result": "done"}
def test_meta_with_structured_content(self, _patch_mcp_server):
session = _patch_mcp_server
session.call_tool = AsyncMock(
return_value=_FakeCallToolResult(
content=[_FakeContentBlock("txt")],
structuredContent={"ok": True},
meta={"com.example/k": "v"},
)
)
handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0)
data = json.loads(handler({}))
assert data == {
"result": "txt",
"structuredContent": {"ok": True},
"_meta": {"com.example/k": "v"},
}
def test_non_serializable_meta_dropped(self, _patch_mcp_server):
session = _patch_mcp_server
session.call_tool = AsyncMock(
return_value=_FakeCallToolResult(
content=[_FakeContentBlock("done")],
meta={"com.example/obj": object()},
)
)
handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0)
data = json.loads(handler({}))
assert data == {"result": "done"}
def test_non_dict_meta_ignored(self, _patch_mcp_server):
session = _patch_mcp_server
session.call_tool = AsyncMock(
return_value=_FakeCallToolResult(
content=[_FakeContentBlock("done")],
meta="not-a-dict",
)
)
handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0)
data = json.loads(handler({}))
assert data == {"result": "done"}
class TestReservedMetaKeyPredicate:
def test_reserved_prefixes(self):
assert mcp_tool._is_reserved_mcp_meta_key("modelcontextprotocol.io/x")
assert mcp_tool._is_reserved_mcp_meta_key("mcp.dev/x")
assert mcp_tool._is_reserved_mcp_meta_key("tools.mcp.com/x")
def test_vendor_and_unprefixed_not_reserved(self):
assert not mcp_tool._is_reserved_mcp_meta_key("com.example.mcp/x") # trailing label
assert not mcp_tool._is_reserved_mcp_meta_key("com.example/x")
assert not mcp_tool._is_reserved_mcp_meta_key("plain-key")
assert not mcp_tool._is_reserved_mcp_meta_key("/leading-slash")