From a5ba75dfcc9e3018bda1fe39f792e4f75ddecb37 Mon Sep 17 00:00:00 2001 From: Kyler Middleton Date: Fri, 22 Aug 2025 10:15:28 -0500 Subject: [PATCH 01/10] Add embeddedresource support to mcp --- src/strands/tools/mcp/mcp_client.py | 75 ++++++++++++++++++++++ tests/strands/tools/mcp/test_mcp_client.py | 50 +++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/src/strands/tools/mcp/mcp_client.py b/src/strands/tools/mcp/mcp_client.py index 96e80385f..2df3a1628 100644 --- a/src/strands/tools/mcp/mcp_client.py +++ b/src/strands/tools/mcp/mcp_client.py @@ -23,6 +23,7 @@ from mcp.types import GetPromptResult, ListPromptsResult from mcp.types import ImageContent as MCPImageContent from mcp.types import TextContent as MCPTextContent +from mcp.types import EmbeddedResource as MCPEmbeddedResource # <-- minimal add from ...types import PaginatedList from ...types.exceptions import MCPClientInitializationError @@ -452,6 +453,80 @@ def _map_mcp_content_to_tool_result_content( "source": {"bytes": base64.b64decode(content.data)}, } } + # If EmbeddedResource + elif isinstance(content, MCPEmbeddedResource): + self._log_debug_with_thread("mapping MCP embedded resource content") + res = getattr(content, "resource", None) + if res is None: + self._log_debug_with_thread("embedded resource has no 'resource' field - dropping") + return None + + # Support both pydantic model and dict access + def _get(attr: str) -> Any: + if hasattr(res, attr): + return getattr(res, attr) + if isinstance(res, dict): + return res.get(attr) + return None + + text_val = _get("text") + if text_val: + return {"text": text_val} + + blob_val = _get("blob") + mime_type = _get("mimeType") + + if blob_val is not None: + # blob is a base64 string in current mcp schema + raw_bytes: Optional[bytes] + try: + if isinstance(blob_val, (bytes, bytearray)): + raw_bytes = bytes(blob_val) + elif isinstance(blob_val, str): + raw_bytes = base64.b64decode(blob_val) + else: + raw_bytes = None + except Exception: + raw_bytes = None + + if raw_bytes is None: + self._log_debug_with_thread("embedded resource blob could not be decoded - dropping") + return None + + def _is_textual(mt: Optional[str]) -> bool: + if not mt: + return False + if mt.startswith("text/"): + return True + textual = ( + "application/json", + "application/xml", + "application/javascript", + "application/x-yaml", + "application/yaml", + "application/xhtml+xml", + ) + if mt in textual or mt.endswith("+json") or mt.endswith("+xml"): + return True + return False + + if _is_textual(mime_type): + try: + return {"text": raw_bytes.decode("utf-8", errors="replace")} + except Exception: + pass + + if mime_type in MIME_TO_FORMAT: + return { + "image": { + "format": MIME_TO_FORMAT[mime_type], + "source": {"bytes": raw_bytes}, + } + } + + self._log_debug_with_thread("embedded resource blob with non-textual/unknown mimeType - dropping") + return None + # ------------------------------------------------- else: self._log_debug_with_thread("unhandled content type: %s - dropping content", content.__class__.__name__) return None diff --git a/tests/strands/tools/mcp/test_mcp_client.py b/tests/strands/tools/mcp/test_mcp_client.py index 67d8fe558..28a0f4031 100644 --- a/tests/strands/tools/mcp/test_mcp_client.py +++ b/tests/strands/tools/mcp/test_mcp_client.py @@ -1,4 +1,5 @@ import time +import base64 from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -541,3 +542,52 @@ def slow_transport(): assert client._background_thread_session is None assert client._background_thread_event_loop is None assert not client._init_future.done() # New future created + + +def test_call_tool_sync_embedded_nested_text(mock_transport, mock_session): + """EmbeddedResource.resource (uri + text) should map to plain text content.""" + er = { + "type": "resource", # required literal + "resource": { + "uri": "mcp://resource/embedded-text-1", + "text": "inner text", + "mimeType": "text/plain", + }, + } + mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[er]) + + from strands.tools.mcp import MCPClient + with MCPClient(mock_transport["transport_callable"]) as client: + result = client.call_tool_sync(tool_use_id="er-text", name="get_file_contents", arguments={}) + + mock_session.call_tool.assert_called_once_with("get_file_contents", {}, None) + assert result["status"] == "success" + assert len(result["content"]) == 1 + assert result["content"][0]["text"] == "inner text" + + +def test_call_tool_sync_embedded_nested_base64_textual_mime(mock_transport, mock_session): + """EmbeddedResource.resource (uri + blob with textual MIME) should decode to text.""" + import base64 + from mcp.types import CallToolResult as MCPCallToolResult + payload = base64.b64encode(b'{"k":"v"}').decode() + + er = { + "type": "resource", + "resource": { + "uri": "mcp://resource/embedded-blob-1", + # NOTE: blob is a STRING, mimeType is sibling + "blob": payload, + "mimeType": "application/json", + }, + } + mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[er]) + + from strands.tools.mcp import MCPClient + with MCPClient(mock_transport["transport_callable"]) as client: + result = client.call_tool_sync(tool_use_id="er-blob", name="get_file_contents", arguments={}) + + mock_session.call_tool.assert_called_once_with("get_file_contents", {}, None) + assert result["status"] == "success" + assert len(result["content"]) == 1 + assert result["content"][0]["text"] == '{"k":"v"}' \ No newline at end of file From 620b72f6e15cbb36ea459cd9cfdf512dcede73ce Mon Sep 17 00:00:00 2001 From: Kyler Middleton Date: Fri, 22 Aug 2025 10:40:08 -0500 Subject: [PATCH 02/10] Fix all paths exit gracefully --- src/strands/tools/mcp/mcp_client.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/strands/tools/mcp/mcp_client.py b/src/strands/tools/mcp/mcp_client.py index 2df3a1628..bc503a3df 100644 --- a/src/strands/tools/mcp/mcp_client.py +++ b/src/strands/tools/mcp/mcp_client.py @@ -526,7 +526,15 @@ def _is_textual(mt: Optional[str]) -> bool: self._log_debug_with_thread("embedded resource blob with non-textual/unknown mimeType - dropping") return None - # ------------------------------------------------- + + # Handle URI-only resources + uri = _get("uri") + if uri: + return {"text": f"[embedded resource] {uri} ({mime_type or 'unknown mime'})"} + + # Make sure we return in all paths + self._log_debug_with_thread("embedded resource had no usable text/blob/uri; dropping") + return None else: self._log_debug_with_thread("unhandled content type: %s - dropping content", content.__class__.__name__) return None From cad3ff23fc2542a90f9487540cf2669653f736ac Mon Sep 17 00:00:00 2001 From: Kyler Middleton Date: Mon, 25 Aug 2025 13:30:43 -0500 Subject: [PATCH 03/10] added type hint --- src/strands/tools/mcp/mcp_client.py | 12 ++-- tests/strands/tools/mcp/test_mcp_client.py | 80 +++++++++++++++++++++- 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/src/strands/tools/mcp/mcp_client.py b/src/strands/tools/mcp/mcp_client.py index bc503a3df..b8914721d 100644 --- a/src/strands/tools/mcp/mcp_client.py +++ b/src/strands/tools/mcp/mcp_client.py @@ -23,7 +23,7 @@ from mcp.types import GetPromptResult, ListPromptsResult from mcp.types import ImageContent as MCPImageContent from mcp.types import TextContent as MCPTextContent -from mcp.types import EmbeddedResource as MCPEmbeddedResource # <-- minimal add +from mcp.types import EmbeddedResource as MCPEmbeddedResource from ...types import PaginatedList from ...types.exceptions import MCPClientInitializationError @@ -429,7 +429,7 @@ def _background_task(self) -> None: def _map_mcp_content_to_tool_result_content( self, - content: MCPTextContent | MCPImageContent | Any, + content: MCPTextContent | MCPImageContent | MCPEmbeddedResource | Any, ) -> Union[ToolResultContent, None]: """Maps MCP content types to tool result content types. @@ -453,7 +453,6 @@ def _map_mcp_content_to_tool_result_content( "source": {"bytes": base64.b64decode(content.data)}, } } - # If EmbeddedResource elif isinstance(content, MCPEmbeddedResource): self._log_debug_with_thread("mapping MCP embedded resource content") res = getattr(content, "resource", None) @@ -530,7 +529,12 @@ def _is_textual(mt: Optional[str]) -> bool: # Handle URI-only resources uri = _get("uri") if uri: - return {"text": f"[embedded resource] {uri} ({mime_type or 'unknown mime'})"} + return { + "json": { + "uri": uri, + "mime_type": mime_type + } + } # Make sure we return in all paths self._log_debug_with_thread("embedded resource had no usable text/blob/uri; dropping") diff --git a/tests/strands/tools/mcp/test_mcp_client.py b/tests/strands/tools/mcp/test_mcp_client.py index 28a0f4031..864882c1c 100644 --- a/tests/strands/tools/mcp/test_mcp_client.py +++ b/tests/strands/tools/mcp/test_mcp_client.py @@ -590,4 +590,82 @@ def test_call_tool_sync_embedded_nested_base64_textual_mime(mock_transport, mock mock_session.call_tool.assert_called_once_with("get_file_contents", {}, None) assert result["status"] == "success" assert len(result["content"]) == 1 - assert result["content"][0]["text"] == '{"k":"v"}' \ No newline at end of file + assert result["content"][0]["text"] == '{"k":"v"}' + + +def test_call_tool_sync_embedded_image_blob(mock_transport, mock_session): + """EmbeddedResource.resource (blob with image MIME) should map to image content.""" + import base64 + # Create a simple 1x1 PNG image + png_data = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==") + payload = base64.b64encode(png_data).decode() + + er = { + "type": "resource", + "resource": { + "uri": "mcp://resource/embedded-image", + "blob": payload, + "mimeType": "image/png", + }, + } + mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[er]) + + from strands.tools.mcp import MCPClient + with MCPClient(mock_transport["transport_callable"]) as client: + result = client.call_tool_sync(tool_use_id="er-image", name="get_file_contents", arguments={}) + + mock_session.call_tool.assert_called_once_with("get_file_contents", {}, None) + assert result["status"] == "success" + assert len(result["content"]) == 1 + assert "image" in result["content"][0] + assert result["content"][0]["image"]["format"] == "png" + assert "bytes" in result["content"][0]["image"]["source"] + + +def test_call_tool_sync_embedded_non_textual_blob_dropped(mock_transport, mock_session): + """EmbeddedResource.resource (blob with non-textual/unknown MIME) should be dropped.""" + payload = base64.b64encode(b'\x00\x01\x02\x03').decode() + + er = { + "type": "resource", + "resource": { + "uri": "mcp://resource/embedded-binary", + "blob": payload, + "mimeType": "application/octet-stream", + }, + } + mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[er]) + + from strands.tools.mcp import MCPClient + with MCPClient(mock_transport["transport_callable"]) as client: + result = client.call_tool_sync(tool_use_id="er-binary", name="get_file_contents", arguments={}) + + mock_session.call_tool.assert_called_once_with("get_file_contents", {}, None) + assert result["status"] == "success" + assert len(result["content"]) == 0 # Content should be dropped + + +def test_call_tool_sync_embedded_multiple_textual_mimes(mock_transport, mock_session): + """EmbeddedResource with different textual MIME types should decode to text.""" + import base64 + + # Test YAML content + yaml_content = base64.b64encode(b'key: value\nlist:\n - item1\n - item2').decode() + er = { + "type": "resource", + "resource": { + "uri": "mcp://resource/embedded-yaml", + "blob": yaml_content, + "mimeType": "application/yaml", + }, + } + mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[er]) + + from strands.tools.mcp import MCPClient + with MCPClient(mock_transport["transport_callable"]) as client: + result = client.call_tool_sync(tool_use_id="er-yaml", name="get_file_contents", arguments={}) + + mock_session.call_tool.assert_called_once_with("get_file_contents", {}, None) + assert result["status"] == "success" + assert len(result["content"]) == 1 + assert "key: value" in result["content"][0]["text"] \ No newline at end of file From 6af5810f8c9fea4ddfc2dd1910ef135fe55b2687 Mon Sep 17 00:00:00 2001 From: Kyler Middleton Date: Mon, 25 Aug 2025 13:37:32 -0500 Subject: [PATCH 04/10] Added integration tests --- tests_integ/mcp/test_mcp_client.py | 123 +++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/tests_integ/mcp/test_mcp_client.py b/tests_integ/mcp/test_mcp_client.py index 5e1dc958b..5808e9104 100644 --- a/tests_integ/mcp/test_mcp_client.py +++ b/tests_integ/mcp/test_mcp_client.py @@ -267,6 +267,129 @@ def transport_callback() -> MCPTransport: assert "Hello, Charlie!" in prompt_text +def test_mcp_client_embedded_resources(): + """Test that MCP client properly handles EmbeddedResource content types.""" + embedded_resource_mcp_client = MCPClient( + lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/embedded_resource_server.py"])) + ) + + with embedded_resource_mcp_client: + # Test text embedded resource + text_result = embedded_resource_mcp_client.call_tool_sync( + tool_use_id="test-embedded-text", + name="get_text_file_content", + arguments={}, + ) + assert text_result["status"] == "success" + assert len(text_result["content"]) == 1 + assert text_result["content"][0]["text"] == "Hello, this is embedded text content!" + + # Test JSON embedded resource (blob with textual MIME type) + json_result = embedded_resource_mcp_client.call_tool_sync( + tool_use_id="test-embedded-json", + name="get_json_file_content", + arguments={}, + ) + assert json_result["status"] == "success" + assert len(json_result["content"]) == 1 + json_content = json_result["content"][0]["text"] + assert "Hello from embedded JSON!" in json_content + assert "test" in json_content + + # Test YAML embedded resource (blob with textual MIME type) + yaml_result = embedded_resource_mcp_client.call_tool_sync( + tool_use_id="test-embedded-yaml", + name="get_yaml_file_content", + arguments={}, + ) + assert yaml_result["status"] == "success" + assert len(yaml_result["content"]) == 1 + yaml_content = yaml_result["content"][0]["text"] + assert "Hello from embedded YAML!" in yaml_content + assert "item1" in yaml_content + + # Test image embedded resource + image_result = embedded_resource_mcp_client.call_tool_sync( + tool_use_id="test-embedded-image", + name="get_image_content", + arguments={}, + ) + assert image_result["status"] == "success" + assert len(image_result["content"]) == 1 + assert "image" in image_result["content"][0] + assert image_result["content"][0]["image"]["format"] == "png" + assert "bytes" in image_result["content"][0]["image"]["source"] + + # Test binary embedded resource (should be dropped due to non-textual MIME type) + binary_result = embedded_resource_mcp_client.call_tool_sync( + tool_use_id="test-embedded-binary", + name="get_binary_content", + arguments={}, + ) + assert binary_result["status"] == "success" + # Binary content should be dropped, so no content returned + assert len(binary_result["content"]) == 0 + + +@pytest.mark.asyncio +async def test_mcp_client_embedded_resources_async(): + """Test that async MCP client properly handles EmbeddedResource content types.""" + embedded_resource_mcp_client = MCPClient( + lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/embedded_resource_server.py"])) + ) + + with embedded_resource_mcp_client: + # Test text embedded resource async + text_result = await embedded_resource_mcp_client.call_tool_async( + tool_use_id="test-embedded-text-async", + name="get_text_file_content", + arguments={}, + ) + assert text_result["status"] == "success" + assert len(text_result["content"]) == 1 + assert text_result["content"][0]["text"] == "Hello, this is embedded text content!" + + # Test JSON embedded resource async + json_result = await embedded_resource_mcp_client.call_tool_async( + tool_use_id="test-embedded-json-async", + name="get_json_file_content", + arguments={}, + ) + assert json_result["status"] == "success" + assert len(json_result["content"]) == 1 + json_content = json_result["content"][0]["text"] + assert "Hello from embedded JSON!" in json_content + + +def test_mcp_client_embedded_resources_with_agent(): + """Test that embedded resources work correctly when used with Agent.""" + embedded_resource_mcp_client = MCPClient( + lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/embedded_resource_server.py"])) + ) + + with embedded_resource_mcp_client: + tools = embedded_resource_mcp_client.list_tools_sync() + agent = Agent(tools=tools) + + # Test that agent can successfully use tools that return embedded resources + result = agent("Get the text file content and tell me what it says") + + # Check that the agent successfully processed the embedded resource + assert result.message is not None + response_text = " ".join([ + block["text"] + for block in result.message["content"] + if "text" in block + ]).lower() + + # The agent should have received and processed the embedded text content + assert any([ + "hello" in response_text, + "embedded text content" in response_text, + "text content" in response_text + ]) + + def _messages_to_content_blocks(messages: List[Message]) -> List[ToolUse]: return [block["toolUse"] for message in messages for block in message["content"] if "toolUse" in block] From 8d7aa6609ebb268400757c23fd7b231c6bfdfa82 Mon Sep 17 00:00:00 2001 From: Kyler Middleton Date: Mon, 25 Aug 2025 14:45:06 -0500 Subject: [PATCH 05/10] also update res to reosurce --- src/strands/tools/mcp/mcp_client.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/strands/tools/mcp/mcp_client.py b/src/strands/tools/mcp/mcp_client.py index b8914721d..e022ce855 100644 --- a/src/strands/tools/mcp/mcp_client.py +++ b/src/strands/tools/mcp/mcp_client.py @@ -455,17 +455,17 @@ def _map_mcp_content_to_tool_result_content( } elif isinstance(content, MCPEmbeddedResource): self._log_debug_with_thread("mapping MCP embedded resource content") - res = getattr(content, "resource", None) - if res is None: + resource = getattr(content, "resource", None) + if resource is None: self._log_debug_with_thread("embedded resource has no 'resource' field - dropping") return None # Support both pydantic model and dict access def _get(attr: str) -> Any: - if hasattr(res, attr): - return getattr(res, attr) - if isinstance(res, dict): - return res.get(attr) + if hasattr(resource, attr): + return getattr(resource, attr) + if isinstance(resource, dict): + return resource.get(attr) return None text_val = _get("text") From 93838c93c739b2544ba042cbbcae1a4c54eda0fb Mon Sep 17 00:00:00 2001 From: Kyler Middleton Date: Mon, 22 Sep 2025 15:13:01 -0500 Subject: [PATCH 06/10] Simplify lookup logic --- src/strands/tools/mcp/mcp_client.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/strands/tools/mcp/mcp_client.py b/src/strands/tools/mcp/mcp_client.py index e022ce855..274429802 100644 --- a/src/strands/tools/mcp/mcp_client.py +++ b/src/strands/tools/mcp/mcp_client.py @@ -460,20 +460,12 @@ def _map_mcp_content_to_tool_result_content( self._log_debug_with_thread("embedded resource has no 'resource' field - dropping") return None - # Support both pydantic model and dict access - def _get(attr: str) -> Any: - if hasattr(resource, attr): - return getattr(resource, attr) - if isinstance(resource, dict): - return resource.get(attr) - return None - - text_val = _get("text") + text_val = getattr(resource, "text", None) if text_val: return {"text": text_val} - blob_val = _get("blob") - mime_type = _get("mimeType") + blob_val = getattr(resource, "blob", None) + mime_type = getattr(resource, "mimeType", None) if blob_val is not None: # blob is a base64 string in current mcp schema @@ -527,7 +519,7 @@ def _is_textual(mt: Optional[str]) -> bool: return None # Handle URI-only resources - uri = _get("uri") + uri = getattr(resource, "uri", None) if uri: return { "json": { From 13cee2909a8d93cb00d483c15d778294b9bab0a3 Mon Sep 17 00:00:00 2001 From: Dean Schmigelski Date: Mon, 6 Oct 2025 12:30:17 -0400 Subject: [PATCH 07/10] feat(mcp): add support for embedded resources in tool responses --- src/strands/tools/mcp/mcp_client.py | 81 ++++++++-------------- tests/strands/tools/mcp/test_mcp_client.py | 50 +++++++++++-- tests_integ/mcp/echo_server.py | 57 +++++++++++++++ tests_integ/mcp/test_mcp_client.py | 81 +++++++--------------- 4 files changed, 154 insertions(+), 115 deletions(-) diff --git a/src/strands/tools/mcp/mcp_client.py b/src/strands/tools/mcp/mcp_client.py index 274429802..37bd0fed9 100644 --- a/src/strands/tools/mcp/mcp_client.py +++ b/src/strands/tools/mcp/mcp_client.py @@ -19,11 +19,11 @@ from typing import Any, Callable, Coroutine, Dict, Optional, TypeVar, Union, cast from mcp import ClientSession, ListToolsResult +from mcp.types import BlobResourceContents, GetPromptResult, ListPromptsResult, TextResourceContents from mcp.types import CallToolResult as MCPCallToolResult -from mcp.types import GetPromptResult, ListPromptsResult +from mcp.types import EmbeddedResource as MCPEmbeddedResource from mcp.types import ImageContent as MCPImageContent from mcp.types import TextContent as MCPTextContent -from mcp.types import EmbeddedResource as MCPEmbeddedResource from ...types import PaginatedList from ...types.exceptions import MCPClientInitializationError @@ -358,8 +358,7 @@ def _handle_tool_result(self, tool_use_id: str, call_tool_result: MCPCallToolRes """ self._log_debug_with_thread("received tool result with %d content items", len(call_tool_result.content)) - # Build a typed list of ToolResultContent. Use a clearer local name to avoid shadowing - # and annotate the result for mypy so it knows the intended element type. + # Build a typed list of ToolResultContent. mapped_contents: list[ToolResultContent] = [ mc for content in call_tool_result.content @@ -454,83 +453,57 @@ def _map_mcp_content_to_tool_result_content( } } elif isinstance(content, MCPEmbeddedResource): - self._log_debug_with_thread("mapping MCP embedded resource content") - resource = getattr(content, "resource", None) - if resource is None: - self._log_debug_with_thread("embedded resource has no 'resource' field - dropping") - return None + """ + TODO: Include URI information in results. + Models may find it useful to be aware not only of the information, + but the location of the information too. - text_val = getattr(resource, "text", None) - if text_val: - return {"text": text_val} + This may be difficult without taking an opinionated position. For example, + a content block may need to indicate that the following Image content block + is of particular URI. + """ - blob_val = getattr(resource, "blob", None) - mime_type = getattr(resource, "mimeType", None) + self._log_debug_with_thread("mapping MCP embedded resource content") - if blob_val is not None: - # blob is a base64 string in current mcp schema - raw_bytes: Optional[bytes] + resource = content.resource + if isinstance(resource, TextResourceContents): + return {"text": resource.text} + elif isinstance(resource, BlobResourceContents): try: - if isinstance(blob_val, (bytes, bytearray)): - raw_bytes = bytes(blob_val) - elif isinstance(blob_val, str): - raw_bytes = base64.b64decode(blob_val) - else: - raw_bytes = None + raw_bytes = base64.b64decode(resource.blob) except Exception: - raw_bytes = None - - if raw_bytes is None: self._log_debug_with_thread("embedded resource blob could not be decoded - dropping") return None - def _is_textual(mt: Optional[str]) -> bool: - if not mt: - return False - if mt.startswith("text/"): - return True - textual = ( + if resource.mimeType and ( + resource.mimeType.startswith("text/") + or resource.mimeType + in ( "application/json", "application/xml", "application/javascript", - "application/x-yaml", "application/yaml", - "application/xhtml+xml", + "application/x-yaml", ) - if mt in textual or mt.endswith("+json") or mt.endswith("+xml"): - return True - return False - - if _is_textual(mime_type): + or resource.mimeType.endswith(("+json", "+xml")) + ): try: return {"text": raw_bytes.decode("utf-8", errors="replace")} except Exception: pass - if mime_type in MIME_TO_FORMAT: + if resource.mimeType in MIME_TO_FORMAT: return { "image": { - "format": MIME_TO_FORMAT[mime_type], + "format": MIME_TO_FORMAT[resource.mimeType], "source": {"bytes": raw_bytes}, } } self._log_debug_with_thread("embedded resource blob with non-textual/unknown mimeType - dropping") return None - - # Handle URI-only resources - uri = getattr(resource, "uri", None) - if uri: - return { - "json": { - "uri": uri, - "mime_type": mime_type - } - } - # Make sure we return in all paths - self._log_debug_with_thread("embedded resource had no usable text/blob/uri; dropping") - return None + return None # type: ignore[unreachable] # Defensive: future MCP resource types else: self._log_debug_with_thread("unhandled content type: %s - dropping content", content.__class__.__name__) return None diff --git a/tests/strands/tools/mcp/test_mcp_client.py b/tests/strands/tools/mcp/test_mcp_client.py index 864882c1c..f4b7269ab 100644 --- a/tests/strands/tools/mcp/test_mcp_client.py +++ b/tests/strands/tools/mcp/test_mcp_client.py @@ -1,5 +1,5 @@ -import time import base64 +import time from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -557,6 +557,7 @@ def test_call_tool_sync_embedded_nested_text(mock_transport, mock_session): mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[er]) from strands.tools.mcp import MCPClient + with MCPClient(mock_transport["transport_callable"]) as client: result = client.call_tool_sync(tool_use_id="er-text", name="get_file_contents", arguments={}) @@ -569,7 +570,9 @@ def test_call_tool_sync_embedded_nested_text(mock_transport, mock_session): def test_call_tool_sync_embedded_nested_base64_textual_mime(mock_transport, mock_session): """EmbeddedResource.resource (uri + blob with textual MIME) should decode to text.""" import base64 + from mcp.types import CallToolResult as MCPCallToolResult + payload = base64.b64encode(b'{"k":"v"}').decode() er = { @@ -584,6 +587,7 @@ def test_call_tool_sync_embedded_nested_base64_textual_mime(mock_transport, mock mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[er]) from strands.tools.mcp import MCPClient + with MCPClient(mock_transport["transport_callable"]) as client: result = client.call_tool_sync(tool_use_id="er-blob", name="get_file_contents", arguments={}) @@ -596,8 +600,11 @@ def test_call_tool_sync_embedded_nested_base64_textual_mime(mock_transport, mock def test_call_tool_sync_embedded_image_blob(mock_transport, mock_session): """EmbeddedResource.resource (blob with image MIME) should map to image content.""" import base64 + # Create a simple 1x1 PNG image - png_data = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==") + png_data = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" + ) payload = base64.b64encode(png_data).decode() er = { @@ -611,6 +618,7 @@ def test_call_tool_sync_embedded_image_blob(mock_transport, mock_session): mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[er]) from strands.tools.mcp import MCPClient + with MCPClient(mock_transport["transport_callable"]) as client: result = client.call_tool_sync(tool_use_id="er-image", name="get_file_contents", arguments={}) @@ -624,7 +632,7 @@ def test_call_tool_sync_embedded_image_blob(mock_transport, mock_session): def test_call_tool_sync_embedded_non_textual_blob_dropped(mock_transport, mock_session): """EmbeddedResource.resource (blob with non-textual/unknown MIME) should be dropped.""" - payload = base64.b64encode(b'\x00\x01\x02\x03').decode() + payload = base64.b64encode(b"\x00\x01\x02\x03").decode() er = { "type": "resource", @@ -637,6 +645,7 @@ def test_call_tool_sync_embedded_non_textual_blob_dropped(mock_transport, mock_s mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[er]) from strands.tools.mcp import MCPClient + with MCPClient(mock_transport["transport_callable"]) as client: result = client.call_tool_sync(tool_use_id="er-binary", name="get_file_contents", arguments={}) @@ -648,9 +657,9 @@ def test_call_tool_sync_embedded_non_textual_blob_dropped(mock_transport, mock_s def test_call_tool_sync_embedded_multiple_textual_mimes(mock_transport, mock_session): """EmbeddedResource with different textual MIME types should decode to text.""" import base64 - + # Test YAML content - yaml_content = base64.b64encode(b'key: value\nlist:\n - item1\n - item2').decode() + yaml_content = base64.b64encode(b"key: value\nlist:\n - item1\n - item2").decode() er = { "type": "resource", "resource": { @@ -662,10 +671,39 @@ def test_call_tool_sync_embedded_multiple_textual_mimes(mock_transport, mock_ses mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[er]) from strands.tools.mcp import MCPClient + with MCPClient(mock_transport["transport_callable"]) as client: result = client.call_tool_sync(tool_use_id="er-yaml", name="get_file_contents", arguments={}) mock_session.call_tool.assert_called_once_with("get_file_contents", {}, None) assert result["status"] == "success" assert len(result["content"]) == 1 - assert "key: value" in result["content"][0]["text"] \ No newline at end of file + assert "key: value" in result["content"][0]["text"] + + +def test_call_tool_sync_embedded_unknown_resource_type_dropped(mock_transport, mock_session): + """EmbeddedResource with unknown resource type should be dropped for forward compatibility.""" + + # Mock an unknown resource type that's neither TextResourceContents nor BlobResourceContents + class UnknownResourceContents: + def __init__(self): + self.uri = "mcp://resource/unknown-type" + self.mimeType = "application/unknown" + self.data = "some unknown data" + + # Create a mock embedded resource with unknown resource type + mock_embedded_resource = MagicMock() + mock_embedded_resource.resource = UnknownResourceContents() + + mock_session.call_tool.return_value = MagicMock( + isError=False, content=[mock_embedded_resource], structuredContent=None + ) + + from strands.tools.mcp import MCPClient + + with MCPClient(mock_transport["transport_callable"]) as client: + result = client.call_tool_sync(tool_use_id="er-unknown", name="get_file_contents", arguments={}) + + mock_session.call_tool.assert_called_once_with("get_file_contents", {}, None) + assert result["status"] == "success" + assert len(result["content"]) == 0 # Unknown resource type should be dropped diff --git a/tests_integ/mcp/echo_server.py b/tests_integ/mcp/echo_server.py index 160ad5af9..776a5a888 100644 --- a/tests_integ/mcp/echo_server.py +++ b/tests_integ/mcp/echo_server.py @@ -15,7 +15,10 @@ $ python echo_server.py """ +import base64 + from mcp.server import FastMCP +from mcp.types import BlobResourceContents, EmbeddedResource, TextResourceContents from pydantic import BaseModel @@ -46,6 +49,60 @@ def echo(to_echo: str) -> str: def echo_with_structured_content(to_echo: str) -> EchoResponse: return EchoResponse(echoed=to_echo, message_length=len(to_echo)) + @mcp.tool(description="Get current weather information for a location") + def get_weather(location: str = "New York"): + """Get weather data including forecasts and alerts for the specified location""" + if location.lower() == "new york": + return [ + EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri="https://weather.api/forecast/nyc", + mimeType="text/plain", + text="Current weather in New York: 72°F, partly cloudy with light winds.", + ), + ) + ] + elif location.lower() == "london": + return [ + EmbeddedResource( + type="resource", + resource=BlobResourceContents( + uri="https://weather.api/data/london.json", + mimeType="application/json", + blob=base64.b64encode( + '{"temperature": 18, "condition": "rainy", "humidity": 85}'.encode() + ).decode(), + ), + ) + ] + elif location.lower() == "tokyo": + # Simple 1x1 PNG image as base64 (weather icon) + png_data = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" + ) + return [ + EmbeddedResource( + type="resource", + resource=BlobResourceContents( + uri="https://weather.api/icons/sunny.png", + mimeType="image/png", + blob=base64.b64encode(png_data).decode(), + ), + ) + ] + else: + return [ + EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri=f"https://weather.api/forecast/{location.lower()}", + mimeType="text/plain", + text=f"Weather data for {location}: Currently unavailable.", + ), + ) + ] + mcp.run(transport="stdio") diff --git a/tests_integ/mcp/test_mcp_client.py b/tests_integ/mcp/test_mcp_client.py index 5808e9104..82e6f00e5 100644 --- a/tests_integ/mcp/test_mcp_client.py +++ b/tests_integ/mcp/test_mcp_client.py @@ -270,49 +270,38 @@ def transport_callback() -> MCPTransport: def test_mcp_client_embedded_resources(): """Test that MCP client properly handles EmbeddedResource content types.""" embedded_resource_mcp_client = MCPClient( - lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/embedded_resource_server.py"])) + lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/mcp/echo_server.py"])) ) with embedded_resource_mcp_client: # Test text embedded resource text_result = embedded_resource_mcp_client.call_tool_sync( tool_use_id="test-embedded-text", - name="get_text_file_content", - arguments={}, + name="get_weather", + arguments={"location": "New York"}, ) assert text_result["status"] == "success" assert len(text_result["content"]) == 1 - assert text_result["content"][0]["text"] == "Hello, this is embedded text content!" + assert "72°F" in text_result["content"][0]["text"] + assert "partly cloudy" in text_result["content"][0]["text"] # Test JSON embedded resource (blob with textual MIME type) json_result = embedded_resource_mcp_client.call_tool_sync( tool_use_id="test-embedded-json", - name="get_json_file_content", - arguments={}, + name="get_weather", + arguments={"location": "London"}, ) assert json_result["status"] == "success" assert len(json_result["content"]) == 1 json_content = json_result["content"][0]["text"] - assert "Hello from embedded JSON!" in json_content - assert "test" in json_content - - # Test YAML embedded resource (blob with textual MIME type) - yaml_result = embedded_resource_mcp_client.call_tool_sync( - tool_use_id="test-embedded-yaml", - name="get_yaml_file_content", - arguments={}, - ) - assert yaml_result["status"] == "success" - assert len(yaml_result["content"]) == 1 - yaml_content = yaml_result["content"][0]["text"] - assert "Hello from embedded YAML!" in yaml_content - assert "item1" in yaml_content + assert "temperature" in json_content + assert "rainy" in json_content # Test image embedded resource image_result = embedded_resource_mcp_client.call_tool_sync( tool_use_id="test-embedded-image", - name="get_image_content", - arguments={}, + name="get_weather", + arguments={"location": "Tokyo"}, ) assert image_result["status"] == "success" assert len(image_result["content"]) == 1 @@ -320,74 +309,56 @@ def test_mcp_client_embedded_resources(): assert image_result["content"][0]["image"]["format"] == "png" assert "bytes" in image_result["content"][0]["image"]["source"] - # Test binary embedded resource (should be dropped due to non-textual MIME type) - binary_result = embedded_resource_mcp_client.call_tool_sync( - tool_use_id="test-embedded-binary", - name="get_binary_content", - arguments={}, - ) - assert binary_result["status"] == "success" - # Binary content should be dropped, so no content returned - assert len(binary_result["content"]) == 0 - @pytest.mark.asyncio async def test_mcp_client_embedded_resources_async(): """Test that async MCP client properly handles EmbeddedResource content types.""" embedded_resource_mcp_client = MCPClient( - lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/embedded_resource_server.py"])) + lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/mcp/echo_server.py"])) ) with embedded_resource_mcp_client: # Test text embedded resource async text_result = await embedded_resource_mcp_client.call_tool_async( tool_use_id="test-embedded-text-async", - name="get_text_file_content", - arguments={}, + name="get_weather", + arguments={"location": "New York"}, ) assert text_result["status"] == "success" assert len(text_result["content"]) == 1 - assert text_result["content"][0]["text"] == "Hello, this is embedded text content!" + assert "72°F" in text_result["content"][0]["text"] # Test JSON embedded resource async json_result = await embedded_resource_mcp_client.call_tool_async( tool_use_id="test-embedded-json-async", - name="get_json_file_content", - arguments={}, + name="get_weather", + arguments={"location": "London"}, ) assert json_result["status"] == "success" assert len(json_result["content"]) == 1 json_content = json_result["content"][0]["text"] - assert "Hello from embedded JSON!" in json_content + assert "temperature" in json_content def test_mcp_client_embedded_resources_with_agent(): """Test that embedded resources work correctly when used with Agent.""" embedded_resource_mcp_client = MCPClient( - lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/embedded_resource_server.py"])) + lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/mcp/echo_server.py"])) ) with embedded_resource_mcp_client: tools = embedded_resource_mcp_client.list_tools_sync() agent = Agent(tools=tools) - + # Test that agent can successfully use tools that return embedded resources - result = agent("Get the text file content and tell me what it says") - + result = agent("Get the weather for New York and tell me what it says") + # Check that the agent successfully processed the embedded resource assert result.message is not None - response_text = " ".join([ - block["text"] - for block in result.message["content"] - if "text" in block - ]).lower() - - # The agent should have received and processed the embedded text content - assert any([ - "hello" in response_text, - "embedded text content" in response_text, - "text content" in response_text - ]) + response_text = " ".join([block["text"] for block in result.message["content"] if "text" in block]).lower() + + # The agent should have received and processed the embedded weather content + assert any(["72" in response_text, "partly cloudy" in response_text, "weather" in response_text]) def _messages_to_content_blocks(messages: List[Message]) -> List[ToolUse]: From 809ff78d8916bb96cfaa6d0d0249ece70dc05706 Mon Sep 17 00:00:00 2001 From: Dean Schmigelski Date: Mon, 6 Oct 2025 13:12:13 -0400 Subject: [PATCH 08/10] refactor: tests --- tests/strands/tools/mcp/test_mcp_client.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/tests/strands/tools/mcp/test_mcp_client.py b/tests/strands/tools/mcp/test_mcp_client.py index f4b7269ab..eecb5e624 100644 --- a/tests/strands/tools/mcp/test_mcp_client.py +++ b/tests/strands/tools/mcp/test_mcp_client.py @@ -569,9 +569,6 @@ def test_call_tool_sync_embedded_nested_text(mock_transport, mock_session): def test_call_tool_sync_embedded_nested_base64_textual_mime(mock_transport, mock_session): """EmbeddedResource.resource (uri + blob with textual MIME) should decode to text.""" - import base64 - - from mcp.types import CallToolResult as MCPCallToolResult payload = base64.b64encode(b'{"k":"v"}').decode() @@ -599,12 +596,9 @@ def test_call_tool_sync_embedded_nested_base64_textual_mime(mock_transport, mock def test_call_tool_sync_embedded_image_blob(mock_transport, mock_session): """EmbeddedResource.resource (blob with image MIME) should map to image content.""" - import base64 - - # Create a simple 1x1 PNG image - png_data = base64.b64decode( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" - ) + # Read yellow.png file + with open("tests_integ/yellow.png", "rb") as image_file: + png_data = image_file.read() payload = base64.b64encode(png_data).decode() er = { @@ -656,7 +650,6 @@ def test_call_tool_sync_embedded_non_textual_blob_dropped(mock_transport, mock_s def test_call_tool_sync_embedded_multiple_textual_mimes(mock_transport, mock_session): """EmbeddedResource with different textual MIME types should decode to text.""" - import base64 # Test YAML content yaml_content = base64.b64encode(b"key: value\nlist:\n - item1\n - item2").decode() From 2aeae796767777473360971a30b4203b6828eab9 Mon Sep 17 00:00:00 2001 From: Dean Schmigelski Date: Mon, 6 Oct 2025 13:14:59 -0400 Subject: [PATCH 09/10] linting --- tests/strands/tools/mcp/test_mcp_client.py | 32 +++++++--------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/tests/strands/tools/mcp/test_mcp_client.py b/tests/strands/tools/mcp/test_mcp_client.py index eecb5e624..130a4703e 100644 --- a/tests/strands/tools/mcp/test_mcp_client.py +++ b/tests/strands/tools/mcp/test_mcp_client.py @@ -546,7 +546,7 @@ def slow_transport(): def test_call_tool_sync_embedded_nested_text(mock_transport, mock_session): """EmbeddedResource.resource (uri + text) should map to plain text content.""" - er = { + embedded_resource = { "type": "resource", # required literal "resource": { "uri": "mcp://resource/embedded-text-1", @@ -554,9 +554,7 @@ def test_call_tool_sync_embedded_nested_text(mock_transport, mock_session): "mimeType": "text/plain", }, } - mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[er]) - - from strands.tools.mcp import MCPClient + mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[embedded_resource]) with MCPClient(mock_transport["transport_callable"]) as client: result = client.call_tool_sync(tool_use_id="er-text", name="get_file_contents", arguments={}) @@ -572,7 +570,7 @@ def test_call_tool_sync_embedded_nested_base64_textual_mime(mock_transport, mock payload = base64.b64encode(b'{"k":"v"}').decode() - er = { + embedded_resource = { "type": "resource", "resource": { "uri": "mcp://resource/embedded-blob-1", @@ -581,9 +579,7 @@ def test_call_tool_sync_embedded_nested_base64_textual_mime(mock_transport, mock "mimeType": "application/json", }, } - mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[er]) - - from strands.tools.mcp import MCPClient + mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[embedded_resource]) with MCPClient(mock_transport["transport_callable"]) as client: result = client.call_tool_sync(tool_use_id="er-blob", name="get_file_contents", arguments={}) @@ -601,7 +597,7 @@ def test_call_tool_sync_embedded_image_blob(mock_transport, mock_session): png_data = image_file.read() payload = base64.b64encode(png_data).decode() - er = { + embedded_resource = { "type": "resource", "resource": { "uri": "mcp://resource/embedded-image", @@ -609,9 +605,7 @@ def test_call_tool_sync_embedded_image_blob(mock_transport, mock_session): "mimeType": "image/png", }, } - mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[er]) - - from strands.tools.mcp import MCPClient + mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[embedded_resource]) with MCPClient(mock_transport["transport_callable"]) as client: result = client.call_tool_sync(tool_use_id="er-image", name="get_file_contents", arguments={}) @@ -628,7 +622,7 @@ def test_call_tool_sync_embedded_non_textual_blob_dropped(mock_transport, mock_s """EmbeddedResource.resource (blob with non-textual/unknown MIME) should be dropped.""" payload = base64.b64encode(b"\x00\x01\x02\x03").decode() - er = { + embedded_resource = { "type": "resource", "resource": { "uri": "mcp://resource/embedded-binary", @@ -636,9 +630,7 @@ def test_call_tool_sync_embedded_non_textual_blob_dropped(mock_transport, mock_s "mimeType": "application/octet-stream", }, } - mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[er]) - - from strands.tools.mcp import MCPClient + mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[embedded_resource]) with MCPClient(mock_transport["transport_callable"]) as client: result = client.call_tool_sync(tool_use_id="er-binary", name="get_file_contents", arguments={}) @@ -653,7 +645,7 @@ def test_call_tool_sync_embedded_multiple_textual_mimes(mock_transport, mock_ses # Test YAML content yaml_content = base64.b64encode(b"key: value\nlist:\n - item1\n - item2").decode() - er = { + embedded_resource = { "type": "resource", "resource": { "uri": "mcp://resource/embedded-yaml", @@ -661,9 +653,7 @@ def test_call_tool_sync_embedded_multiple_textual_mimes(mock_transport, mock_ses "mimeType": "application/yaml", }, } - mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[er]) - - from strands.tools.mcp import MCPClient + mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[embedded_resource]) with MCPClient(mock_transport["transport_callable"]) as client: result = client.call_tool_sync(tool_use_id="er-yaml", name="get_file_contents", arguments={}) @@ -692,8 +682,6 @@ def __init__(self): isError=False, content=[mock_embedded_resource], structuredContent=None ) - from strands.tools.mcp import MCPClient - with MCPClient(mock_transport["transport_callable"]) as client: result = client.call_tool_sync(tool_use_id="er-unknown", name="get_file_contents", arguments={}) From 2cfa291f5fea1fd336116db9ab1e277fe8ffa52d Mon Sep 17 00:00:00 2001 From: Dean Schmigelski Date: Mon, 6 Oct 2025 13:35:08 -0400 Subject: [PATCH 10/10] more cleaning --- tests_integ/mcp/echo_server.py | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/tests_integ/mcp/echo_server.py b/tests_integ/mcp/echo_server.py index 776a5a888..e15065a4a 100644 --- a/tests_integ/mcp/echo_server.py +++ b/tests_integ/mcp/echo_server.py @@ -16,6 +16,7 @@ """ import base64 +from typing import Literal from mcp.server import FastMCP from mcp.types import BlobResourceContents, EmbeddedResource, TextResourceContents @@ -50,7 +51,7 @@ def echo_with_structured_content(to_echo: str) -> EchoResponse: return EchoResponse(echoed=to_echo, message_length=len(to_echo)) @mcp.tool(description="Get current weather information for a location") - def get_weather(location: str = "New York"): + def get_weather(location: Literal["New York", "London", "Tokyo"] = "New York"): """Get weather data including forecasts and alerts for the specified location""" if location.lower() == "new york": return [ @@ -77,10 +78,9 @@ def get_weather(location: str = "New York"): ) ] elif location.lower() == "tokyo": - # Simple 1x1 PNG image as base64 (weather icon) - png_data = base64.b64decode( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" - ) + # Read yellow.png file for weather icon + with open("tests_integ/yellow.png", "rb") as image_file: + png_data = image_file.read() return [ EmbeddedResource( type="resource", @@ -91,17 +91,6 @@ def get_weather(location: str = "New York"): ), ) ] - else: - return [ - EmbeddedResource( - type="resource", - resource=TextResourceContents( - uri=f"https://weather.api/forecast/{location.lower()}", - mimeType="text/plain", - text=f"Weather data for {location}: Currently unavailable.", - ), - ) - ] mcp.run(transport="stdio")