mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
Follow-ups on top of #64061's salvage: - ResourceLink markers now point at mcp__<server>__read_resource (the actual registered tool name via mcp_prefixed_tool_name) instead of a nonexistent <server>_read_resource the agent could hallucinate-call. - The isError path now surfaces EmbeddedResource .resource.text blocks instead of dropping them, so error payloads carried in resources no longer collapse to a bare 'MCP tool returned an error'. (Same-class fix flagged in #64061 and independently addressed in #63576 by @alauer.) - 3 new error-path tests + updated ResourceLink wire-name assertion.
281 lines
11 KiB
Python
281 lines
11 KiB
Python
"""Tests for MCP ResourceLink / EmbeddedResource / AudioContent handling.
|
|
|
|
Regression coverage for a customer report (2026-07): non-image binary
|
|
resources returned through MCP resource blocks were silently dropped from
|
|
tool results, so a PDF-returning MCP tool appeared to return metadata only.
|
|
"""
|
|
|
|
import base64
|
|
import json
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
|
|
PDF_BYTES = b"%PDF-1.4 fake pdf payload for tests"
|
|
|
|
|
|
def _blob_resource(data: bytes, uri="slack://files/F123/report.pdf", mime="application/pdf"):
|
|
return SimpleNamespace(
|
|
uri=uri,
|
|
mimeType=mime,
|
|
blob=base64.b64encode(data).decode("ascii"),
|
|
text=None,
|
|
)
|
|
|
|
|
|
def _embedded(resource):
|
|
return SimpleNamespace(type="resource", resource=resource)
|
|
|
|
|
|
@pytest.fixture()
|
|
def doc_cache(tmp_path, monkeypatch):
|
|
"""Point the document cache at a temp dir."""
|
|
import gateway.platforms.base as base
|
|
|
|
monkeypatch.setattr(base, "DOCUMENT_CACHE_DIR", tmp_path)
|
|
monkeypatch.setenv("HERMES_DOCUMENT_CACHE_DIR", str(tmp_path))
|
|
# _resolve_cache_dir consults the module constant; patching the constant
|
|
# is sufficient because the import-default comparison detects the change.
|
|
return tmp_path
|
|
|
|
|
|
class TestRenderResourceBlock:
|
|
def test_embedded_pdf_blob_is_materialized(self, doc_cache):
|
|
from tools.mcp_tool import _render_mcp_resource_block
|
|
|
|
out = _render_mcp_resource_block(_embedded(_blob_resource(PDF_BYTES)), "slack")
|
|
assert "saved to" in out
|
|
assert "application/pdf" in out
|
|
# Extract path and verify bytes round-trip
|
|
path = out.split("saved to ", 1)[1].split(" (", 1)[0]
|
|
with open(path, "rb") as fh:
|
|
assert fh.read() == PDF_BYTES
|
|
assert "report.pdf" in path
|
|
|
|
def test_embedded_text_resource_is_inlined(self):
|
|
from tools.mcp_tool import _render_mcp_resource_block
|
|
|
|
res = SimpleNamespace(uri="mem://notes", mimeType="text/plain", text="hello world", blob=None)
|
|
assert _render_mcp_resource_block(_embedded(res), "srv") == "hello world"
|
|
|
|
def test_resource_link_preserves_uri_and_points_at_reader(self):
|
|
from tools.mcp_tool import _render_mcp_resource_block
|
|
|
|
link = SimpleNamespace(
|
|
type="resource_link",
|
|
uri="slack://files/F123",
|
|
name="report.pdf",
|
|
mimeType="application/pdf",
|
|
)
|
|
out = _render_mcp_resource_block(link, "slack")
|
|
assert "slack://files/F123" in out
|
|
# Must be the real wire name (mcp__<server>__read_resource), not a
|
|
# made-up "<server>_read_resource" the agent can't actually call.
|
|
assert "mcp__slack__read_resource" in out
|
|
assert "report.pdf" in out
|
|
|
|
def test_oversized_blob_fails_explicitly_without_writing(self, doc_cache, monkeypatch):
|
|
import tools.mcp_tool as m
|
|
|
|
monkeypatch.setattr(m, "_MCP_RESOURCE_MAX_BYTES", 8)
|
|
out = m._render_mcp_resource_block(_embedded(_blob_resource(PDF_BYTES)), "srv")
|
|
assert "too large" in out
|
|
assert not list(doc_cache.glob("doc_*"))
|
|
|
|
def test_malformed_base64_fails_explicitly(self):
|
|
from tools.mcp_tool import _render_mcp_resource_block
|
|
|
|
res = SimpleNamespace(uri="x://y", mimeType="application/pdf", blob="!!!not-base64!!!", text=None)
|
|
out = _render_mcp_resource_block(_embedded(res), "srv")
|
|
assert "could not be decoded" in out
|
|
|
|
def test_non_resource_block_returns_empty(self):
|
|
from tools.mcp_tool import _render_mcp_resource_block
|
|
|
|
assert _render_mcp_resource_block(SimpleNamespace(type="text", text="hi"), "srv") == ""
|
|
|
|
def test_path_traversal_uri_is_neutralized(self, doc_cache):
|
|
from tools.mcp_tool import _render_mcp_resource_block
|
|
|
|
res = _blob_resource(PDF_BYTES, uri="evil://host/../../etc/passwd")
|
|
out = _render_mcp_resource_block(_embedded(res), "srv")
|
|
assert "saved to" in out
|
|
path = out.split("saved to ", 1)[1].split(" (", 1)[0]
|
|
assert str(doc_cache) in path
|
|
assert "/etc/passwd" not in path
|
|
|
|
|
|
class TestResourceFilename:
|
|
def test_uri_last_segment_used(self):
|
|
from tools.mcp_tool import _mcp_resource_filename
|
|
|
|
assert _mcp_resource_filename("slack://f/ABC/quarterly.pdf", "application/pdf") == "quarterly.pdf"
|
|
|
|
def test_fallback_to_mime_extension(self):
|
|
from tools.mcp_tool import _mcp_resource_filename
|
|
|
|
name = _mcp_resource_filename("", "application/pdf")
|
|
assert name.endswith(".pdf")
|
|
|
|
def test_dotdot_rejected(self):
|
|
from tools.mcp_tool import _mcp_resource_filename
|
|
|
|
assert _mcp_resource_filename("x://y/..", "application/pdf") != ".."
|
|
|
|
def test_control_chars_stripped(self):
|
|
from tools.mcp_tool import _mcp_resource_filename
|
|
|
|
name = _mcp_resource_filename("x://h/report.pdf%0Ainjected%1b[31m", "application/pdf")
|
|
assert "\n" not in name and "\x1b" not in name
|
|
|
|
def test_long_filename_capped_preserving_extension(self):
|
|
from tools.mcp_tool import _mcp_resource_filename
|
|
|
|
name = _mcp_resource_filename("x://h/" + "a" * 500 + ".pdf", "application/pdf")
|
|
assert len(name) <= 150
|
|
assert name.endswith(".pdf")
|
|
|
|
|
|
class TestPreDecodeSizeCap:
|
|
def test_oversized_b64_rejected_before_decode(self, monkeypatch):
|
|
import tools.mcp_tool as m
|
|
|
|
monkeypatch.setattr(m, "_MCP_RESOURCE_MAX_B64_CHARS", 16)
|
|
res = SimpleNamespace(
|
|
uri="x://y/big.pdf", mimeType="application/pdf",
|
|
blob="A" * 100, text=None,
|
|
)
|
|
called = []
|
|
monkeypatch.setattr(base64, "b64decode", lambda *a, **k: called.append(1))
|
|
out = m._render_mcp_resource_block(_embedded(res), "srv")
|
|
assert "too large" in out
|
|
assert not called
|
|
|
|
|
|
class TestAudioBlock:
|
|
def test_non_audio_returns_empty(self):
|
|
from tools.mcp_tool import _cache_mcp_audio_block
|
|
|
|
block = SimpleNamespace(data=base64.b64encode(b"x").decode(), mimeType="application/pdf")
|
|
assert _cache_mcp_audio_block(block) == ""
|
|
|
|
def test_audio_block_cached_as_media(self, tmp_path, monkeypatch):
|
|
import gateway.platforms.base as base
|
|
from tools.mcp_tool import _cache_mcp_audio_block
|
|
|
|
monkeypatch.setattr(base, "AUDIO_CACHE_DIR", tmp_path)
|
|
block = SimpleNamespace(
|
|
data=base64.b64encode(b"RIFFfakewav").decode(),
|
|
mimeType="audio/wav",
|
|
)
|
|
out = _cache_mcp_audio_block(block)
|
|
assert out.startswith("MEDIA:")
|
|
|
|
|
|
class TestToolResultLoopOrdering:
|
|
def test_mixed_blocks_preserve_order(self, doc_cache):
|
|
"""Simulate the tool-result block loop with text + pdf resource."""
|
|
from tools.mcp_tool import (
|
|
_cache_mcp_image_block,
|
|
_cache_mcp_audio_block,
|
|
_render_mcp_resource_block,
|
|
)
|
|
|
|
blocks = [
|
|
SimpleNamespace(type="text", text="File ID: F123\nMIME Type: application/pdf"),
|
|
_embedded(_blob_resource(PDF_BYTES)),
|
|
]
|
|
parts = []
|
|
for block in blocks:
|
|
if getattr(block, "text", None):
|
|
parts.append(block.text)
|
|
continue
|
|
tag = _cache_mcp_image_block(block) or _cache_mcp_audio_block(block)
|
|
if tag:
|
|
parts.append(tag)
|
|
continue
|
|
rendered = _render_mcp_resource_block(block, "slack")
|
|
if rendered:
|
|
parts.append(rendered)
|
|
assert len(parts) == 2
|
|
assert parts[0].startswith("File ID")
|
|
assert "saved to" in parts[1]
|
|
|
|
def test_existing_image_behavior_unchanged(self):
|
|
from tools.mcp_tool import _cache_mcp_image_block
|
|
|
|
block = SimpleNamespace(
|
|
data=base64.b64encode(b"some bytes").decode("ascii"),
|
|
mimeType="application/pdf",
|
|
)
|
|
assert _cache_mcp_image_block(block) == ""
|
|
|
|
|
|
class TestErrorPathResourceText:
|
|
"""isError payloads must surface EmbeddedResource text, not drop it."""
|
|
|
|
@pytest.fixture()
|
|
def _handler(self, monkeypatch):
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock, patch as mock_patch
|
|
|
|
from tools import mcp_tool
|
|
|
|
fake_session = MagicMock()
|
|
fake_server = SimpleNamespace(session=fake_session, _rpc_lock=None)
|
|
|
|
def _fake_run_on_mcp_loop(coro_or_factory, timeout=30):
|
|
coro = coro_or_factory() if callable(coro_or_factory) else coro_or_factory
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
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()
|
|
|
|
with mock_patch.dict(mcp_tool._servers, {"test-server": fake_server}), \
|
|
mock_patch("tools.mcp_tool._run_on_mcp_loop", side_effect=_fake_run_on_mcp_loop):
|
|
fake_session.call_tool = AsyncMock()
|
|
yield fake_session, mcp_tool._make_tool_handler("test-server", "my-tool", 30.0)
|
|
|
|
def test_error_embedded_resource_text_surfaced(self, _handler):
|
|
from unittest.mock import AsyncMock
|
|
|
|
session, handler = _handler
|
|
res = SimpleNamespace(uri="mem://err", mimeType="text/plain",
|
|
text="quota exceeded for workspace W1", blob=None)
|
|
session.call_tool = AsyncMock(return_value=SimpleNamespace(
|
|
content=[_embedded(res)], isError=True, structuredContent=None,
|
|
))
|
|
data = json.loads(handler({}))
|
|
assert "quota exceeded for workspace W1" in data["error"]
|
|
|
|
def test_error_mixed_text_and_resource(self, _handler):
|
|
from unittest.mock import AsyncMock
|
|
|
|
session, handler = _handler
|
|
res = SimpleNamespace(uri="mem://err", mimeType="text/plain",
|
|
text=" — details in resource", blob=None)
|
|
session.call_tool = AsyncMock(return_value=SimpleNamespace(
|
|
content=[SimpleNamespace(type="text", text="tool failed"), _embedded(res)],
|
|
isError=True, structuredContent=None,
|
|
))
|
|
data = json.loads(handler({}))
|
|
assert "tool failed" in data["error"]
|
|
assert "details in resource" in data["error"]
|
|
|
|
def test_error_with_no_text_blocks_falls_back(self, _handler):
|
|
from unittest.mock import AsyncMock
|
|
|
|
session, handler = _handler
|
|
session.call_tool = AsyncMock(return_value=SimpleNamespace(
|
|
content=[], isError=True, structuredContent=None,
|
|
))
|
|
data = json.loads(handler({}))
|
|
assert data["error"] == "MCP tool returned an error"
|