mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(mcp): combine content and structuredContent when both present (#7118)
When an MCP server returns both content (model-oriented text) and structuredContent (machine-oriented JSON), the client now combines them instead of discarding content. The text content becomes the primary result (what the agent reads), and structuredContent is included as supplementary metadata. Previously, structuredContent took full precedence — causing data loss for servers like Desktop Commander that put the actual file text in content and metadata in structuredContent. MCP spec guidance: for conversational/agent UX, prefer content.
This commit is contained in:
parent
9a0dfb5a6d
commit
04baab5422
2 changed files with 32 additions and 4 deletions
|
|
@ -66,8 +66,8 @@ class TestStructuredContentPreservation:
|
||||||
data = json.loads(raw)
|
data = json.loads(raw)
|
||||||
assert data == {"result": "hello"}
|
assert data == {"result": "hello"}
|
||||||
|
|
||||||
def test_structured_content_is_the_result(self, _patch_mcp_server):
|
def test_both_content_and_structured(self, _patch_mcp_server):
|
||||||
"""When structuredContent is present, it becomes the result directly."""
|
"""When both content and structuredContent are present, combine them."""
|
||||||
session = _patch_mcp_server
|
session = _patch_mcp_server
|
||||||
payload = {"value": "secret-123", "revealed": True}
|
payload = {"value": "secret-123", "revealed": True}
|
||||||
session.call_tool = AsyncMock(
|
session.call_tool = AsyncMock(
|
||||||
|
|
@ -79,7 +79,27 @@ class TestStructuredContentPreservation:
|
||||||
handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0)
|
handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0)
|
||||||
raw = handler({})
|
raw = handler({})
|
||||||
data = json.loads(raw)
|
data = json.loads(raw)
|
||||||
assert data["result"] == payload
|
# content is the primary result, structuredContent is supplementary
|
||||||
|
assert data["result"] == "OK"
|
||||||
|
assert data["structuredContent"] == payload
|
||||||
|
|
||||||
|
def test_both_content_and_structured_desktop_commander(self, _patch_mcp_server):
|
||||||
|
"""Real-world case: Desktop Commander returns file text in content,
|
||||||
|
metadata in structuredContent. Agent must see file contents."""
|
||||||
|
session = _patch_mcp_server
|
||||||
|
file_text = "import os\nprint('hello')\n"
|
||||||
|
metadata = {"fileName": "main.py", "filePath": "/tmp/main.py", "fileType": "python"}
|
||||||
|
session.call_tool = AsyncMock(
|
||||||
|
return_value=_FakeCallToolResult(
|
||||||
|
content=[_FakeContentBlock(file_text)],
|
||||||
|
structuredContent=metadata,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0)
|
||||||
|
raw = handler({})
|
||||||
|
data = json.loads(raw)
|
||||||
|
assert data["result"] == file_text
|
||||||
|
assert data["structuredContent"] == metadata
|
||||||
|
|
||||||
def test_structured_content_none_falls_back_to_text(self, _patch_mcp_server):
|
def test_structured_content_none_falls_back_to_text(self, _patch_mcp_server):
|
||||||
"""When structuredContent is explicitly None, fall back to text."""
|
"""When structuredContent is explicitly None, fall back to text."""
|
||||||
|
|
|
||||||
|
|
@ -1255,9 +1255,17 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float):
|
||||||
parts.append(block.text)
|
parts.append(block.text)
|
||||||
text_result = "\n".join(parts) if parts else ""
|
text_result = "\n".join(parts) if parts else ""
|
||||||
|
|
||||||
# Prefer structuredContent (machine-readable JSON) over plain text
|
# Combine content + structuredContent when both are present.
|
||||||
|
# MCP spec: content is model-oriented (text), structuredContent
|
||||||
|
# is machine-oriented (JSON metadata). For an AI agent, content
|
||||||
|
# is the primary payload; structuredContent supplements it.
|
||||||
structured = getattr(result, "structuredContent", None)
|
structured = getattr(result, "structuredContent", None)
|
||||||
if structured is not None:
|
if structured is not None:
|
||||||
|
if text_result:
|
||||||
|
return json.dumps({
|
||||||
|
"result": text_result,
|
||||||
|
"structuredContent": structured,
|
||||||
|
})
|
||||||
return json.dumps({"result": structured})
|
return json.dumps({"result": structured})
|
||||||
return json.dumps({"result": text_result})
|
return json.dumps({"result": text_result})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue