Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 100 additions & 41 deletions python/packages/core/agent_framework/_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import re
import sys
from abc import abstractmethod
from collections.abc import Collection
from collections.abc import Collection, Sequence
from contextlib import AsyncExitStack, _AsyncGeneratorContextManager # type: ignore
from datetime import timedelta
from functools import partial
Expand All @@ -22,7 +22,16 @@
from pydantic import BaseModel, Field, create_model

from ._tools import AIFunction, HostedMCPSpecificApproval
from ._types import ChatMessage, Contents, DataContent, Role, TextContent, UriContent
from ._types import (
ChatMessage,
Contents,
DataContent,
FunctionCallContent,
FunctionResultContent,
Role,
TextContent,
UriContent,
)
from .exceptions import ToolException, ToolExecutionException

if sys.version_info >= (3, 11):
Expand Down Expand Up @@ -61,7 +70,7 @@ def _mcp_prompt_message_to_chat_message(
"""Convert a MCP container type to a Agent Framework type."""
return ChatMessage(
role=Role(value=mcp_type.role),
contents=[_mcp_type_to_ai_content(mcp_type.content)],
contents=_mcp_type_to_ai_content(mcp_type.content),
raw_representation=mcp_type,
)

Expand All @@ -87,8 +96,7 @@ def _mcp_call_tool_result_to_ai_contents(
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)
meta_data = mcp_type.meta

# Prepare merged metadata once if present
merged_meta_props = None
Expand All @@ -104,53 +112,104 @@ def _mcp_call_tool_result_to_ai_contents(
# Convert each content item and merge metadata
result_contents = []
for item in mcp_type.content:
content = _mcp_type_to_ai_content(item)
contents = _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)

for content in contents:
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.extend(contents)
return result_contents


def _mcp_type_to_ai_content(
mcp_type: types.ImageContent | types.TextContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink,
) -> Contents:
mcp_type: types.ImageContent
| types.TextContent
| types.AudioContent
| types.EmbeddedResource
| types.ResourceLink
| types.ToolUseContent
| types.ToolResultContent
| Sequence[
types.ImageContent
| types.TextContent
| types.AudioContent
| types.EmbeddedResource
| types.ResourceLink
| types.ToolUseContent
| types.ToolResultContent
],
) -> list[Contents]:
"""Convert a MCP type to a Agent Framework type."""
match mcp_type:
case types.TextContent():
return TextContent(text=mcp_type.text, raw_representation=mcp_type)
case types.ImageContent() | types.AudioContent():
return DataContent(
uri=mcp_type.data,
media_type=mcp_type.mimeType,
raw_representation=mcp_type,
)
case types.ResourceLink():
return UriContent(
uri=str(mcp_type.uri),
media_type=mcp_type.mimeType or "application/json",
raw_representation=mcp_type,
)
case _:
match mcp_type.resource:
case types.TextResourceContents():
return TextContent(
text=mcp_type.resource.text,
mcp_types = mcp_type if isinstance(mcp_type, Sequence) else [mcp_type]
return_types: list[Contents] = []
for mcp_type in mcp_types:
match mcp_type:
case types.TextContent():
return_types.append(TextContent(text=mcp_type.text, raw_representation=mcp_type))
case types.ImageContent() | types.AudioContent():
return_types.append(
DataContent(
uri=mcp_type.data,
media_type=mcp_type.mimeType,
raw_representation=mcp_type,
additional_properties=(mcp_type.annotations.model_dump() if mcp_type.annotations else None),
)
case types.BlobResourceContents():
return DataContent(
uri=mcp_type.resource.blob,
media_type=mcp_type.resource.mimeType,
)
case types.ResourceLink():
return_types.append(
UriContent(
uri=str(mcp_type.uri),
media_type=mcp_type.mimeType or "application/json",
raw_representation=mcp_type,
additional_properties=(mcp_type.annotations.model_dump() if mcp_type.annotations else None),
)
)
case types.ToolUseContent():
return_types.append(
FunctionCallContent(
call_id=mcp_type.id,
name=mcp_type.name,
arguments=mcp_type.input,
raw_representation=mcp_type,
)
)
case types.ToolResultContent():
return_types.append(
FunctionResultContent(
call_id=mcp_type.toolUseId,
result=_mcp_type_to_ai_content(mcp_type.content)
if mcp_type.content
else mcp_type.structuredContent,
exception=Exception() if mcp_type.isError else None,
raw_representation=mcp_type,
)
)
case types.EmbeddedResource():
match mcp_type.resource:
case types.TextResourceContents():
return_types.append(
TextContent(
text=mcp_type.resource.text,
raw_representation=mcp_type,
additional_properties=(
mcp_type.annotations.model_dump() if mcp_type.annotations else None
),
)
)
case types.BlobResourceContents():
return_types.append(
DataContent(
uri=mcp_type.resource.blob,
media_type=mcp_type.resource.mimeType,
raw_representation=mcp_type,
additional_properties=(
mcp_type.annotations.model_dump() if mcp_type.annotations else None
),
)
)
return return_types


def _ai_content_to_mcp_types(
Expand Down
84 changes: 23 additions & 61 deletions python/packages/core/tests/core/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,10 @@ def test_mcp_call_tool_result_to_ai_contents():
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"}
mcp_result = types.CallToolResult(
content=[types.TextContent(type="text", text="Error occurred")],
_meta={"isError": True, "errorCode": "TOOL_ERROR", "errorMessage": "Tool execution failed"},
)

ai_contents = _mcp_call_tool_result_to_ai_contents(mcp_result)

Expand All @@ -115,15 +116,16 @@ def test_mcp_call_tool_result_with_meta_arbitrary_data():
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",
}
mcp_result = types.CallToolResult(
content=[types.TextContent(type="text", text="Success 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)

Expand All @@ -145,8 +147,7 @@ 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}
mcp_result = types.CallToolResult(content=[text_content], _meta={"newField": "newValue", "isError": False})

ai_contents = _mcp_call_tool_result_to_ai_contents(mcp_result)

Expand All @@ -159,30 +160,6 @@ def test_mcp_call_tool_result_with_meta_merging_existing_properties():
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")])
Expand All @@ -200,21 +177,6 @@ def test_mcp_call_tool_result_with_meta_none():
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
Expand Down Expand Up @@ -247,7 +209,7 @@ def test_mcp_call_tool_result_regression_successful_workflow():
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")
ai_content = _mcp_type_to_ai_content(mcp_content)
ai_content = _mcp_type_to_ai_content(mcp_content)[0]

assert isinstance(ai_content, TextContent)
assert ai_content.text == "Sample text"
Expand All @@ -257,7 +219,7 @@ def test_mcp_content_types_to_ai_content_text():
def test_mcp_content_types_to_ai_content_image():
"""Test conversion of MCP image content to AI content."""
mcp_content = types.ImageContent(type="image", data="data:image/jpeg;base64,abc", mimeType="image/jpeg")
ai_content = _mcp_type_to_ai_content(mcp_content)
ai_content = _mcp_type_to_ai_content(mcp_content)[0]

assert isinstance(ai_content, DataContent)
assert ai_content.uri == "data:image/jpeg;base64,abc"
Expand All @@ -268,7 +230,7 @@ def test_mcp_content_types_to_ai_content_image():
def test_mcp_content_types_to_ai_content_audio():
"""Test conversion of MCP audio content to AI content."""
mcp_content = types.AudioContent(type="audio", data="data:audio/wav;base64,def", mimeType="audio/wav")
ai_content = _mcp_type_to_ai_content(mcp_content)
ai_content = _mcp_type_to_ai_content(mcp_content)[0]

assert isinstance(ai_content, DataContent)
assert ai_content.uri == "data:audio/wav;base64,def"
Expand All @@ -284,7 +246,7 @@ def test_mcp_content_types_to_ai_content_resource_link():
name="test_resource",
mimeType="application/json",
)
ai_content = _mcp_type_to_ai_content(mcp_content)
ai_content = _mcp_type_to_ai_content(mcp_content)[0]

assert isinstance(ai_content, UriContent)
assert ai_content.uri == "https://example.com/resource"
Expand All @@ -300,7 +262,7 @@ def test_mcp_content_types_to_ai_content_embedded_resource_text():
text="Embedded text content",
)
mcp_content = types.EmbeddedResource(type="resource", resource=text_resource)
ai_content = _mcp_type_to_ai_content(mcp_content)
ai_content = _mcp_type_to_ai_content(mcp_content)[0]

assert isinstance(ai_content, TextContent)
assert ai_content.text == "Embedded text content"
Expand All @@ -316,7 +278,7 @@ def test_mcp_content_types_to_ai_content_embedded_resource_blob():
blob="data:application/octet-stream;base64,dGVzdCBkYXRh",
)
mcp_content = types.EmbeddedResource(type="resource", resource=blob_resource)
ai_content = _mcp_type_to_ai_content(mcp_content)
ai_content = _mcp_type_to_ai_content(mcp_content)[0]

assert isinstance(ai_content, DataContent)
assert ai_content.uri == "data:application/octet-stream;base64,dGVzdCBkYXRh"
Expand Down Expand Up @@ -650,9 +612,9 @@ async def connect(self):

# Create a CallToolResult with _meta field
tool_result = types.CallToolResult(
content=[types.TextContent(type="text", text="Tool executed with metadata")]
content=[types.TextContent(type="text", text="Tool executed with metadata")],
_meta={"executionTime": 1.5, "cost": {"usd": 0.002}, "isError": False, "toolVersion": "1.2.3"},
)
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)

Expand Down
2 changes: 1 addition & 1 deletion python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core[all]",
"agent-framework-core[all]==1.0.0b251120",
]

[dependency-groups]
Expand Down
Loading
Loading