From 9d9bc96b1a81b5edda0e21461d5380bea1e90e18 Mon Sep 17 00:00:00 2001 From: Vamil Gandhi Date: Sat, 22 Nov 2025 01:00:02 +0000 Subject: [PATCH 1/3] feat: add meta field support to MCP tool results Add support for the _meta field in MCP tool results to enable MCP servers to pass arbitrary metadata alongside tool outputs. This allows tracking of token usage, performance metrics, and other business-specific information. Changes: - Add meta field to MCPToolResult TypedDict - Update _handle_tool_result to extract and pass through meta field - Add comprehensive tests for meta field handling - Handle MCP SDK's _meta field aliasing via model_dump() Closes #881 --- src/strands/tools/mcp/mcp_client.py | 4 ++ src/strands/tools/mcp/mcp_types.py | 4 ++ tests/strands/tools/mcp/test_mcp_client.py | 62 ++++++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/src/strands/tools/mcp/mcp_client.py b/src/strands/tools/mcp/mcp_client.py index 7a26cdd6b..5589dcb4b 100644 --- a/src/strands/tools/mcp/mcp_client.py +++ b/src/strands/tools/mcp/mcp_client.py @@ -567,6 +567,10 @@ def _handle_tool_result(self, tool_use_id: str, call_tool_result: MCPCallToolRes if call_tool_result.structuredContent: result["structuredContent"] = call_tool_result.structuredContent + call_tool_result_dict = call_tool_result.model_dump() + if call_tool_result_dict.get("meta"): + result["meta"] = call_tool_result_dict["meta"] + return result async def _async_background_thread(self) -> None: diff --git a/src/strands/tools/mcp/mcp_types.py b/src/strands/tools/mcp/mcp_types.py index 66eda08ae..0a969321b 100644 --- a/src/strands/tools/mcp/mcp_types.py +++ b/src/strands/tools/mcp/mcp_types.py @@ -58,6 +58,10 @@ class MCPToolResult(ToolResult): structuredContent: Optional JSON object containing structured data returned by the MCP tool. This allows MCP tools to return complex data structures that can be processed programmatically by agents or other tools. + meta: Optional arbitrary metadata returned by the MCP tool. This field allows + MCP servers to attach custom metadata to tool results (e.g., token usage, + performance metrics, or business-specific tracking information). """ structuredContent: NotRequired[Dict[str, Any]] + meta: NotRequired[Dict[str, Any]] diff --git a/tests/strands/tools/mcp/test_mcp_client.py b/tests/strands/tools/mcp/test_mcp_client.py index e72aebd92..795fa15d5 100644 --- a/tests/strands/tools/mcp/test_mcp_client.py +++ b/tests/strands/tools/mcp/test_mcp_client.py @@ -750,3 +750,65 @@ async def test_handle_error_message_non_exception(): # This should not raise an exception await client._handle_error_message("normal message") + + +def test_call_tool_sync_with_meta_field(mock_transport, mock_session): + """Test that call_tool_sync correctly handles meta field.""" + mock_content = MCPTextContent(type="text", text="Test message") + meta_data = {"tokenUsage": {"inputTokens": 100, "outputTokens": 50}, "executionTime": 1.5} + mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[mock_content], meta=meta_data) + + with MCPClient(mock_transport["transport_callable"]) as client: + result = client.call_tool_sync(tool_use_id="test-123", name="test_tool", arguments={"param": "value"}) + + mock_session.call_tool.assert_called_once_with("test_tool", {"param": "value"}, None) + + assert result["status"] == "success" + assert result["toolUseId"] == "test-123" + assert len(result["content"]) == 1 + assert result["content"][0]["text"] == "Test message" + assert "meta" in result + assert result["meta"] == meta_data + assert result["meta"]["tokenUsage"]["inputTokens"] == 100 + assert result["meta"]["tokenUsage"]["outputTokens"] == 50 + assert result["meta"]["executionTime"] == 1.5 + + +def test_call_tool_sync_without_meta_field(mock_transport, mock_session): + """Test that call_tool_sync works correctly when no meta field is provided.""" + mock_content = MCPTextContent(type="text", text="Test message") + mock_session.call_tool.return_value = MCPCallToolResult( + isError=False, + content=[mock_content], + ) + + with MCPClient(mock_transport["transport_callable"]) as client: + result = client.call_tool_sync(tool_use_id="test-123", name="test_tool", arguments={"param": "value"}) + + assert result["status"] == "success" + assert result["toolUseId"] == "test-123" + assert len(result["content"]) == 1 + assert result["content"][0]["text"] == "Test message" + assert result.get("meta") is None + + +def test_call_tool_sync_with_meta_and_structured_content(mock_transport, mock_session): + """Test that call_tool_sync correctly handles both meta and structuredContent fields.""" + mock_content = MCPTextContent(type="text", text="Test message") + meta_data = {"tokenUsage": {"inputTokens": 100, "outputTokens": 50}} + structured_content = {"result": 42, "status": "completed"} + mock_session.call_tool.return_value = MCPCallToolResult( + isError=False, content=[mock_content], meta=meta_data, structuredContent=structured_content + ) + + with MCPClient(mock_transport["transport_callable"]) as client: + result = client.call_tool_sync(tool_use_id="test-123", name="test_tool", arguments={"param": "value"}) + + mock_session.call_tool.assert_called_once_with("test_tool", {"param": "value"}, None) + + assert result["status"] == "success" + assert result["toolUseId"] == "test-123" + assert "meta" in result + assert result["meta"] == meta_data + assert "structuredContent" in result + assert result["structuredContent"] == structured_content From 5d06302bb136bfc9b502257e5b367b967b27dc94 Mon Sep 17 00:00:00 2001 From: Dean Schmigelski Date: Wed, 17 Dec 2025 10:47:14 -0500 Subject: [PATCH 2/3] feat(mcp): switch from meta to metadata naming --- src/strands/tools/mcp/mcp_client.py | 6 +- src/strands/tools/mcp/mcp_types.py | 8 +- tests/strands/tools/mcp/test_mcp_client.py | 4 +- tests_integ/mcp/echo_server.py | 12 ++- ..._client_structured_content_and_metadata.py | 95 +++++++++++++++++++ ...cp_client_structured_content_with_hooks.py | 64 ------------- 6 files changed, 114 insertions(+), 75 deletions(-) create mode 100644 tests_integ/mcp/test_mcp_client_structured_content_and_metadata.py delete mode 100644 tests_integ/mcp/test_mcp_client_structured_content_with_hooks.py diff --git a/src/strands/tools/mcp/mcp_client.py b/src/strands/tools/mcp/mcp_client.py index 5589dcb4b..6ce591bc5 100644 --- a/src/strands/tools/mcp/mcp_client.py +++ b/src/strands/tools/mcp/mcp_client.py @@ -566,10 +566,8 @@ def _handle_tool_result(self, tool_use_id: str, call_tool_result: MCPCallToolRes if call_tool_result.structuredContent: result["structuredContent"] = call_tool_result.structuredContent - - call_tool_result_dict = call_tool_result.model_dump() - if call_tool_result_dict.get("meta"): - result["meta"] = call_tool_result_dict["meta"] + if call_tool_result.meta: + result["metadata"] = call_tool_result.meta return result diff --git a/src/strands/tools/mcp/mcp_types.py b/src/strands/tools/mcp/mcp_types.py index 0a969321b..8fbf573be 100644 --- a/src/strands/tools/mcp/mcp_types.py +++ b/src/strands/tools/mcp/mcp_types.py @@ -1,7 +1,7 @@ """Type definitions for MCP integration.""" from contextlib import AbstractAsyncContextManager -from typing import Any, Dict +from typing import Any from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from mcp.client.streamable_http import GetSessionIdCallback @@ -58,10 +58,10 @@ class MCPToolResult(ToolResult): structuredContent: Optional JSON object containing structured data returned by the MCP tool. This allows MCP tools to return complex data structures that can be processed programmatically by agents or other tools. - meta: Optional arbitrary metadata returned by the MCP tool. This field allows + metadata: Optional arbitrary metadata returned by the MCP tool. This field allows MCP servers to attach custom metadata to tool results (e.g., token usage, performance metrics, or business-specific tracking information). """ - structuredContent: NotRequired[Dict[str, Any]] - meta: NotRequired[Dict[str, Any]] + structuredContent: NotRequired[dict[str, Any]] + metadata: NotRequired[dict[str, Any]] diff --git a/tests/strands/tools/mcp/test_mcp_client.py b/tests/strands/tools/mcp/test_mcp_client.py index 795fa15d5..e402d823f 100644 --- a/tests/strands/tools/mcp/test_mcp_client.py +++ b/tests/strands/tools/mcp/test_mcp_client.py @@ -533,7 +533,7 @@ def test_stop_closes_event_loop(): mock_thread.join = MagicMock() mock_event_loop = MagicMock() mock_event_loop.close = MagicMock() - + client._background_thread = mock_thread client._background_thread_event_loop = mock_event_loop @@ -542,7 +542,7 @@ def test_stop_closes_event_loop(): # Verify thread was joined mock_thread.join.assert_called_once() - + # Verify event loop was closed mock_event_loop.close.assert_called_once() diff --git a/tests_integ/mcp/echo_server.py b/tests_integ/mcp/echo_server.py index e15065a4a..a23a87b5c 100644 --- a/tests_integ/mcp/echo_server.py +++ b/tests_integ/mcp/echo_server.py @@ -19,7 +19,7 @@ from typing import Literal from mcp.server import FastMCP -from mcp.types import BlobResourceContents, EmbeddedResource, TextResourceContents +from mcp.types import BlobResourceContents, CallToolResult, EmbeddedResource, TextContent, TextResourceContents from pydantic import BaseModel @@ -50,6 +50,16 @@ 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="Echos response back with metadata") + def echo_with_metadata(to_echo: str): + """Return structured content and metadata in the tool result.""" + + return CallToolResult( + content=[TextContent(type="text", text=to_echo)], + isError=False, + _meta={"metadata": {"nested": 1}, "shallow": "val"}, + ) + @mcp.tool(description="Get current weather information for a location") def get_weather(location: Literal["New York", "London", "Tokyo"] = "New York"): """Get weather data including forecasts and alerts for the specified location""" diff --git a/tests_integ/mcp/test_mcp_client_structured_content_and_metadata.py b/tests_integ/mcp/test_mcp_client_structured_content_and_metadata.py new file mode 100644 index 000000000..3e6132b38 --- /dev/null +++ b/tests_integ/mcp/test_mcp_client_structured_content_and_metadata.py @@ -0,0 +1,95 @@ +"""Integration test for MCP client structured content and metadata support. + +This test verifies that MCP tools can return structured content and metadata, +and that the MCP client properly handles and exposes these fields in tool results. +""" + +import json + +from mcp import StdioServerParameters, stdio_client + +from strands import Agent +from strands.hooks import AfterToolCallEvent, HookProvider, HookRegistry +from strands.tools.mcp.mcp_client import MCPClient + + +class ToolResultCapture(HookProvider): + """Captures tool results for inspection.""" + + def __init__(self): + self.captured_results = {} + + def register_hooks(self, registry: HookRegistry) -> None: + """Register callback for after tool invocation events.""" + registry.add_callback(AfterToolCallEvent, self.on_after_tool_invocation) + + def on_after_tool_invocation(self, event: AfterToolCallEvent) -> None: + """Capture tool results by tool name.""" + tool_name = event.tool_use["name"] + self.captured_results[tool_name] = event.result + + +def test_structured_content(): + """Test that MCP tools can return structured content.""" + # Set up result capture + result_capture = ToolResultCapture() + + # Set up MCP client for echo server + stdio_mcp_client = MCPClient( + lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/mcp/echo_server.py"])) + ) + + with stdio_mcp_client: + # Create agent with MCP tools and result capture + agent = Agent(tools=stdio_mcp_client.list_tools_sync(), hooks=[result_capture]) + + # Test structured content functionality + test_data = "STRUCTURED_TEST" + agent(f"Use the echo_with_structured_content tool to echo: {test_data}") + + # Verify result was captured + assert "echo_with_structured_content" in result_capture.captured_results + result = result_capture.captured_results["echo_with_structured_content"] + + # Verify basic result structure + assert result["status"] == "success" + assert len(result["content"]) == 1 + + # Verify structured content is present and correct + assert "structuredContent" in result + assert result["structuredContent"] == {"echoed": test_data, "message_length": 15} + + # Verify text content matches structured content + text_content = json.loads(result["content"][0]["text"]) + assert text_content == {"echoed": test_data, "message_length": 15} + + +def test_metadata(): + """Test that MCP tools can return metadata.""" + # Set up result capture + result_capture = ToolResultCapture() + + # Set up MCP client for echo server + stdio_mcp_client = MCPClient( + lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/mcp/echo_server.py"])) + ) + + with stdio_mcp_client: + # Create agent with MCP tools and result capture + agent = Agent(tools=stdio_mcp_client.list_tools_sync(), hooks=[result_capture]) + + # Test metadata functionality + test_data = "METADATA_TEST" + agent(f"Use the echo_with_metadata tool to echo: {test_data}") + + # Verify result was captured + assert "echo_with_metadata" in result_capture.captured_results + result = result_capture.captured_results["echo_with_metadata"] + + # Verify basic result structure + assert result["status"] == "success" + + # Verify metadata is present and correct + assert "metadata" in result + expected_metadata = {"metadata": {"nested": 1}, "shallow": "val"} + assert result["metadata"] == expected_metadata diff --git a/tests_integ/mcp/test_mcp_client_structured_content_with_hooks.py b/tests_integ/mcp/test_mcp_client_structured_content_with_hooks.py deleted file mode 100644 index ef4993b05..000000000 --- a/tests_integ/mcp/test_mcp_client_structured_content_with_hooks.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Integration test demonstrating hooks system with MCP client structured content tool. - -This test shows how to use the hooks system to capture and inspect tool invocation -results, specifically testing the echo_with_structured_content tool from echo_server. -""" - -import json - -from mcp import StdioServerParameters, stdio_client - -from strands import Agent -from strands.hooks import AfterToolCallEvent, HookProvider, HookRegistry -from strands.tools.mcp.mcp_client import MCPClient - - -class StructuredContentHookProvider(HookProvider): - """Hook provider that captures structured content tool results.""" - - def __init__(self): - self.captured_result = None - - def register_hooks(self, registry: HookRegistry) -> None: - """Register callback for after tool invocation events.""" - registry.add_callback(AfterToolCallEvent, self.on_after_tool_invocation) - - def on_after_tool_invocation(self, event: AfterToolCallEvent) -> None: - """Capture structured content tool results.""" - if event.tool_use["name"] == "echo_with_structured_content": - self.captured_result = event.result - - -def test_mcp_client_hooks_structured_content(): - """Test using hooks to inspect echo_with_structured_content tool result.""" - # Create hook provider to capture tool result - hook_provider = StructuredContentHookProvider() - - # Set up MCP client for echo server - stdio_mcp_client = MCPClient( - lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/mcp/echo_server.py"])) - ) - - with stdio_mcp_client: - # Create agent with MCP tools and hook provider - agent = Agent(tools=stdio_mcp_client.list_tools_sync(), hooks=[hook_provider]) - - # Test structured content functionality - test_data = "HOOKS_TEST_DATA" - agent(f"Use the echo_with_structured_content tool to echo: {test_data}") - - # Verify hook captured the tool result - assert hook_provider.captured_result is not None - result = hook_provider.captured_result - - # Verify basic result structure - assert result["status"] == "success" - assert len(result["content"]) == 1 - - # Verify structured content is present and correct - assert "structuredContent" in result - assert result["structuredContent"] == {"echoed": test_data, "message_length": 15} - - # Verify text content matches structured content - text_content = json.loads(result["content"][0]["text"]) - assert text_content == {"echoed": test_data, "message_length": 15} From 4468e97a1d10a8051a0e4dd610222b79743bc3b2 Mon Sep 17 00:00:00 2001 From: Dean Schmigelski Date: Wed, 17 Dec 2025 11:09:04 -0500 Subject: [PATCH 3/3] fix: update metadata test --- tests/strands/tools/mcp/test_mcp_client.py | 48 ++-------------------- 1 file changed, 4 insertions(+), 44 deletions(-) diff --git a/tests/strands/tools/mcp/test_mcp_client.py b/tests/strands/tools/mcp/test_mcp_client.py index e402d823f..f5040de1b 100644 --- a/tests/strands/tools/mcp/test_mcp_client.py +++ b/tests/strands/tools/mcp/test_mcp_client.py @@ -752,53 +752,13 @@ async def test_handle_error_message_non_exception(): await client._handle_error_message("normal message") -def test_call_tool_sync_with_meta_field(mock_transport, mock_session): - """Test that call_tool_sync correctly handles meta field.""" - mock_content = MCPTextContent(type="text", text="Test message") - meta_data = {"tokenUsage": {"inputTokens": 100, "outputTokens": 50}, "executionTime": 1.5} - mock_session.call_tool.return_value = MCPCallToolResult(isError=False, content=[mock_content], meta=meta_data) - - with MCPClient(mock_transport["transport_callable"]) as client: - result = client.call_tool_sync(tool_use_id="test-123", name="test_tool", arguments={"param": "value"}) - - mock_session.call_tool.assert_called_once_with("test_tool", {"param": "value"}, None) - - assert result["status"] == "success" - assert result["toolUseId"] == "test-123" - assert len(result["content"]) == 1 - assert result["content"][0]["text"] == "Test message" - assert "meta" in result - assert result["meta"] == meta_data - assert result["meta"]["tokenUsage"]["inputTokens"] == 100 - assert result["meta"]["tokenUsage"]["outputTokens"] == 50 - assert result["meta"]["executionTime"] == 1.5 - - -def test_call_tool_sync_without_meta_field(mock_transport, mock_session): - """Test that call_tool_sync works correctly when no meta field is provided.""" - mock_content = MCPTextContent(type="text", text="Test message") - mock_session.call_tool.return_value = MCPCallToolResult( - isError=False, - content=[mock_content], - ) - - with MCPClient(mock_transport["transport_callable"]) as client: - result = client.call_tool_sync(tool_use_id="test-123", name="test_tool", arguments={"param": "value"}) - - assert result["status"] == "success" - assert result["toolUseId"] == "test-123" - assert len(result["content"]) == 1 - assert result["content"][0]["text"] == "Test message" - assert result.get("meta") is None - - def test_call_tool_sync_with_meta_and_structured_content(mock_transport, mock_session): """Test that call_tool_sync correctly handles both meta and structuredContent fields.""" mock_content = MCPTextContent(type="text", text="Test message") - meta_data = {"tokenUsage": {"inputTokens": 100, "outputTokens": 50}} + metadata = {"tokenUsage": {"inputTokens": 100, "outputTokens": 50}} structured_content = {"result": 42, "status": "completed"} mock_session.call_tool.return_value = MCPCallToolResult( - isError=False, content=[mock_content], meta=meta_data, structuredContent=structured_content + isError=False, content=[mock_content], _meta=metadata, structuredContent=structured_content ) with MCPClient(mock_transport["transport_callable"]) as client: @@ -808,7 +768,7 @@ def test_call_tool_sync_with_meta_and_structured_content(mock_transport, mock_se assert result["status"] == "success" assert result["toolUseId"] == "test-123" - assert "meta" in result - assert result["meta"] == meta_data + assert "metadata" in result + assert result["metadata"] == metadata assert "structuredContent" in result assert result["structuredContent"] == structured_content