{"repo":"NousResearch/hermes-agent","public_id":"3892","display_label":"MCP tool result content handling and provider fixes","summary":"These issues and PRs address multiple related problems: MCP tool results silently dropping EmbeddedResource and image content blocks, empty or unhelpful MCP error messages, and provider-specific bugs in Bedrock Converse and Gemini around handling image and empty tool results. A BlueBubbles iMessage platform adapter is also included.","subsystem":"agent","counts":{"members":28,"open_issues":3,"open_issues_distinct":3,"open_prs":3,"closed_issues":7,"merged_prs":6,"attempt_prs":12},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":21292},"resolution":{"state":"partially_resolved","by":"pr-6437,pr-21289"},"freshness":{"state":"verified","last_verified_at":"2026-08-27T15:37:22+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.057142857142857134},"fix_edges":[{"issue_number":10759,"pr_number":21328,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":19417,"pr_number":21292,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":30601,"pr_number":64556,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":55426,"pr_number":96466,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":3892,"title":"mcp_bluebubbles_send_imessage failing with empty error message","url":null,"state":"closed"},{"kind":"issue","number":10759,"title":"[Bug]: MCP tool responses with ImageContent blocks are silently discarded","url":null,"state":"closed"},{"kind":"issue","number":19417,"title":"[Bug]: MCP tool calls fail with `ClosedResourceError` and empty error message","url":null,"state":"closed"},{"kind":"issue","number":21559,"title":"MCP tool responses: image content silently dropped + large text results truncated","url":null,"state":"closed"},{"kind":"issue","number":26972,"title":"[Feature]: Fix for: Hermes Agent Silently Drops `EmbeddedResource` Content Blocks from MCP Tool Results","url":null,"state":"closed"},{"kind":"issue","number":30601,"title":"MCP tool results with EmbeddedResource (resource) blocks are silently dropped","url":null,"state":"closed"},{"kind":"issue","number":55092,"title":"Bedrock Converse: empty tool result produces an empty text block and 400s the request","url":null,"state":"open"},{"kind":"issue","number":55134,"title":"Bedrock Converse: image tool results are flattened to a base64 text dump, hiding them from the model","url":null,"state":"open"},{"kind":"issue","number":55426,"title":"Native Gemini drops images returned by tools: _translate_tool_result_to_gemini coerces tool-result content to text only","url":null,"state":"closed"},{"kind":"issue","number":55434,"title":"Native Gemini silently drops remote (http) image URLs: _extract_multimodal_parts only handles data: URLs","url":null,"state":"open"},{"kind":"pr","number":6437,"title":"feat(gateway): add BlueBubbles iMessage platform adapter","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true},{"kind":"pr","number":10848,"title":"fix(mcp): preserve ImageContent tool results","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c8e3e39185","number":0},{"kind":"commit","value":"c3115644151","number":0},{"kind":"pr","value":"#21328","number":21328},{"kind":"pr","value":"#17915","number":17915}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":13736,"title":"fix(mcp): extract text from EmbeddedResource content blocks in tool results","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#18118","number":18118}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":19425,"title":"fix(mcp): include exception type in error messages when str(exc) is empty","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":20026,"title":"fix: improve MCP error messages for exceptions with empty str()","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f9b4b8af3410e13ba002129e25c3f212568ee031","number":0},{"kind":"pr","value":"#19425","number":19425},{"kind":"pr","value":"#19417","number":19417}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":21289,"title":"fix(mcp): retry stale pipe transport failures as session-expired","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true},{"kind":"pr","number":21292,"title":"fix(mcp): include exception type in error messages when str(exc) is empty (salvage #19425)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_mcp_empty_error_message.py","tools/mcp_tool.py"]},{"kind":"pr","number":21328,"title":"fix(mcp): surface image tool results as MEDIA tags instead of dropping them","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_mcp_image_content.py","tools/mcp_tool.py"],"fix_hunks":[{"file":"tests/tools/test_mcp_image_content.py","hunks":["@@ -0,0 +1,138 @@\n+\"\"\"Regression tests for MCP ImageContent block handling.\n+\n+Background\n+==========\n+MCP tool results may include ``ImageContent`` blocks (screenshots from\n+Playwright / Blockbench / Puppeteer / any server that returns renders).\n+The tool result handler in ``tools/mcp_tool.py`` used to iterate content\n+blocks looking only for ``block.text`` — image blocks were silently dropped\n+and the agent saw an empty result. Distilled from @c3115644151's PR #17915\n+and @gnanirahulnutakki's PR #10848 (both too stale to cherry-pick); this\n+test file locks in #10848's approach of plumbing the bytes through\n+Hermes' existing ``cache_image_from_bytes`` so a ``MEDIA:<path>`` tag\n+goes back to the agent and through to messaging adapters that render\n+images natively.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import base64\n+from types import SimpleNamespace\n+from unittest.mock import patch\n+\n+import pytest\n+\n+\n+def _png_bytes():\n+    \"\"\"Return a minimal valid PNG byte sequence.\n+\n+    Hermes' ``cache_image_from_bytes`` has a format-sniff guard that rejects\n+    non-image payloads — use a real PNG signature so the test exercises the\n+    full pipeline instead of the reject path.\n+    \"\"\"\n+    # 1x1 transparent PNG\n+    return base64.b64decode(\n+        \"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=\"\n+    )\n+\n+\n+class TestMimeExtension:\n+    def test_maps_jpeg_variants_to_jpg(self):\n+        from tools.mcp_tool import _mcp_image_extension_for_mime_type\n+        assert _mcp_image_extension_for_mime_type(\"image/jpeg\") == \".jpg\"\n+        assert _mcp_image_extension_for_mime_type(\"image/jpg\") == \".jpg\"\n+        assert _mcp_image_extension_for_mime_type(\"IMAGE/JPEG\") == \".jpg\"\n+        assert _mcp_image_extension_for_mime_type(\"image/jpeg; charset=utf-8\") == \".jpg\"\n+\n+    def test_png_falls_through_to_mimetypes(self):\n+        from tools.mcp_tool import _mcp_image_extension_for_mime_type\n+        assert _mcp_image_extension_for_mime_type(\"image/png\") == \".png\"\n+\n+    def test_unknown_defaults_to_png(self):\n+        from tools.mcp_tool import _mcp_image_extension_for_mime_type\n+        assert _mcp_image_extension_for_mime_type(\"\") == \".png\"\n+        assert _mcp_image_extension_for_mime_type(\"image/unheard-of-format\") == \".png\"\n+\n+\n+class TestCacheMcpImageBlock:\n+    def test_returns_media_tag_for_valid_image_block(self, tmp_path, monkeypatch):\n+        \"\"\"A well-formed ImageContent block with valid PNG bytes caches\n+        to the image dir and the helper returns a ``MEDIA:<path>`` tag.\"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        from tools.mcp_tool import _cache_mcp_image_block\n+\n+        block = SimpleNamespace(\n+            data=base64.b64encode(_png_bytes()).decode(\"ascii\"),\n+            mimeType=\"image/png\",\n+        )\n+        tag = _cache_mcp_image_block(block)\n+        assert tag.startswith(\"MEDIA:\"), f\"expected MEDIA: tag, got {tag!r}\"\n+        # The cached file should be in Hermes' image cache dir\n+        from gateway.platforms.base import get_image_cache_dir\n+        cache_dir = str(get_image_cache_dir().resolve())\n+        assert tag.startswith(f\"MEDIA:{cache_dir}\"), (\n+            f\"cached file not under HERMES_HOME image cache dir. \"\n+            f\"tag={tag!r}, cache_dir={cache_dir!r}\"\n+        )\n+        # And it should exist + have the PNG bytes\n+        path = tag[len(\"MEDIA:\"):]\n+        with open(path, \"rb\") as fh:\n+            assert fh.read() == _png_bytes()\n+\n+    def test_returns_empty_when_block_is_not_an_image(self, tmp_path, monkeypatch):\n+        \"\"\"Non-image MIME types shouldn't trigger caching.\"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        from tools.mcp_tool import _cache_mcp_image_block\n+\n+        block = SimpleNamespace(\n+            data=base64.b64encode(b\"some bytes\").decode(\"ascii\"),\n+            mimeType=\"application/pdf\",\n+        )\n+        assert _cache_mcp_image_block(block) == \"\"\n+\n+    def test_returns_empty_when_block_has_no_data(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        from tools.mcp_tool import _cache_mcp_image_block\n+\n+        block = SimpleNamespace(data=None, mimeType=\"image/png\")\n+        assert _cache_mcp_image_block(block) == \"\"\n+\n+    def test_returns_empty_on_malformed_base64(self, tmp_path, monkeypatch):\n+        \"\"\"A server that sends garbage base64 shouldn't crash the handler —\n+        we log and drop the block, letting any text blocks still come through.\"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        from tools.mcp_tool import _cache_mcp_image_block\n+\n+        block = SimpleNamespace(\n+            data=\"!!!not-base64!!!\",\n+            mimeType=\"image/png\",\n+        )\n+        assert _cache_mcp_image_block(block) == \"\"\n+\n+    def test_returns_empty_when_bytes_dont_look_like_an_image(self, tmp_path, monkeypatch):\n+        \"\"\"``cache_image_from_bytes`` has a format sniff; if the claimed\n+        ``image/png`` is actually an HTML error page, the cache raises and\n+        we log + drop rather than propagate.\"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        from tools.mcp_tool import _cache_mcp_image_block\n+\n+        block = SimpleNamespace(\n+            data=base64.b64encode(b\"<html>error</html>\").decode(\"ascii\"),\n+            mimeType=\"image/png\",\n+        )\n+        assert _cache_mcp_image_block(block) == \"\"\n+\n+    def test_handles_jpeg(self, tmp_path, monkeypatch):\n+        \"\"\"JPEG signature should also be accepted.\"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        from tools.mcp_tool import _cache_mcp_image_block\n+\n+        # minimal JPEG SOI marker + filler\n+        jpeg = b\"\\xff\\xd8\\xff\\xe0\" + b\"\\x00\" * 100 + b\"\\xff\\xd9\"\n+        block = SimpleNamespace(\n+            data=base64.b64encode(jpeg).decode(\"ascii\"),\n+            mimeType=\"image/jpeg\",\n+        )\n+        tag = _cache_mcp_image_block(block)\n+        assert tag.startswith(\"MEDIA:\")\n+        assert tag.endswith(\".jpg\"), f\"expected .jpg extension, got {tag!r}\""]},{"file":"tools/mcp_tool.py","hunks":["@@ -426,6 +426,64 @@ def _resolve_stdio_command(command: str, env: dict) -> tuple[str, dict]:\n     return resolved_command, resolved_env\n \n \n+# ---------------------------------------------------------------------------\n+# MCP ImageContent block → Hermes MEDIA tag\n+# ---------------------------------------------------------------------------\n+\n+\n+def _mcp_image_extension_for_mime_type(mime_type: str) -> str:\n+    \"\"\"Return a reasonable file extension for an MCP image MIME type.\"\"\"\n+    import mimetypes\n+    normalized = (mime_type or \"\").split(\";\", 1)[0].strip().lower()\n+    if normalized in {\"image/jpeg\", \"image/jpg\"}:\n+        return \".jpg\"\n+    return mimetypes.guess_extension(normalized) or \".png\"\n+\n+\n+def _cache_mcp_image_block(block) -> str:\n+    \"\"\"Cache an MCP ``ImageContent`` block to the shared image cache and\n+    return a ``MEDIA:<path>`` tag that Hermes gateways know how to render.\n+\n+    Returns an empty string when *block* is not an image, when the base64\n+    payload is malformed, or when the cache helper rejects the bytes (e.g.\n+    non-image MIME masquerading as an image). Errors are logged, not raised:\n+    a single bad block shouldn't kill the tool result, and the caller will\n+    fall through to any text blocks that did parse.\n+    \"\"\"\n+    import base64\n+\n+    data = getattr(block, \"data\", None)\n+    mime_type = getattr(block, \"mimeType\", None)\n+    normalized_mime = str(mime_type or \"\").split(\";\", 1)[0].strip().lower()\n+    if data is None or not normalized_mime.startswith(\"image/\"):\n+        return \"\"\n+\n+    try:\n+        raw_bytes = base64.b64decode(data)\n+    except (TypeError, ValueError) as exc:\n+        logger.warning(\"MCP image block decode failed (%s): %s\", normalized_mime, exc)\n+        return \"\"\n+\n+    try:\n+        from gateway.platforms.base import cache_image_from_bytes\n+\n+        image_path = cache_image_from_bytes(\n+            raw_bytes,\n+            ext=_mcp_image_extension_for_mime_type(normalized_mime),\n+        )\n+    except ImportError:\n+        # gateway.platforms.base not importable in this process (e.g. cron\n+        # without gateway deps). Fall back to silently dropping — callers\n+        # get any text blocks that did parse.\n+        logger.debug(\"MCP image caching skipped — gateway.platforms.base unavailable\")\n+        return \"\"\n+    except Exception as exc:\n+        logger.warning(\"MCP image block cache failed: %s\", exc)\n+        return \"\"\n+\n+    return f\"MEDIA:{image_path}\"\n+\n+\n def _format_connect_error(exc: BaseException) -> str:\n     \"\"\"Render nested MCP connection errors into an actionable short message.\"\"\"\n ","@@ -2146,11 +2204,25 @@ async def _call():\n                     )\n                 }, ensure_ascii=False)\n \n-            # Collect text from content blocks\n+            # Collect text from content blocks. MCP tool results can also\n+            # include ImageContent blocks (screenshot / Blockbench / Playwright\n+            # etc.); cache those via the gateway's image-cache helper so they\n+            # flow through Hermes' MEDIA: tag convention and out to messaging\n+            # adapters that render images natively. Without this, image blocks\n+            # were silently dropped and the agent got an empty response.\n+            #\n+            # Distilled from #17915 (c3115644151) and #10848 (gnanirahulnutakki),\n+            # both too stale to cherry-pick. #10848's approach (integrate with\n+            # Hermes' MEDIA tag + cache_image_from_bytes) was the cleaner of\n+            # the two — plugs into existing infrastructure.\n             parts: List[str] = []\n             for block in (result.content or []):\n-                if hasattr(block, \"text\"):\n+                if hasattr(block, \"text\") and block.text:\n                     parts.append(block.text)\n+                    continue\n+                image_tag = _cache_mcp_image_block(block)\n+                if image_tag:\n+                    parts.append(image_tag)\n             text_result = \"\\n\".join(parts) if parts else \"\"\n \n             # Combine content + structuredContent when both are present.\n"]}]},{"kind":"pr","number":31356,"title":"fix(mcp): preserve embedded resource text in tool results (#30601)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64556","number":64556}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":32352,"title":"fix(agent): embed images in Gemini 3.x functionResponse.parts for multimodal tool results","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#96466","number":96466},{"kind":"pr","value":"#55474","number":55474}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":50999,"title":"fix(mcp): extract EmbeddedResource content blocks from tool results","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64556","number":64556},{"kind":"pr","value":"#64061","number":64061}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":55093,"title":"Guard empty Bedrock Converse tool results against empty text blocks","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#66167","number":66167}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":55135,"title":"Convert image tool results to Converse image blocks for Bedrock","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#117729","number":117729},{"kind":"pr","value":"#117729","number":117729}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":55460,"title":"fix(gemini): fetch remote http(s) image URLs instead of silently dropping","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":55474,"title":"fix(gemini): preserve image parts from tool results in functionResponse","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#32352","number":32352},{"kind":"pr","value":"#96466","number":96466},{"kind":"pr","value":"#32352","number":32352}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":64556,"title":"fix(mcp): materialize ResourceLink/EmbeddedResource/Audio blocks instead of dropping them (salvage #64061)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_mcp_resource_content.py","tools/mcp_tool.py"],"fix_hunks":[{"file":"tests/tools/test_mcp_resource_content.py","hunks":["@@ -0,0 +1,281 @@\n+\"\"\"Tests for MCP ResourceLink / EmbeddedResource / AudioContent handling.\n+\n+Regression coverage for a customer report (2026-07): non-image binary\n+resources returned through MCP resource blocks were silently dropped from\n+tool results, so a PDF-returning MCP tool appeared to return metadata only.\n+\"\"\"\n+\n+import base64\n+import json\n+from types import SimpleNamespace\n+\n+import pytest\n+\n+\n+PDF_BYTES = b\"%PDF-1.4 fake pdf payload for tests\"\n+\n+\n+def _blob_resource(data: bytes, uri=\"slack://files/F123/report.pdf\", mime=\"application/pdf\"):\n+    return SimpleNamespace(\n+        uri=uri,\n+        mimeType=mime,\n+        blob=base64.b64encode(data).decode(\"ascii\"),\n+        text=None,\n+    )\n+\n+\n+def _embedded(resource):\n+    return SimpleNamespace(type=\"resource\", resource=resource)\n+\n+\n[redacted:email]()\n+def doc_cache(tmp_path, monkeypatch):\n+    \"\"\"Point the document cache at a temp dir.\"\"\"\n+    import gateway.platforms.base as base\n+\n+    monkeypatch.setattr(base, \"DOCUMENT_CACHE_DIR\", tmp_path)\n+    monkeypatch.setenv(\"HERMES_DOCUMENT_CACHE_DIR\", str(tmp_path))\n+    # _resolve_cache_dir consults the module constant; patching the constant\n+    # is sufficient because the import-default comparison detects the change.\n+    return tmp_path\n+\n+\n+class TestRenderResourceBlock:\n+    def test_embedded_pdf_blob_is_materialized(self, doc_cache):\n+        from tools.mcp_tool import _render_mcp_resource_block\n+\n+        out = _render_mcp_resource_block(_embedded(_blob_resource(PDF_BYTES)), \"slack\")\n+        assert \"saved to\" in out\n+        assert \"application/pdf\" in out\n+        # Extract path and verify bytes round-trip\n+        path = out.split(\"saved to \", 1)[1].split(\" (\", 1)[0]\n+        with open(path, \"rb\") as fh:\n+            assert fh.read() == PDF_BYTES\n+        assert \"report.pdf\" in path\n+\n+    def test_embedded_text_resource_is_inlined(self):\n+        from tools.mcp_tool import _render_mcp_resource_block\n+\n+        res = SimpleNamespace(uri=\"mem://notes\", mimeType=\"text/plain\", text=\"hello world\", blob=None)\n+        assert _render_mcp_resource_block(_embedded(res), \"srv\") == \"hello world\"\n+\n+    def test_resource_link_preserves_uri_and_points_at_reader(self):\n+        from tools.mcp_tool import _render_mcp_resource_block\n+\n+        link = SimpleNamespace(\n+            type=\"resource_link\",\n+            uri=\"slack://files/F123\",\n+            name=\"report.pdf\",\n+            mimeType=\"application/pdf\",\n+        )\n+        out = _render_mcp_resource_block(link, \"slack\")\n+        assert \"slack://files/F123\" in out\n+        # Must be the real wire name (mcp__<server>__read_resource), not a\n+        # made-up \"<server>_read_resource\" the agent can't actually call.\n+        assert \"mcp__slack__read_resource\" in out\n+        assert \"report.pdf\" in out\n+\n+    def test_oversized_blob_fails_explicitly_without_writing(self, doc_cache, monkeypatch):\n+        import tools.mcp_tool as m\n+\n+        monkeypatch.setattr(m, \"_MCP_RESOURCE_MAX_BYTES\", 8)\n+        out = m._render_mcp_resource_block(_embedded(_blob_resource(PDF_BYTES)), \"srv\")\n+        assert \"too large\" in out\n+        assert not list(doc_cache.glob(\"doc_*\"))\n+\n+    def test_malformed_base64_fails_explicitly(self):\n+        from tools.mcp_tool import _render_mcp_resource_block\n+\n+        res = SimpleNamespace(uri=\"x://y\", mimeType=\"application/pdf\", blob=\"!!!not-base64!!!\", text=None)\n+        out = _render_mcp_resource_block(_embedded(res), \"srv\")\n+        assert \"could not be decoded\" in out\n+\n+    def test_non_resource_block_returns_empty(self):\n+        from tools.mcp_tool import _render_mcp_resource_block\n+\n+        assert _render_mcp_resource_block(SimpleNamespace(type=\"text\", text=\"hi\"), \"srv\") == \"\"\n+\n+    def test_path_traversal_uri_is_neutralized(self, doc_cache):\n+        from tools.mcp_tool import _render_mcp_resource_block\n+\n+        res = _blob_resource(PDF_BYTES, uri=\"evil://host/../../etc/passwd\")\n+        out = _render_mcp_resource_block(_embedded(res), \"srv\")\n+        assert \"saved to\" in out\n+        path = out.split(\"saved to \", 1)[1].split(\" (\", 1)[0]\n+        assert str(doc_cache) in path\n+        assert \"/etc/passwd\" not in path\n+\n+\n+class TestResourceFilename:\n+    def test_uri_last_segment_used(self):\n+        from tools.mcp_tool import _mcp_resource_filename\n+\n+        assert _mcp_resource_filename(\"slack://f/ABC/quarterly.pdf\", \"application/pdf\") == \"quarterly.pdf\"\n+\n+    def test_fallback_to_mime_extension(self):\n+        from tools.mcp_tool import _mcp_resource_filename\n+\n+        name = _mcp_resource_filename(\"\", \"application/pdf\")\n+        assert name.endswith(\".pdf\")\n+\n+    def test_dotdot_rejected(self):\n+        from tools.mcp_tool import _mcp_resource_filename\n+\n+        assert _mcp_resource_filename(\"x://y/..\", \"application/pdf\") != \"..\"\n+\n+    def test_control_chars_stripped(self):\n+        from tools.mcp_tool import _mcp_resource_filename\n+\n+        name = _mcp_resource_filename(\"x://h/report.pdf%0Ainjected%1b[31m\", \"application/pdf\")\n+        assert \"\\n\" not in name and \"\\x1b\" not in name\n+\n+    def test_long_filename_capped_preserving_extension(self):\n+        from tools.mcp_tool import _mcp_resource_filename\n+\n+        name = _mcp_resource_filename(\"x://h/\" + \"a\" * 500 + \".pdf\", \"application/pdf\")\n+        assert len(name) <= 150\n+        assert name.endswith(\".pdf\")\n+\n+\n+class TestPreDecodeSizeCap:\n+    def test_oversized_b64_rejected_before_decode(self, monkeypatch):\n+        import tools.mcp_tool as m\n+\n+        monkeypatch.setattr(m, \"_MCP_RESOURCE_MAX_B64_CHARS\", 16)\n+        res = SimpleNamespace(\n+            uri=\"x://y/big.pdf\", mimeType=\"application/pdf\",\n+            blob=\"A\" * 100, text=None,\n+        )\n+        called = []\n+        monkeypatch.setattr(base64, \"b64decode\", lambda *a, **k: called.append(1))\n+        out = m._render_mcp_resource_block(_embedded(res), \"srv\")\n+        assert \"too large\" in out\n+        assert not called\n+\n+\n+class TestAudioBlock:\n+    def test_non_audio_returns_empty(self):\n+        from tools.mcp_tool import _cache_mcp_audio_block\n+\n+        block = SimpleNamespace(data=base64.b64encode(b\"x\").decode(), mimeType=\"application/pdf\")\n+        assert _cache_mcp_audio_block(block) == \"\"\n+\n+    def test_audio_block_cached_as_media(self, tmp_path, monkeypatch):\n+        import gateway.platforms.base as base\n+        from tools.mcp_tool import _cache_mcp_audio_block\n+\n+        monkeypatch.setattr(base, \"AUDIO_CACHE_DIR\", tmp_path)\n+        block = SimpleNamespace(\n+            data=base64.b64encode(b\"RIFFfakewav\").decode(),\n+            mimeType=\"audio/wav\",\n+        )\n+        out = _cache_mcp_audio_block(block)\n+        assert out.startswith(\"MEDIA:\")\n+\n+\n+class TestToolResultLoopOrdering:\n+    def test_mixed_blocks_preserve_order(self, doc_cache):\n+        \"\"\"Simulate the tool-result block loop with text + pdf resource.\"\"\"\n+        from tools.mcp_tool import (\n+            _cache_mcp_image_block,\n+            _cache_mcp_audio_block,\n+            _render_mcp_resource_block,\n+        )\n+\n+        blocks = [\n+            SimpleNamespace(type=\"text\", text=\"File ID: F123\\nMIME Type: application/pdf\"),\n+            _embedded(_blob_resource(PDF_BYTES)),\n+        ]\n+        parts = []\n+        for block in blocks:\n+            if getattr(block, \"text\", None):\n+                parts.append(block.text)\n+                continue\n+            tag = _cache_mcp_image_block(block) or _cache_mcp_audio_block(block)\n+            if tag:\n+                parts.append(tag)\n+                continue\n+            rendered = _render_mcp_resource_block(block, \"slack\")\n+            if rendered:\n+                parts.append(rendered)\n+        assert len(parts) == 2\n+        assert parts[0].startswith(\"File ID\")\n+        assert \"saved to\" in parts[1]\n+\n+    def test_existing_image_behavior_unchanged(self):\n+        from tools.mcp_tool import _cache_mcp_image_block\n+\n+        block = SimpleNamespace(\n+            data=base64.b64encode(b\"some bytes\").decode(\"ascii\"),\n+            mimeType=\"application/pdf\",\n+        )\n+        assert _cache_mcp_image_block(block) == \"\"\n+\n+\n+class TestErrorPathResourceText:\n+    \"\"\"isError payloads must surface EmbeddedResource text, not drop it.\"\"\"\n+\n+    @pytest.fixture()\n+    def _handler(self, monkeypatch):\n+        import asyncio\n+        from unittest.mock import AsyncMock, MagicMock, patch as mock_patch\n+\n+        from tools import mcp_tool\n+\n+        fake_session = MagicMock()\n+        fake_server = SimpleNamespace(session=fake_session, _rpc_lock=None)\n+\n+        def _fake_run_on_mcp_loop(coro_or_factory, timeout=30):\n+            coro = coro_or_factory() if callable(coro_or_factory) else coro_or_factory\n+            loop = asyncio.new_event_loop()\n+            try:\n+                async def _install_lock_and_run():\n+                    for srv in list(mcp_tool._servers.values()):\n+                        if getattr(srv, \"_rpc_lock\", None) is None:\n+                            srv._rpc_lock = asyncio.Lock()\n+                    return await coro\n+\n+                return loop.run_until_complete(_install_lock_and_run())\n+            finally:\n+                loop.close()\n+\n+        with mock_patch.dict(mcp_tool._servers, {\"test-server\": fake_server}), \\\n+             mock_patch(\"tools.mcp_tool._run_on_mcp_loop\", side_effect=_fake_run_on_mcp_loop):\n+            fake_session.call_tool = AsyncMock()\n+            yield fake_session, mcp_tool._make_tool_handler(\"test-server\", \"my-tool\", 30.0)\n+\n+    def test_error_embedded_resource_text_surfaced(self, _handler):\n+        from unittest.mock import AsyncMock\n+\n+        session, handler = _handler\n+        res = SimpleNamespace(uri=\"mem://err\", mimeType=\"text/plain\",\n+                              text=\"quota exceeded for workspace W1\", blob=None)\n+        session.call_tool = AsyncMock(return_value=SimpleNamespace(\n+            content=[_embedded(res)], isError=True, structuredContent=None,\n+        ))\n+        data = json.loads(handler({}))\n+        assert \"quota exceeded for workspace W1\" in data[\"error\"]\n+\n+    def test_error_mixed_text_and_resource(self, _handler):\n+        from unittest.mock import AsyncMock\n+\n+        session, handler = _handler\n+        res = SimpleNamespace(uri=\"mem://err\", mimeType=\"text/plain\",\n+                              text=\" — details in resource\", blob=None)\n+        session.call_tool = AsyncMock(return_value=SimpleNamespace(\n+            content=[SimpleNamespace(type=\"text\", text=\"tool failed\"), _embedded(res)],\n+            isError=True, structuredContent=None,\n+        ))\n+        data = json.loads(handler({}))\n+        assert \"tool failed\" in data[\"error\"]\n+        assert \"details in resource\" in data[\"error\"]\n+\n+    def test_error_with_no_text_blocks_falls_back(self, _handler):\n+        from unittest.mock import AsyncMock\n+\n+        session, handler = _handler\n+        session.call_tool = AsyncMock(return_value=SimpleNamespace(\n+            content=[], isError=True, structuredContent=None,\n+        ))\n+        data = json.loads(handler({}))\n+        assert data[\"error\"] == \"MCP tool returned an error\""]},{"file":"tools/mcp_tool.py","hunks":["@@ -102,6 +102,7 @@\n import sys\n import threading\n import time\n+from types import SimpleNamespace\n from typing import Callable\n from datetime import datetime\n from typing import Any, Coroutine, Dict, List, Optional","@@ -714,6 +715,169 @@ def _cache_mcp_image_block(block) -> str:\n     return f\"MEDIA:{image_path}\"\n \n \n+# ---------------------------------------------------------------------------\n+# MCP resource blocks (ResourceLink / EmbeddedResource / AudioContent)\n+# ---------------------------------------------------------------------------\n+\n+# Hard cap on decoded resource bytes materialized from an MCP tool result.\n+# Prevents a misbehaving server from filling the cache disk via one block.\n+_MCP_RESOURCE_MAX_BYTES = 50 * 1024 * 1024\n+\n+# Base64 expands raw bytes by ~4/3; reject oversized payloads before decoding\n+# so a multi-GB blob string is never transiently doubled in memory.\n+_MCP_RESOURCE_MAX_B64_CHARS = _MCP_RESOURCE_MAX_BYTES * 4 // 3 + 4\n+\n+\n+def _mcp_resource_filename(uri: str, mime_type: str) -> str:\n+    \"\"\"Derive a safe display filename for an MCP resource.\n+\n+    Only the last path segment of the URI is considered, and only as a\n+    *name hint* — `cache_document_from_bytes` re-sanitizes and prefixes it,\n+    so remote path components can't influence the cache location.\n+    \"\"\"\n+    import mimetypes\n+    import re as _re\n+    from pathlib import Path\n+    from urllib.parse import urlparse, unquote\n+\n+    name = \"\"\n+    if uri:\n+        try:\n+            name = Path(unquote(urlparse(str(uri)).path or \"\")).name\n+        except (ValueError, TypeError):\n+            name = \"\"\n+    # Strip control characters (newlines/ANSI escapes from hostile URIs would\n+    # otherwise land in the filename and the transcript marker) and cap the\n+    # length, preserving the extension.\n+    name = _re.sub(r\"[\\x00-\\x1f\\x7f]\", \"\", name).strip()\n+    if len(name) > 150:\n+        stem, dot, ext = name.rpartition(\".\")\n+        if dot and 0 < len(ext) <= 12:\n+            name = stem[: 150 - len(ext) - 1] + \".\" + ext\n+        else:\n+            name = name[:150]\n+    if not name or name in {\".\", \"..\"}:\n+        normalized = (mime_type or \"\").split(\";\", 1)[0].strip().lower()\n+        ext = mimetypes.guess_extension(normalized) or \".bin\"\n+        name = f\"resource{ext}\"\n+    return name\n+\n+\n+def _cache_mcp_audio_block(block) -> str:\n+    \"\"\"Cache an MCP ``AudioContent`` block and return a ``MEDIA:`` tag.\n+\n+    Returns an empty string when *block* is not audio or on any failure —\n+    same fail-open contract as ``_cache_mcp_image_block``.\n+    \"\"\"\n+    import base64\n+\n+    data = getattr(block, \"data\", None)\n+    mime_type = str(getattr(block, \"mimeType\", None) or \"\").split(\";\", 1)[0].strip().lower()\n+    if data is None or not mime_type.startswith(\"audio/\"):\n+        return \"\"\n+    if len(data) > _MCP_RESOURCE_MAX_B64_CHARS:\n+        return f\"[MCP audio resource too large to cache: ~{len(data) * 3 // 4} bytes]\"\n+    try:\n+        raw_bytes = base64.b64decode(data)\n+    except (TypeError, ValueError) as exc:\n+        logger.warning(\"MCP audio block decode failed (%s): %s\", mime_type, exc)\n+        return \"\"\n+    if len(raw_bytes) > _MCP_RESOURCE_MAX_BYTES:\n+        return f\"[MCP audio resource too large to cache: {len(raw_bytes)} bytes]\"\n+    try:\n+        from gateway.platforms.base import cache_audio_from_bytes\n+        import mimetypes\n+\n+        ext = (\n+            {\"audio/wav\": \".wav\", \"audio/x-wav\": \".wav\", \"audio/wave\": \".wav\"}.get(mime_type)\n+            or mimetypes.guess_extension(mime_type)\n+            or \".ogg\"\n+        )\n+        audio_path = cache_audio_from_bytes(raw_bytes, ext=ext)\n+    except ImportError:\n+        logger.debug(\"MCP audio caching skipped — gateway.platforms.base unavailable\")\n+        return \"\"\n+    except Exception as exc:\n+        logger.warning(\"MCP audio block cache failed: %s\", exc)\n+        return \"\"\n+    return f\"MEDIA:{audio_path}\"\n+\n+\n+def _render_mcp_resource_block(block, server_name: str = \"\") -> str:\n+    \"\"\"Render an MCP ``ResourceLink`` or ``EmbeddedResource`` block as text.\n+\n+    - ``EmbeddedResource`` with text contents → the text itself.\n+    - ``EmbeddedResource`` with blob contents → bytes are decoded (size-capped)\n+      and materialized into the Hermes document cache; returns a marker with\n+      the local path so file/terminal tools can consume it.\n+    - ``ResourceLink`` → the URI plus a pointer at the server's read_resource\n+      tool. No network fetch happens here; the link is only readable through\n+      the originating MCP session.\n+\n+    Returns an empty string for non-resource blocks. Failures are logged and\n+    reported inline rather than silently dropping the block.\n+    \"\"\"\n+    block_type = getattr(block, \"type\", \"\")\n+\n+    if block_type == \"resource_link\" or (\n+        hasattr(block, \"uri\") and not hasattr(block, \"resource\") and block_type != \"text\"\n+    ):\n+        uri = getattr(block, \"uri\", None)\n+        if not uri:\n+            return \"\"\n+        name = getattr(block, \"name\", \"\") or \"\"\n+        mime = getattr(block, \"mimeType\", \"\") or \"\"\n+        details = f\"uri={uri}\"\n+        if name:\n+            details += f\", name={name}\"\n+        if mime:\n+            details += f\", mimeType={mime}\"\n+        reader = (\n+            mcp_prefixed_tool_name(server_name, \"read_resource\")\n+            if server_name\n+            else \"the MCP server's read_resource tool\"\n+        )\n+        return f\"[MCP resource link: {details} — fetch it with {reader}]\"\n+\n+    resource = getattr(block, \"resource\", None)\n+    if resource is None:\n+        return \"\"\n+\n+    text = getattr(resource, \"text\", None)\n+    if text is not None:\n+        return str(text)\n+\n+    blob = getattr(resource, \"blob\", None)\n+    if blob is None:\n+        return \"\"\n+\n+    import base64\n+\n+    uri = str(getattr(resource, \"uri\", \"\") or \"\")\n+    mime = str(getattr(resource, \"mimeType\", \"\") or \"\")\n+    if len(blob) > _MCP_RESOURCE_MAX_B64_CHARS:\n+        return f\"[MCP embedded resource too large to cache: ~{len(blob) * 3 // 4} bytes, uri={uri}]\"\n+    try:\n+        raw_bytes = base64.b64decode(blob)\n+    except (TypeError, ValueError) as exc:\n+        logger.warning(\"MCP embedded resource decode failed (%s): %s\", mime or uri, exc)\n+        return f\"[MCP embedded resource could not be decoded: {mime or uri}]\"\n+    if len(raw_bytes) > _MCP_RESOURCE_MAX_BYTES:\n+        return f\"[MCP embedded resource too large to cache: {len(raw_bytes)} bytes, uri={uri}]\"\n+    try:\n+        from gateway.platforms.base import cache_document_from_bytes\n+\n+        path = cache_document_from_bytes(raw_bytes, _mcp_resource_filename(uri, mime))\n+    except ImportError:\n+        logger.debug(\"MCP resource caching skipped — gateway.platforms.base unavailable\")\n+        return f\"[MCP embedded resource received ({len(raw_bytes)} bytes, {mime or 'unknown type'}) but document cache unavailable in this process]\"\n+    except Exception as exc:\n+        logger.warning(\"MCP embedded resource cache failed: %s\", exc)\n+        return f\"[MCP embedded resource could not be cached: {mime or uri}]\"\n+    detail = mime or \"unknown type\"\n+    return f\"[MCP resource saved to {path} ({detail}, {len(raw_bytes)} bytes) — read it with read_file or terminal tools]\"\n+\n+\n # ---------------------------------------------------------------------------\n # Remote MCP URL validation\n # ---------------------------------------------------------------------------","@@ -3947,8 +4111,15 @@ async def _call():\n             if result.isError:\n                 error_text = \"\"\n                 for block in (result.content or []):\n-                    if hasattr(block, \"text\"):\n+                    if getattr(block, \"text\", None):\n                         error_text += block.text\n+                        continue\n+                    # EmbeddedResource blocks inside error payloads carry\n+                    # their text under .resource.text — previously dropped,\n+                    # leaving a bare \"MCP tool returned an error\".\n+                    res_text = getattr(getattr(block, \"resource\", None), \"text\", None)\n+                    if res_text:\n+                        error_text += str(res_text)\n                 return json.dumps({\n                     \"error\": _sanitize_error(\n                         error_text or \"MCP tool returned an error\"","@@ -3974,6 +4145,34 @@ async def _call():\n                 image_tag = _cache_mcp_image_block(block)\n                 if image_tag:\n                     parts.append(image_tag)\n+                    continue\n+                audio_tag = _cache_mcp_audio_block(block)\n+                if audio_tag:\n+                    parts.append(audio_tag)\n+                    continue\n+                # ResourceLink / EmbeddedResource blocks (PDFs, archives,\n+                # office docs, ...). Previously these were silently dropped,\n+                # so document-oriented MCP tools appeared to return metadata\n+                # only (enterprise customer report, 2026-07).\n+                resource_text = _render_mcp_resource_block(block, server_name)\n+                if resource_text:\n+                    parts.append(resource_text)\n+                    continue\n+                # Benign empty renders (empty text blocks, empty text\n+                # resources, audio in a process without the gateway cache)\n+                # aren't data loss — log at debug. Warn only for genuinely\n+                # unrecognized block shapes.\n+                block_type = getattr(block, \"type\", None) or type(block).__name__\n+                if block_type in {\"text\", \"resource\", \"audio\", \"image\"}:\n+                    logger.debug(\n+                        \"MCP %s: content block type %r rendered empty\",\n+                        server_name, block_type,\n+                    )\n+                else:\n+                    logger.warning(\n+                        \"MCP %s: dropping unsupported content block type %r\",\n+                        server_name, block_type,\n+                    )\n             text_result = \"\\n\".join(parts) if parts else \"\"\n \n             # Combine content + structuredContent when both are present.","@@ -4124,10 +4323,17 @@ async def _call():\n             parts: List[str] = []\n             contents = result.contents if hasattr(result, \"contents\") else []\n             for block in contents:\n-                if hasattr(block, \"text\"):\n+                if getattr(block, \"text\", None) is not None:\n                     parts.append(block.text)\n-                elif hasattr(block, \"blob\"):\n-                    parts.append(f\"[binary data, {len(block.blob)} bytes]\")\n+                elif getattr(block, \"blob\", None) is not None:\n+                    # Materialize binary resource contents into the document\n+                    # cache instead of discarding them (same contract as\n+                    # EmbeddedResource blocks in tool results).\n+                    rendered = _render_mcp_resource_block(\n+                        SimpleNamespace(type=\"resource\", resource=block),\n+                        server_name,\n+                    )\n+                    parts.append(rendered or f\"[binary data, {len(block.blob)} bytes]\")\n             return json.dumps({\"result\": \"\\n\".join(parts) if parts else \"\"}, ensure_ascii=False)\n \n         def _call_once():\n"]}]},{"kind":"pr","number":96466,"title":"fix(gemini): embed images in Gemini 3.x functionResponse.parts for multimodal tool results","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/gemini_native_adapter.py","tests/agent/test_gemini_native_adapter.py"]},{"kind":"pr","number":117729,"title":"fix(bedrock): tool-result images reach the model as Converse image blocks (#55134, salvage #55135)","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#14524","number":14524},{"kind":"pr","value":"#128064","number":128064},{"kind":"pr","value":"#128064","number":128064},{"kind":"pr","value":"#128064","number":128064}],"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}