Skip to content

Commit 7d2a521

Browse files
bokelleyclaude
andcommitted
refactor: move MCP serialization to protocol boundary
This refactor addresses the architectural issue identified in code review: the response parser was handling both parsing AND protocol translation, violating single responsibility principle. Changes: 1. Added _serialize_mcp_content() to MCPAdapter to convert Pydantic objects to dicts at the protocol boundary 2. Simplified parse_mcp_content() to only handle plain dicts 3. Removed get_field() helper function (no longer needed) 4. Removed Pydantic mock tests from response_parser tests 5. Added serialization tests to protocol adapter tests Benefits: - Clean separation: adapters translate, parsers parse - Simpler type signatures: list[dict[str, Any]] instead of | Any - Tests use plain dicts (no MCP SDK dependency in parser tests) - More obvious code (removed clever helper function) - Follows adapter pattern correctly The fix maintains backward compatibility while establishing proper architectural boundaries for future MCP content types (images, resources). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent f29179b commit 7d2a521

File tree

4 files changed

+103
-73
lines changed

4 files changed

+103
-73
lines changed

src/adcp/protocols/mcp.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,40 @@ async def _get_session(self) -> ClientSession:
186186
else:
187187
raise ValueError(f"Unsupported transport scheme: {parsed.scheme}")
188188

189+
def _serialize_mcp_content(self, content: list[Any]) -> list[dict[str, Any]]:
190+
"""
191+
Convert MCP SDK content objects to plain dicts.
192+
193+
The MCP SDK returns Pydantic objects (TextContent, ImageContent, etc.)
194+
but the rest of the ADCP client expects protocol-agnostic dicts.
195+
This method handles the translation at the protocol boundary.
196+
197+
Args:
198+
content: List of MCP content items (may be dicts or Pydantic objects)
199+
200+
Returns:
201+
List of plain dicts representing the content
202+
"""
203+
result = []
204+
for item in content:
205+
# Already a dict, pass through
206+
if isinstance(item, dict):
207+
result.append(item)
208+
# Pydantic v2 model with model_dump()
209+
elif hasattr(item, "model_dump"):
210+
result.append(item.model_dump())
211+
# Pydantic v1 model with dict()
212+
elif hasattr(item, "dict") and callable(item.dict):
213+
result.append(item.dict()) # type: ignore[attr-defined]
214+
# Fallback: try to access __dict__
215+
elif hasattr(item, "__dict__"):
216+
result.append(dict(item.__dict__))
217+
# Last resort: serialize as unknown type
218+
else:
219+
logger.warning(f"Unknown MCP content type: {type(item)}, serializing as string")
220+
result.append({"type": "unknown", "data": str(item)})
221+
return result
222+
189223
async def _call_mcp_tool(self, tool_name: str, params: dict[str, Any]) -> TaskResult[Any]:
190224
"""Call a tool using MCP protocol."""
191225
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
205239
# Call the tool using MCP client session
206240
result = await session.call_tool(tool_name, params)
207241

242+
# Serialize MCP SDK types to plain dicts at protocol boundary
243+
serialized_content = self._serialize_mcp_content(result.content)
244+
208245
if self.agent_config.debug and start_time:
209246
duration_ms = (time.time() - start_time) * 1000
210247
debug_info = DebugInfo(
211248
request=debug_request,
212249
response={
213-
"content": result.content,
250+
"content": serialized_content,
214251
"is_error": result.isError if hasattr(result, "isError") else False,
215252
},
216253
duration_ms=duration_ms,
@@ -220,7 +257,7 @@ async def _call_mcp_tool(self, tool_name: str, params: dict[str, Any]) -> TaskRe
220257
# For AdCP, we expect the data in the content
221258
return TaskResult[Any](
222259
status=TaskStatus.COMPLETED,
223-
data=result.content,
260+
data=serialized_content,
224261
success=True,
225262
debug_info=debug_info,
226263
)

src/adcp/utils/response_parser.py

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,20 @@
1313
T = TypeVar("T", bound=BaseModel)
1414

1515

16-
def parse_mcp_content(content: list[dict[str, Any] | Any], response_type: type[T]) -> T:
16+
def parse_mcp_content(content: list[dict[str, Any]], response_type: type[T]) -> T:
1717
"""
1818
Parse MCP content array into structured response type.
1919
2020
MCP tools return content as a list of content items:
2121
[{"type": "text", "text": "..."}, {"type": "resource", ...}]
2222
23-
Content items may be either plain dicts or Pydantic objects (TextContent, etc.)
24-
depending on the MCP SDK version and usage pattern.
23+
The MCP adapter is responsible for serializing MCP SDK Pydantic objects
24+
to plain dicts before calling this function.
2525
2626
For AdCP, we expect JSON data in text content items.
2727
2828
Args:
29-
content: MCP content array (list of dicts or Pydantic objects)
29+
content: MCP content array (list of plain dicts)
3030
response_type: Expected Pydantic model type
3131
3232
Returns:
@@ -38,19 +38,10 @@ def parse_mcp_content(content: list[dict[str, Any] | Any], response_type: type[T
3838
if not content:
3939
raise ValueError("Empty MCP content array")
4040

41-
# Helper to get field value from dict or object
42-
def get_field(item: Any, field: str, default: Any = None) -> Any:
43-
"""Get field from dict or Pydantic object."""
44-
if isinstance(item, dict):
45-
return item.get(field, default)
46-
return getattr(item, field, default)
47-
4841
# Look for text content items that might contain JSON
4942
for item in content:
50-
item_type = get_field(item, "type")
51-
52-
if item_type == "text":
53-
text = get_field(item, "text", "")
43+
if item.get("type") == "text":
44+
text = item.get("text", "")
5445
if not text:
5546
continue
5647

@@ -67,7 +58,7 @@ def get_field(item: Any, field: str, default: Any = None) -> Any:
6758
f"MCP content doesn't match expected schema {response_type.__name__}: {e}"
6859
)
6960
raise ValueError(f"MCP response doesn't match expected schema: {e}") from e
70-
elif item_type == "resource":
61+
elif item.get("type") == "resource":
7162
# Resource content might have structured data
7263
try:
7364
return response_type.model_validate(item)
@@ -81,12 +72,9 @@ def get_field(item: Any, field: str, default: Any = None) -> Any:
8172
if len(content_preview) > 500:
8273
content_preview = content_preview[:500] + "..."
8374

84-
# Extract types for error message
85-
content_types = [get_field(item, "type") for item in content]
86-
8775
raise ValueError(
8876
f"No valid {response_type.__name__} data found in MCP content. "
89-
f"Content types: {content_types}. "
77+
f"Content types: {[item.get('type') for item in content]}. "
9078
f"Content preview:\n{content_preview}"
9179
)
9280

tests/test_protocols.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,3 +240,59 @@ async def test_close_session(self, mcp_config):
240240
mock_exit_stack.aclose.assert_called_once()
241241
assert adapter._exit_stack is None
242242
assert adapter._session is None
243+
244+
def test_serialize_mcp_content_with_dicts(self, mcp_config):
245+
"""Test serializing MCP content that's already dicts."""
246+
adapter = MCPAdapter(mcp_config)
247+
248+
content = [
249+
{"type": "text", "text": "Hello"},
250+
{"type": "resource", "uri": "file://test.txt"},
251+
]
252+
253+
result = adapter._serialize_mcp_content(content)
254+
255+
assert result == content # Pass through unchanged
256+
assert len(result) == 2
257+
258+
def test_serialize_mcp_content_with_pydantic_v2(self, mcp_config):
259+
"""Test serializing MCP content with Pydantic v2 objects."""
260+
from pydantic import BaseModel
261+
262+
adapter = MCPAdapter(mcp_config)
263+
264+
class MockTextContent(BaseModel):
265+
type: str
266+
text: str
267+
268+
content = [
269+
MockTextContent(type="text", text="Pydantic v2"),
270+
]
271+
272+
result = adapter._serialize_mcp_content(content)
273+
274+
assert len(result) == 1
275+
assert result[0] == {"type": "text", "text": "Pydantic v2"}
276+
assert isinstance(result[0], dict)
277+
278+
def test_serialize_mcp_content_mixed(self, mcp_config):
279+
"""Test serializing mixed MCP content (dicts and Pydantic objects)."""
280+
from pydantic import BaseModel
281+
282+
adapter = MCPAdapter(mcp_config)
283+
284+
class MockTextContent(BaseModel):
285+
type: str
286+
text: str
287+
288+
content = [
289+
{"type": "text", "text": "Plain dict"},
290+
MockTextContent(type="text", text="Pydantic object"),
291+
]
292+
293+
result = adapter._serialize_mcp_content(content)
294+
295+
assert len(result) == 2
296+
assert result[0] == {"type": "text", "text": "Plain dict"}
297+
assert result[1] == {"type": "text", "text": "Pydantic object"}
298+
assert all(isinstance(item, dict) for item in result)

tests/test_response_parser.py

Lines changed: 0 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,6 @@ class SampleResponse(BaseModel):
1818
items: list[str] = Field(default_factory=list)
1919

2020

21-
class MockTextContent(BaseModel):
22-
"""Mock MCP TextContent object for testing."""
23-
24-
type: str = "text"
25-
text: str
26-
27-
2821
class TestParseMCPContent:
2922
"""Tests for parse_mcp_content function."""
3023

@@ -99,50 +92,6 @@ def test_empty_text_content_skipped(self):
9992
result = parse_mcp_content(content, SampleResponse)
10093
assert result.message == "Found"
10194

102-
def test_parse_pydantic_text_content(self):
103-
"""Test parsing MCP Pydantic TextContent objects."""
104-
content = [
105-
MockTextContent(
106-
type="text",
107-
text=json.dumps({"message": "Pydantic", "count": 99, "items": ["x", "y"]}),
108-
)
109-
]
110-
111-
result = parse_mcp_content(content, SampleResponse)
112-
113-
assert isinstance(result, SampleResponse)
114-
assert result.message == "Pydantic"
115-
assert result.count == 99
116-
assert result.items == ["x", "y"]
117-
118-
def test_parse_mixed_dict_and_pydantic_content(self):
119-
"""Test parsing MCP content with both dicts and Pydantic objects."""
120-
content = [
121-
{"type": "text", "text": "Not JSON"},
122-
MockTextContent(
123-
type="text",
124-
text=json.dumps({"message": "Mixed", "count": 15}),
125-
),
126-
]
127-
128-
result = parse_mcp_content(content, SampleResponse)
129-
130-
assert result.message == "Mixed"
131-
assert result.count == 15
132-
133-
def test_parse_pydantic_empty_text_skipped(self):
134-
"""Test that empty Pydantic text content is skipped."""
135-
content = [
136-
MockTextContent(type="text", text=""),
137-
MockTextContent(
138-
type="text",
139-
text=json.dumps({"message": "Skip", "count": 7}),
140-
),
141-
]
142-
143-
result = parse_mcp_content(content, SampleResponse)
144-
assert result.message == "Skip"
145-
14695

14796
class TestParseJSONOrText:
14897
"""Tests for parse_json_or_text function."""

0 commit comments

Comments
 (0)