diff --git a/src/adcp/protocols/mcp.py b/src/adcp/protocols/mcp.py index fa61a20e..4fc6ce21 100644 --- a/src/adcp/protocols/mcp.py +++ b/src/adcp/protocols/mcp.py @@ -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 @@ -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, @@ -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, ) diff --git a/src/adcp/utils/response_parser.py b/src/adcp/utils/response_parser.py index ad869299..cd60541e 100644 --- a/src/adcp/utils/response_parser.py +++ b/src/adcp/utils/response_parser.py @@ -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: diff --git a/tests/test_protocols.py b/tests/test_protocols.py index 7ffd8c70..b10e8013 100644 --- a/tests/test_protocols.py +++ b/tests/test_protocols.py @@ -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)