mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
a7a0660cac
commit
d18ce24bf8
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ OpenAI Chat Client with Local MCP Example
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user