Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user