Python: feat(mcp): add full _meta field support for CallToolResult objects (#2286)

* feat(mcp): add full _meta field support for CallToolResult objects

- Extract and preserve complete _meta field from MCP CallToolResult responses
- Merge metadata into additional_properties of converted content items
- Handle isError field for proper error state integration
- Support arbitrary metadata like token usage, costs, and performance metrics
- Maintain backward compatibility with existing tool execution workflows
- Add comprehensive test coverage for all metadata scenarios including edge cases
- Update documentation with metadata handling examples and patterns

Fixes protocol compliance violation where _meta fields were being dropped,
enables proper monitoring and cost tracking of MCP tool usage.

* Update python/packages/core/agent_framework/_mcp.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Clarify MCP _meta field test to use generic example metadata

- Updated test_mcp_call_tool_result_with_meta_arbitrary_data to use arbitrary metadata fields
- Added comments to emphasize that _meta structure is server-specific and not standardized

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Bryan Ostdiek
2025-11-21 03:43:24 -05:00
committed by GitHub
Unverified
parent a7a0660cac
commit d18ce24bf8
3 changed files with 258 additions and 2 deletions
+46 -2
View File
@@ -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(
+208
View File
@@ -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 @@ async def test_local_mcp_server_load_prompts():
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."""