Skip to content

Commit aada326

Browse files
feat(mcp): Add EmbeddedResource support to mcp (#726)
--------- Co-authored-by: Dean Schmigelski <[email protected]>
1 parent 2f04758 commit aada326

File tree

4 files changed

+343
-4
lines changed

4 files changed

+343
-4
lines changed

src/strands/tools/mcp/mcp_client.py

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@
2020

2121
import anyio
2222
from mcp import ClientSession, ListToolsResult
23+
from mcp.types import BlobResourceContents, GetPromptResult, ListPromptsResult, TextResourceContents
2324
from mcp.types import CallToolResult as MCPCallToolResult
24-
from mcp.types import GetPromptResult, ListPromptsResult
25+
from mcp.types import EmbeddedResource as MCPEmbeddedResource
2526
from mcp.types import ImageContent as MCPImageContent
2627
from mcp.types import TextContent as MCPTextContent
2728

@@ -358,8 +359,7 @@ def _handle_tool_result(self, tool_use_id: str, call_tool_result: MCPCallToolRes
358359
"""
359360
self._log_debug_with_thread("received tool result with %d content items", len(call_tool_result.content))
360361

361-
# Build a typed list of ToolResultContent. Use a clearer local name to avoid shadowing
362-
# and annotate the result for mypy so it knows the intended element type.
362+
# Build a typed list of ToolResultContent.
363363
mapped_contents: list[ToolResultContent] = [
364364
mc
365365
for content in call_tool_result.content
@@ -438,7 +438,7 @@ def _background_task(self) -> None:
438438

439439
def _map_mcp_content_to_tool_result_content(
440440
self,
441-
content: MCPTextContent | MCPImageContent | Any,
441+
content: MCPTextContent | MCPImageContent | MCPEmbeddedResource | Any,
442442
) -> Union[ToolResultContent, None]:
443443
"""Maps MCP content types to tool result content types.
444444
@@ -462,6 +462,58 @@ def _map_mcp_content_to_tool_result_content(
462462
"source": {"bytes": base64.b64decode(content.data)},
463463
}
464464
}
465+
elif isinstance(content, MCPEmbeddedResource):
466+
"""
467+
TODO: Include URI information in results.
468+
Models may find it useful to be aware not only of the information,
469+
but the location of the information too.
470+
471+
This may be difficult without taking an opinionated position. For example,
472+
a content block may need to indicate that the following Image content block
473+
is of particular URI.
474+
"""
475+
476+
self._log_debug_with_thread("mapping MCP embedded resource content")
477+
478+
resource = content.resource
479+
if isinstance(resource, TextResourceContents):
480+
return {"text": resource.text}
481+
elif isinstance(resource, BlobResourceContents):
482+
try:
483+
raw_bytes = base64.b64decode(resource.blob)
484+
except Exception:
485+
self._log_debug_with_thread("embedded resource blob could not be decoded - dropping")
486+
return None
487+
488+
if resource.mimeType and (
489+
resource.mimeType.startswith("text/")
490+
or resource.mimeType
491+
in (
492+
"application/json",
493+
"application/xml",
494+
"application/javascript",
495+
"application/yaml",
496+
"application/x-yaml",
497+
)
498+
or resource.mimeType.endswith(("+json", "+xml"))
499+
):
500+
try:
501+
return {"text": raw_bytes.decode("utf-8", errors="replace")}
502+
except Exception:
503+
pass
504+
505+
if resource.mimeType in MIME_TO_FORMAT:
506+
return {
507+
"image": {
508+
"format": MIME_TO_FORMAT[resource.mimeType],
509+
"source": {"bytes": raw_bytes},
510+
}
511+
}
512+
513+
self._log_debug_with_thread("embedded resource blob with non-textual/unknown mimeType - dropping")
514+
return None
515+
516+
return None # type: ignore[unreachable] # Defensive: future MCP resource types
465517
else:
466518
self._log_debug_with_thread("unhandled content type: %s - dropping content", content.__class__.__name__)
467519
return None

tests/strands/tools/mcp/test_mcp_client.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import base64
12
import time
23
from unittest.mock import AsyncMock, MagicMock, patch
34

@@ -541,3 +542,149 @@ def slow_transport():
541542
assert client._background_thread_session is None
542543
assert client._background_thread_event_loop is None
543544
assert not client._init_future.done() # New future created
545+
546+
547+
def test_call_tool_sync_embedded_nested_text(mock_transport, mock_session):
548+
"""EmbeddedResource.resource (uri + text) should map to plain text content."""
549+
embedded_resource = {
550+
"type": "resource", # required literal
551+
"resource": {
552+
"uri": "mcp://resource/embedded-text-1",
553+
"text": "inner text",
554+
"mimeType": "text/plain",
555+
},
556+
}
557+
mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[embedded_resource])
558+
559+
with MCPClient(mock_transport["transport_callable"]) as client:
560+
result = client.call_tool_sync(tool_use_id="er-text", name="get_file_contents", arguments={})
561+
562+
mock_session.call_tool.assert_called_once_with("get_file_contents", {}, None)
563+
assert result["status"] == "success"
564+
assert len(result["content"]) == 1
565+
assert result["content"][0]["text"] == "inner text"
566+
567+
568+
def test_call_tool_sync_embedded_nested_base64_textual_mime(mock_transport, mock_session):
569+
"""EmbeddedResource.resource (uri + blob with textual MIME) should decode to text."""
570+
571+
payload = base64.b64encode(b'{"k":"v"}').decode()
572+
573+
embedded_resource = {
574+
"type": "resource",
575+
"resource": {
576+
"uri": "mcp://resource/embedded-blob-1",
577+
# NOTE: blob is a STRING, mimeType is sibling
578+
"blob": payload,
579+
"mimeType": "application/json",
580+
},
581+
}
582+
mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[embedded_resource])
583+
584+
with MCPClient(mock_transport["transport_callable"]) as client:
585+
result = client.call_tool_sync(tool_use_id="er-blob", name="get_file_contents", arguments={})
586+
587+
mock_session.call_tool.assert_called_once_with("get_file_contents", {}, None)
588+
assert result["status"] == "success"
589+
assert len(result["content"]) == 1
590+
assert result["content"][0]["text"] == '{"k":"v"}'
591+
592+
593+
def test_call_tool_sync_embedded_image_blob(mock_transport, mock_session):
594+
"""EmbeddedResource.resource (blob with image MIME) should map to image content."""
595+
# Read yellow.png file
596+
with open("tests_integ/yellow.png", "rb") as image_file:
597+
png_data = image_file.read()
598+
payload = base64.b64encode(png_data).decode()
599+
600+
embedded_resource = {
601+
"type": "resource",
602+
"resource": {
603+
"uri": "mcp://resource/embedded-image",
604+
"blob": payload,
605+
"mimeType": "image/png",
606+
},
607+
}
608+
mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[embedded_resource])
609+
610+
with MCPClient(mock_transport["transport_callable"]) as client:
611+
result = client.call_tool_sync(tool_use_id="er-image", name="get_file_contents", arguments={})
612+
613+
mock_session.call_tool.assert_called_once_with("get_file_contents", {}, None)
614+
assert result["status"] == "success"
615+
assert len(result["content"]) == 1
616+
assert "image" in result["content"][0]
617+
assert result["content"][0]["image"]["format"] == "png"
618+
assert "bytes" in result["content"][0]["image"]["source"]
619+
620+
621+
def test_call_tool_sync_embedded_non_textual_blob_dropped(mock_transport, mock_session):
622+
"""EmbeddedResource.resource (blob with non-textual/unknown MIME) should be dropped."""
623+
payload = base64.b64encode(b"\x00\x01\x02\x03").decode()
624+
625+
embedded_resource = {
626+
"type": "resource",
627+
"resource": {
628+
"uri": "mcp://resource/embedded-binary",
629+
"blob": payload,
630+
"mimeType": "application/octet-stream",
631+
},
632+
}
633+
mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[embedded_resource])
634+
635+
with MCPClient(mock_transport["transport_callable"]) as client:
636+
result = client.call_tool_sync(tool_use_id="er-binary", name="get_file_contents", arguments={})
637+
638+
mock_session.call_tool.assert_called_once_with("get_file_contents", {}, None)
639+
assert result["status"] == "success"
640+
assert len(result["content"]) == 0 # Content should be dropped
641+
642+
643+
def test_call_tool_sync_embedded_multiple_textual_mimes(mock_transport, mock_session):
644+
"""EmbeddedResource with different textual MIME types should decode to text."""
645+
646+
# Test YAML content
647+
yaml_content = base64.b64encode(b"key: value\nlist:\n - item1\n - item2").decode()
648+
embedded_resource = {
649+
"type": "resource",
650+
"resource": {
651+
"uri": "mcp://resource/embedded-yaml",
652+
"blob": yaml_content,
653+
"mimeType": "application/yaml",
654+
},
655+
}
656+
mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[embedded_resource])
657+
658+
with MCPClient(mock_transport["transport_callable"]) as client:
659+
result = client.call_tool_sync(tool_use_id="er-yaml", name="get_file_contents", arguments={})
660+
661+
mock_session.call_tool.assert_called_once_with("get_file_contents", {}, None)
662+
assert result["status"] == "success"
663+
assert len(result["content"]) == 1
664+
assert "key: value" in result["content"][0]["text"]
665+
666+
667+
def test_call_tool_sync_embedded_unknown_resource_type_dropped(mock_transport, mock_session):
668+
"""EmbeddedResource with unknown resource type should be dropped for forward compatibility."""
669+
670+
# Mock an unknown resource type that's neither TextResourceContents nor BlobResourceContents
671+
class UnknownResourceContents:
672+
def __init__(self):
673+
self.uri = "mcp://resource/unknown-type"
674+
self.mimeType = "application/unknown"
675+
self.data = "some unknown data"
676+
677+
# Create a mock embedded resource with unknown resource type
678+
mock_embedded_resource = MagicMock()
679+
mock_embedded_resource.resource = UnknownResourceContents()
680+
681+
mock_session.call_tool.return_value = MagicMock(
682+
isError=False, content=[mock_embedded_resource], structuredContent=None
683+
)
684+
685+
with MCPClient(mock_transport["transport_callable"]) as client:
686+
result = client.call_tool_sync(tool_use_id="er-unknown", name="get_file_contents", arguments={})
687+
688+
mock_session.call_tool.assert_called_once_with("get_file_contents", {}, None)
689+
assert result["status"] == "success"
690+
assert len(result["content"]) == 0 # Unknown resource type should be dropped

tests_integ/mcp/echo_server.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515
$ python echo_server.py
1616
"""
1717

18+
import base64
19+
from typing import Literal
20+
1821
from mcp.server import FastMCP
22+
from mcp.types import BlobResourceContents, EmbeddedResource, TextResourceContents
1923
from pydantic import BaseModel
2024

2125

@@ -46,6 +50,48 @@ def echo(to_echo: str) -> str:
4650
def echo_with_structured_content(to_echo: str) -> EchoResponse:
4751
return EchoResponse(echoed=to_echo, message_length=len(to_echo))
4852

53+
@mcp.tool(description="Get current weather information for a location")
54+
def get_weather(location: Literal["New York", "London", "Tokyo"] = "New York"):
55+
"""Get weather data including forecasts and alerts for the specified location"""
56+
if location.lower() == "new york":
57+
return [
58+
EmbeddedResource(
59+
type="resource",
60+
resource=TextResourceContents(
61+
uri="https://weather.api/forecast/nyc",
62+
mimeType="text/plain",
63+
text="Current weather in New York: 72°F, partly cloudy with light winds.",
64+
),
65+
)
66+
]
67+
elif location.lower() == "london":
68+
return [
69+
EmbeddedResource(
70+
type="resource",
71+
resource=BlobResourceContents(
72+
uri="https://weather.api/data/london.json",
73+
mimeType="application/json",
74+
blob=base64.b64encode(
75+
'{"temperature": 18, "condition": "rainy", "humidity": 85}'.encode()
76+
).decode(),
77+
),
78+
)
79+
]
80+
elif location.lower() == "tokyo":
81+
# Read yellow.png file for weather icon
82+
with open("tests_integ/yellow.png", "rb") as image_file:
83+
png_data = image_file.read()
84+
return [
85+
EmbeddedResource(
86+
type="resource",
87+
resource=BlobResourceContents(
88+
uri="https://weather.api/icons/sunny.png",
89+
mimeType="image/png",
90+
blob=base64.b64encode(png_data).decode(),
91+
),
92+
)
93+
]
94+
4995
mcp.run(transport="stdio")
5096

5197

0 commit comments

Comments
 (0)