diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 66c96425c8..586ebd8df1 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -69,8 +69,52 @@ def _mcp_prompt_message_to_chat_message( def _mcp_call_tool_result_to_ai_contents( mcp_type: types.CallToolResult, ) -> list[Contents]: - """Convert a MCP container type to a Agent Framework type.""" - return [_mcp_type_to_ai_content(item) for item in mcp_type.content] + """Convert a MCP container type to a Agent Framework type. + + This function extracts the complete _meta field from CallToolResult objects + and merges all metadata into the additional_properties field of converted + content items. + + Note: The _meta field from CallToolResult is applied to ALL content items + in the result, as the Agent Framework's content model doesn't have a + result-level metadata container. This ensures metadata is preserved but + means it will be duplicated across multiple content items if present. + + Args: + mcp_type: The MCP CallToolResult object to convert. + + Returns: + A list of Agent Framework content items with metadata merged into + additional_properties. + """ + # Extract _meta field using getattr for compatibility + meta_data = getattr(mcp_type, "_meta", None) + + # Prepare merged metadata once if present + merged_meta_props = None + if meta_data: + merged_meta_props = {} + if hasattr(meta_data, "__dict__"): + merged_meta_props.update(meta_data.__dict__) + elif isinstance(meta_data, dict): + merged_meta_props.update(meta_data) + else: + merged_meta_props["_meta"] = meta_data + + # Convert each content item and merge metadata + result_contents = [] + for item in mcp_type.content: + content = _mcp_type_to_ai_content(item) + + if merged_meta_props: + existing_props = getattr(content, "additional_properties", None) or {} + # Merge with content-specific properties, letting content-specific props override + final_props = merged_meta_props.copy() + final_props.update(existing_props) + content.additional_properties = final_props + result_contents.append(content) + + return result_contents def _mcp_type_to_ai_content( diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 865e2ef484..34f4857dc2 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -88,6 +88,162 @@ def test_mcp_call_tool_result_to_ai_contents(): assert ai_contents[1].media_type == "image/png" +def test_mcp_call_tool_result_with_meta_error(): + """Test conversion from MCP tool result with _meta field containing isError=True.""" + # Create a mock CallToolResult with _meta field containing error information + mcp_result = types.CallToolResult(content=[types.TextContent(type="text", text="Error occurred")]) + # Simulate _meta field with isError=True + mcp_result._meta = {"isError": True, "errorCode": "TOOL_ERROR", "errorMessage": "Tool execution failed"} + + ai_contents = _mcp_call_tool_result_to_ai_contents(mcp_result) + + assert len(ai_contents) == 1 + assert isinstance(ai_contents[0], TextContent) + assert ai_contents[0].text == "Error occurred" + + # Check that _meta data is merged into additional_properties + assert ai_contents[0].additional_properties is not None + assert ai_contents[0].additional_properties["isError"] is True + assert ai_contents[0].additional_properties["errorCode"] == "TOOL_ERROR" + assert ai_contents[0].additional_properties["errorMessage"] == "Tool execution failed" + + +def test_mcp_call_tool_result_with_meta_arbitrary_data(): + """Test conversion from MCP tool result with _meta field containing arbitrary metadata. + + Note: The _meta field is optional and can contain any structure that a specific + MCP server chooses to provide. This test uses example metadata to verify that + whatever is provided gets preserved in additional_properties. + """ + mcp_result = types.CallToolResult(content=[types.TextContent(type="text", text="Success result")]) + # Example _meta field - different MCP servers may provide completely different structures + mcp_result._meta = { + "serverVersion": "2.1.0", + "executionId": "exec_abc123", + "metrics": {"responseTime": 1.25, "memoryUsed": "64MB"}, + "source": "example-mcp-server", + "customField": "arbitrary_value", + } + + ai_contents = _mcp_call_tool_result_to_ai_contents(mcp_result) + + assert len(ai_contents) == 1 + assert isinstance(ai_contents[0], TextContent) + assert ai_contents[0].text == "Success result" + + # Check that _meta data is preserved in additional_properties + props = ai_contents[0].additional_properties + assert props is not None + assert props["serverVersion"] == "2.1.0" + assert props["executionId"] == "exec_abc123" + assert props["metrics"] == {"responseTime": 1.25, "memoryUsed": "64MB"} + assert props["source"] == "example-mcp-server" + assert props["customField"] == "arbitrary_value" + + +def test_mcp_call_tool_result_with_meta_merging_existing_properties(): + """Test that _meta data merges correctly with existing additional_properties.""" + # Create content with existing additional_properties + text_content = types.TextContent(type="text", text="Test content") + mcp_result = types.CallToolResult(content=[text_content]) + mcp_result._meta = {"newField": "newValue", "isError": False} + + ai_contents = _mcp_call_tool_result_to_ai_contents(mcp_result) + + assert len(ai_contents) == 1 + content = ai_contents[0] + + # Check that _meta data is present in additional_properties + assert content.additional_properties is not None + assert content.additional_properties["newField"] == "newValue" + assert content.additional_properties["isError"] is False + + +def test_mcp_call_tool_result_with_meta_object_attributes(): + """Test conversion when _meta is an object with attributes rather than a dict.""" + + class MetaObject: + def __init__(self): + self.isError = True + self.requestId = "req-12345" + self.executionTime = 2.5 + + mcp_result = types.CallToolResult(content=[types.TextContent(type="text", text="Object meta test")]) + mcp_result._meta = MetaObject() + + ai_contents = _mcp_call_tool_result_to_ai_contents(mcp_result) + + assert len(ai_contents) == 1 + content = ai_contents[0] + + # Check that object attributes are extracted correctly + assert content.additional_properties is not None + assert content.additional_properties["isError"] is True + assert content.additional_properties["requestId"] == "req-12345" + assert content.additional_properties["executionTime"] == 2.5 + + +def test_mcp_call_tool_result_with_meta_none(): + """Test that missing _meta field is handled gracefully.""" + mcp_result = types.CallToolResult(content=[types.TextContent(type="text", text="No meta test")]) + # No _meta field set + + ai_contents = _mcp_call_tool_result_to_ai_contents(mcp_result) + + assert len(ai_contents) == 1 + assert isinstance(ai_contents[0], TextContent) + assert ai_contents[0].text == "No meta test" + + # Should handle gracefully when no _meta field exists + # additional_properties may be None or empty dict + props = ai_contents[0].additional_properties + assert props is None or props == {} + + +def test_mcp_call_tool_result_with_meta_non_dict_value(): + """Test conversion when _meta contains a non-dict value.""" + mcp_result = types.CallToolResult(content=[types.TextContent(type="text", text="Non-dict meta test")]) + mcp_result._meta = "simple string meta" + + ai_contents = _mcp_call_tool_result_to_ai_contents(mcp_result) + + assert len(ai_contents) == 1 + content = ai_contents[0] + + # Non-dict _meta should be stored under '_meta' key + assert content.additional_properties is not None + assert content.additional_properties["_meta"] == "simple string meta" + + +def test_mcp_call_tool_result_regression_successful_workflow(): + """Regression test to ensure existing successful workflows remain unchanged.""" + # Test the original successful workflow still works + mcp_result = types.CallToolResult( + content=[ + types.TextContent(type="text", text="Success message"), + types.ImageContent(type="image", data="data:image/jpeg;base64,abc123", mimeType="image/jpeg"), + ] + ) + + ai_contents = _mcp_call_tool_result_to_ai_contents(mcp_result) + + # Verify basic conversion still works correctly + assert len(ai_contents) == 2 + + text_content = ai_contents[0] + assert isinstance(text_content, TextContent) + assert text_content.text == "Success message" + + image_content = ai_contents[1] + assert isinstance(image_content, DataContent) + assert image_content.uri == "data:image/jpeg;base64,abc123" + assert image_content.media_type == "image/jpeg" + + # Should have no additional_properties when no _meta field + assert text_content.additional_properties is None or text_content.additional_properties == {} + assert image_content.additional_properties is None or image_content.additional_properties == {} + + def test_mcp_content_types_to_ai_content_text(): """Test conversion of MCP text content to AI content.""" mcp_content = types.TextContent(type="text", text="Sample text") @@ -440,6 +596,58 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: assert server.functions[0].name == "test_prompt" +async def test_mcp_tool_call_tool_with_meta_integration(): + """Test that call_tool method properly integrates with enhanced metadata extraction.""" + + class TestServer(MCPTool): + async def connect(self): + self.session = Mock(spec=ClientSession) + self.session.list_tools = AsyncMock( + return_value=types.ListToolsResult( + tools=[ + types.Tool( + name="test_tool", + description="Test tool", + inputSchema={ + "type": "object", + "properties": {"param": {"type": "string"}}, + "required": ["param"], + }, + ) + ] + ) + ) + + # Create a CallToolResult with _meta field + tool_result = types.CallToolResult( + content=[types.TextContent(type="text", text="Tool executed with metadata")] + ) + tool_result._meta = {"executionTime": 1.5, "cost": {"usd": 0.002}, "isError": False, "toolVersion": "1.2.3"} + + self.session.call_tool = AsyncMock(return_value=tool_result) + + def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: + return None + + server = TestServer(name="test_server") + async with server: + await server.load_tools() + func = server.functions[0] + result = await func.invoke(param="test_value") + + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].text == "Tool executed with metadata" + + # Verify that _meta data is present in additional_properties + props = result[0].additional_properties + assert props is not None + assert props["executionTime"] == 1.5 + assert props["cost"] == {"usd": 0.002} + assert props["isError"] is False + assert props["toolVersion"] == "1.2.3" + + async def test_local_mcp_server_function_execution(): """Test function execution through MCP server.""" diff --git a/python/samples/getting_started/agents/openai/openai_chat_client_with_local_mcp.py b/python/samples/getting_started/agents/openai/openai_chat_client_with_local_mcp.py index 07749339ed..8df65ad8fd 100644 --- a/python/samples/getting_started/agents/openai/openai_chat_client_with_local_mcp.py +++ b/python/samples/getting_started/agents/openai/openai_chat_client_with_local_mcp.py @@ -10,6 +10,10 @@ This sample demonstrates integrating Model Context Protocol (MCP) tools with OpenAI Chat Client for extended functionality and external service access. + +The Agent Framework now supports enhanced metadata extraction from MCP tool +results, including error states, token usage, costs, and other arbitrary +metadata through the _meta field of CallToolResult objects. """