Skip to content

Commit 4e0e0a6

Browse files
feat(mcp): retain structured content in the AgentTool response (#528)
1 parent bdc893b commit 4e0e0a6

File tree

9 files changed

+389
-56
lines changed

9 files changed

+389
-56
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ dependencies = [
2929
"boto3>=1.26.0,<2.0.0",
3030
"botocore>=1.29.0,<2.0.0",
3131
"docstring_parser>=0.15,<1.0",
32-
"mcp>=1.8.0,<2.0.0",
32+
"mcp>=1.11.0,<2.0.0",
3333
"pydantic>=2.0.0,<3.0.0",
3434
"typing-extensions>=4.13.2,<5.0.0",
3535
"watchdog>=6.0.0,<7.0.0",

src/strands/models/bedrock.py

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@
1717

1818
from ..event_loop import streaming
1919
from ..tools import convert_pydantic_to_tool_spec
20-
from ..types.content import Messages
20+
from ..types.content import ContentBlock, Message, Messages
2121
from ..types.exceptions import ContextWindowOverflowException, ModelThrottledException
2222
from ..types.streaming import StreamEvent
23-
from ..types.tools import ToolSpec
23+
from ..types.tools import ToolResult, ToolSpec
2424
from .model import Model
2525

2626
logger = logging.getLogger(__name__)
@@ -181,7 +181,7 @@ def format_request(
181181
"""
182182
return {
183183
"modelId": self.config["model_id"],
184-
"messages": messages,
184+
"messages": self._format_bedrock_messages(messages),
185185
"system": [
186186
*([{"text": system_prompt}] if system_prompt else []),
187187
*([{"cachePoint": {"type": self.config["cache_prompt"]}}] if self.config.get("cache_prompt") else []),
@@ -246,6 +246,53 @@ def format_request(
246246
),
247247
}
248248

249+
def _format_bedrock_messages(self, messages: Messages) -> Messages:
250+
"""Format messages for Bedrock API compatibility.
251+
252+
This function ensures messages conform to Bedrock's expected format by:
253+
- Cleaning tool result content blocks by removing additional fields that may be
254+
useful for retaining information in hooks but would cause Bedrock validation
255+
exceptions when presented with unexpected fields
256+
- Ensuring all message content blocks are properly formatted for the Bedrock API
257+
258+
Args:
259+
messages: List of messages to format
260+
261+
Returns:
262+
Messages formatted for Bedrock API compatibility
263+
264+
Note:
265+
Bedrock will throw validation exceptions when presented with additional
266+
unexpected fields in tool result blocks.
267+
https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ToolResultBlock.html
268+
"""
269+
cleaned_messages = []
270+
271+
for message in messages:
272+
cleaned_content: list[ContentBlock] = []
273+
274+
for content_block in message["content"]:
275+
if "toolResult" in content_block:
276+
# Create a new content block with only the cleaned toolResult
277+
tool_result: ToolResult = content_block["toolResult"]
278+
279+
# Keep only the required fields for Bedrock
280+
cleaned_tool_result = ToolResult(
281+
content=tool_result["content"], toolUseId=tool_result["toolUseId"], status=tool_result["status"]
282+
)
283+
284+
cleaned_block: ContentBlock = {"toolResult": cleaned_tool_result}
285+
cleaned_content.append(cleaned_block)
286+
else:
287+
# Keep other content blocks as-is
288+
cleaned_content.append(content_block)
289+
290+
# Create new message with cleaned content
291+
cleaned_message: Message = Message(content=cleaned_content, role=message["role"])
292+
cleaned_messages.append(cleaned_message)
293+
294+
return cleaned_messages
295+
249296
def _has_blocked_guardrail(self, guardrail_data: dict[str, Any]) -> bool:
250297
"""Check if guardrail data contains any blocked policies.
251298

src/strands/tools/mcp/mcp_client.py

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@
2626
from ...types import PaginatedList
2727
from ...types.exceptions import MCPClientInitializationError
2828
from ...types.media import ImageFormat
29-
from ...types.tools import ToolResult, ToolResultContent, ToolResultStatus
29+
from ...types.tools import ToolResultContent, ToolResultStatus
3030
from .mcp_agent_tool import MCPAgentTool
31-
from .mcp_types import MCPTransport
31+
from .mcp_types import MCPToolResult, MCPTransport
3232

3333
logger = logging.getLogger(__name__)
3434

@@ -57,7 +57,8 @@ class MCPClient:
5757
It handles the creation, initialization, and cleanup of MCP connections.
5858
5959
The connection runs in a background thread to avoid blocking the main application thread
60-
while maintaining communication with the MCP service.
60+
while maintaining communication with the MCP service. When structured content is available
61+
from MCP tools, it will be returned as the last item in the content array of the ToolResult.
6162
"""
6263

6364
def __init__(self, transport_callable: Callable[[], MCPTransport]):
@@ -170,11 +171,13 @@ def call_tool_sync(
170171
name: str,
171172
arguments: dict[str, Any] | None = None,
172173
read_timeout_seconds: timedelta | None = None,
173-
) -> ToolResult:
174+
) -> MCPToolResult:
174175
"""Synchronously calls a tool on the MCP server.
175176
176177
This method calls the asynchronous call_tool method on the MCP session
177-
and converts the result to the ToolResult format.
178+
and converts the result to the ToolResult format. If the MCP tool returns
179+
structured content, it will be included as the last item in the content array
180+
of the returned ToolResult.
178181
179182
Args:
180183
tool_use_id: Unique identifier for this tool use
@@ -183,7 +186,7 @@ def call_tool_sync(
183186
read_timeout_seconds: Optional timeout for the tool call
184187
185188
Returns:
186-
ToolResult: The result of the tool call
189+
MCPToolResult: The result of the tool call
187190
"""
188191
self._log_debug_with_thread("calling MCP tool '%s' synchronously with tool_use_id=%s", name, tool_use_id)
189192
if not self._is_session_active():
@@ -205,11 +208,11 @@ async def call_tool_async(
205208
name: str,
206209
arguments: dict[str, Any] | None = None,
207210
read_timeout_seconds: timedelta | None = None,
208-
) -> ToolResult:
211+
) -> MCPToolResult:
209212
"""Asynchronously calls a tool on the MCP server.
210213
211214
This method calls the asynchronous call_tool method on the MCP session
212-
and converts the result to the ToolResult format.
215+
and converts the result to the MCPToolResult format.
213216
214217
Args:
215218
tool_use_id: Unique identifier for this tool use
@@ -218,7 +221,7 @@ async def call_tool_async(
218221
read_timeout_seconds: Optional timeout for the tool call
219222
220223
Returns:
221-
ToolResult: The result of the tool call
224+
MCPToolResult: The result of the tool call
222225
"""
223226
self._log_debug_with_thread("calling MCP tool '%s' asynchronously with tool_use_id=%s", name, tool_use_id)
224227
if not self._is_session_active():
@@ -235,15 +238,27 @@ async def _call_tool_async() -> MCPCallToolResult:
235238
logger.exception("tool execution failed")
236239
return self._handle_tool_execution_error(tool_use_id, e)
237240

238-
def _handle_tool_execution_error(self, tool_use_id: str, exception: Exception) -> ToolResult:
241+
def _handle_tool_execution_error(self, tool_use_id: str, exception: Exception) -> MCPToolResult:
239242
"""Create error ToolResult with consistent logging."""
240-
return ToolResult(
243+
return MCPToolResult(
241244
status="error",
242245
toolUseId=tool_use_id,
243246
content=[{"text": f"Tool execution failed: {str(exception)}"}],
244247
)
245248

246-
def _handle_tool_result(self, tool_use_id: str, call_tool_result: MCPCallToolResult) -> ToolResult:
249+
def _handle_tool_result(self, tool_use_id: str, call_tool_result: MCPCallToolResult) -> MCPToolResult:
250+
"""Maps MCP tool result to the agent's MCPToolResult format.
251+
252+
This method processes the content from the MCP tool call result and converts it to the format
253+
expected by the framework.
254+
255+
Args:
256+
tool_use_id: Unique identifier for this tool use
257+
call_tool_result: The result from the MCP tool call
258+
259+
Returns:
260+
MCPToolResult: The converted tool result
261+
"""
247262
self._log_debug_with_thread("received tool result with %d content items", len(call_tool_result.content))
248263

249264
mapped_content = [
@@ -254,7 +269,15 @@ def _handle_tool_result(self, tool_use_id: str, call_tool_result: MCPCallToolRes
254269

255270
status: ToolResultStatus = "error" if call_tool_result.isError else "success"
256271
self._log_debug_with_thread("tool execution completed with status: %s", status)
257-
return ToolResult(status=status, toolUseId=tool_use_id, content=mapped_content)
272+
result = MCPToolResult(
273+
status=status,
274+
toolUseId=tool_use_id,
275+
content=mapped_content,
276+
)
277+
if call_tool_result.structuredContent:
278+
result["structuredContent"] = call_tool_result.structuredContent
279+
280+
return result
258281

259282
async def _async_background_thread(self) -> None:
260283
"""Asynchronous method that runs in the background thread to manage the MCP connection.

src/strands/tools/mcp/mcp_types.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
"""Type definitions for MCP integration."""
22

33
from contextlib import AbstractAsyncContextManager
4+
from typing import Any, Dict
45

56
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
67
from mcp.client.streamable_http import GetSessionIdCallback
78
from mcp.shared.memory import MessageStream
89
from mcp.shared.message import SessionMessage
10+
from typing_extensions import NotRequired
11+
12+
from strands.types.tools import ToolResult
913

1014
"""
1115
MCPTransport defines the interface for MCP transport implementations. This abstracts
@@ -41,3 +45,19 @@ async def my_transport_implementation():
4145
MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage], GetSessionIdCallback
4246
]
4347
MCPTransport = AbstractAsyncContextManager[MessageStream | _MessageStreamWithGetSessionIdCallback]
48+
49+
50+
class MCPToolResult(ToolResult):
51+
"""Result of an MCP tool execution.
52+
53+
Extends the base ToolResult with MCP-specific structured content support.
54+
The structuredContent field contains optional JSON data returned by MCP tools
55+
that provides structured results beyond the standard text/image/document content.
56+
57+
Attributes:
58+
structuredContent: Optional JSON object containing structured data returned
59+
by the MCP tool. This allows MCP tools to return complex data structures
60+
that can be processed programmatically by agents or other tools.
61+
"""
62+
63+
structuredContent: NotRequired[Dict[str, Any]]

0 commit comments

Comments
 (0)