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
41 changes: 39 additions & 2 deletions src/adcp/protocols/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,40 @@ async def _get_session(self) -> ClientSession:
else:
raise ValueError(f"Unsupported transport scheme: {parsed.scheme}")

def _serialize_mcp_content(self, content: list[Any]) -> list[dict[str, Any]]:
"""
Convert MCP SDK content objects to plain dicts.

The MCP SDK returns Pydantic objects (TextContent, ImageContent, etc.)
but the rest of the ADCP client expects protocol-agnostic dicts.
This method handles the translation at the protocol boundary.

Args:
content: List of MCP content items (may be dicts or Pydantic objects)

Returns:
List of plain dicts representing the content
"""
result = []
for item in content:
# Already a dict, pass through
if isinstance(item, dict):
result.append(item)
# Pydantic v2 model with model_dump()
elif hasattr(item, "model_dump"):
result.append(item.model_dump())
# Pydantic v1 model with dict()
elif hasattr(item, "dict") and callable(item.dict):
result.append(item.dict())
# Fallback: try to access __dict__
elif hasattr(item, "__dict__"):
result.append(dict(item.__dict__))
# Last resort: serialize as unknown type
else:
logger.warning(f"Unknown MCP content type: {type(item)}, serializing as string")
result.append({"type": "unknown", "data": str(item)})
return result

async def _call_mcp_tool(self, tool_name: str, params: dict[str, Any]) -> TaskResult[Any]:
"""Call a tool using MCP protocol."""
start_time = time.time() if self.agent_config.debug else None
Expand All @@ -205,12 +239,15 @@ async def _call_mcp_tool(self, tool_name: str, params: dict[str, Any]) -> TaskRe
# Call the tool using MCP client session
result = await session.call_tool(tool_name, params)

# Serialize MCP SDK types to plain dicts at protocol boundary
serialized_content = self._serialize_mcp_content(result.content)

if self.agent_config.debug and start_time:
duration_ms = (time.time() - start_time) * 1000
debug_info = DebugInfo(
request=debug_request,
response={
"content": result.content,
"content": serialized_content,
"is_error": result.isError if hasattr(result, "isError") else False,
},
duration_ms=duration_ms,
Expand All @@ -220,7 +257,7 @@ async def _call_mcp_tool(self, tool_name: str, params: dict[str, Any]) -> TaskRe
# For AdCP, we expect the data in the content
return TaskResult[Any](
status=TaskStatus.COMPLETED,
data=result.content,
data=serialized_content,
success=True,
debug_info=debug_info,
)
Expand Down
5 changes: 4 additions & 1 deletion src/adcp/utils/response_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ def parse_mcp_content(content: list[dict[str, Any]], response_type: type[T]) ->
MCP tools return content as a list of content items:
[{"type": "text", "text": "..."}, {"type": "resource", ...}]

The MCP adapter is responsible for serializing MCP SDK Pydantic objects
to plain dicts before calling this function.

For AdCP, we expect JSON data in text content items.

Args:
content: MCP content array
content: MCP content array (list of plain dicts)
response_type: Expected Pydantic model type

Returns:
Expand Down
56 changes: 56 additions & 0 deletions tests/test_protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,3 +240,59 @@ async def test_close_session(self, mcp_config):
mock_exit_stack.aclose.assert_called_once()
assert adapter._exit_stack is None
assert adapter._session is None

def test_serialize_mcp_content_with_dicts(self, mcp_config):
"""Test serializing MCP content that's already dicts."""
adapter = MCPAdapter(mcp_config)

content = [
{"type": "text", "text": "Hello"},
{"type": "resource", "uri": "file://test.txt"},
]

result = adapter._serialize_mcp_content(content)

assert result == content # Pass through unchanged
assert len(result) == 2

def test_serialize_mcp_content_with_pydantic_v2(self, mcp_config):
"""Test serializing MCP content with Pydantic v2 objects."""
from pydantic import BaseModel

adapter = MCPAdapter(mcp_config)

class MockTextContent(BaseModel):
type: str
text: str

content = [
MockTextContent(type="text", text="Pydantic v2"),
]

result = adapter._serialize_mcp_content(content)

assert len(result) == 1
assert result[0] == {"type": "text", "text": "Pydantic v2"}
assert isinstance(result[0], dict)

def test_serialize_mcp_content_mixed(self, mcp_config):
"""Test serializing mixed MCP content (dicts and Pydantic objects)."""
from pydantic import BaseModel

adapter = MCPAdapter(mcp_config)

class MockTextContent(BaseModel):
type: str
text: str

content = [
{"type": "text", "text": "Plain dict"},
MockTextContent(type="text", text="Pydantic object"),
]

result = adapter._serialize_mcp_content(content)

assert len(result) == 2
assert result[0] == {"type": "text", "text": "Plain dict"}
assert result[1] == {"type": "text", "text": "Pydantic object"}
assert all(isinstance(item, dict) for item in result)