diff --git a/tests/tools/test_mcp_structured_content.py b/tests/tools/test_mcp_structured_content.py index f4cda00f9f01..fd05ad58b14f 100644 --- a/tests/tools/test_mcp_structured_content.py +++ b/tests/tools/test_mcp_structured_content.py @@ -18,6 +18,14 @@ def __init__(self, text: str, block_type: str = "text"): self.type = block_type +class _FakeEmbeddedResourceBlock: + """Minimal EmbeddedResource-like block with text under .resource.text.""" + + def __init__(self, text: str): + self.type = "resource" + self.resource = SimpleNamespace(text=text, mimeType="text/markdown") + + class _FakeCallToolResult: """Minimal CallToolResult stand-in. @@ -78,6 +86,33 @@ def test_text_only_result(self, _patch_mcp_server): data = json.loads(raw) assert data == {"result": "hello"} + def test_embedded_resource_text_result(self, _patch_mcp_server): + """EmbeddedResource.resource.text is forwarded as normal result text.""" + session = _patch_mcp_server + session.call_tool = AsyncMock( + return_value=_FakeCallToolResult( + content=[_FakeEmbeddedResourceBlock("# Title\n")], + ) + ) + handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0) + raw = handler({}) + data = json.loads(raw) + assert data == {"result": "# Title\n"} + + def test_embedded_resource_error_text_result(self, _patch_mcp_server): + """EmbeddedResource.resource.text is preserved on MCP error results.""" + session = _patch_mcp_server + session.call_tool = AsyncMock( + return_value=_FakeCallToolResult( + content=[_FakeEmbeddedResourceBlock("resource failed")], + is_error=True, + ) + ) + handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0) + raw = handler({}) + data = json.loads(raw) + assert data == {"error": "resource failed"} + def test_both_content_and_structured(self, _patch_mcp_server): """When both content and structuredContent are present, combine them.""" session = _patch_mcp_server diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index e50efc05a0c2..802ae0d1246b 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -2287,6 +2287,25 @@ async def _connect_server(name: str, config: dict) -> MCPServerTask: # Handler / check-fn factories # --------------------------------------------------------------------------- +def _extract_mcp_text_block(block) -> str: + """Extract model-visible text from an MCP content block. + + Most MCP tools return TextContent blocks exposing ``.text`` directly. Some + servers, including QMD, return EmbeddedResource blocks whose payload lives + under ``.resource.text``. Treat both shapes as equivalent text sources so + wrapper formatting does not silently drop resource-style responses. + """ + text = getattr(block, "text", None) + if isinstance(text, str) and text: + return text + resource = getattr(block, "resource", None) + if resource is not None: + resource_text = getattr(resource, "text", None) + if isinstance(resource_text, str) and resource_text: + return resource_text + return "" + + def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float): """Return a sync handler that calls an MCP tool via the background loop. @@ -2336,8 +2355,7 @@ async def _call(): if result.isError: error_text = "" for block in (result.content or []): - if hasattr(block, "text"): - error_text += block.text + error_text += _extract_mcp_text_block(block) return json.dumps({ "error": _sanitize_error( error_text or "MCP tool returned an error" @@ -2357,8 +2375,9 @@ async def _call(): # the two — plugs into existing infrastructure. parts: List[str] = [] for block in (result.content or []): - if hasattr(block, "text") and block.text: - parts.append(block.text) + text = _extract_mcp_text_block(block) + if text: + parts.append(text) continue image_tag = _cache_mcp_image_block(block) if image_tag: