hermes-agent/tests/tools/test_mcp_resource_content.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

240 lines
8.9 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_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_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()
mcp_tool._reset_server_error("test-server")
try:
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)
finally:
mcp_tool._reset_server_error("test-server")
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"