Skip to content

Commit f29179b

Browse files
bokelleyclaude
andcommitted
fix: handle Pydantic TextContent objects in MCP response parser
The MCP SDK returns Pydantic TextContent objects, not plain dicts. The response_parser.py was using .get() method which only works on dicts, causing AttributeError: 'TextContent' object has no attribute 'get'. This change adds a helper function that handles both dict and Pydantic object access patterns, ensuring compatibility with both the MCP SDK's actual return types and the test suite's mock dicts. Tests added for: - Pydantic TextContent objects - Mixed dict and Pydantic content - Empty Pydantic text content handling Fixes regression introduced in v1.0.3 where parsing logic was added that assumed dict objects. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent f665405 commit f29179b

File tree

2 files changed

+72
-6
lines changed

2 files changed

+72
-6
lines changed

src/adcp/utils/response_parser.py

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

1515

16-
def parse_mcp_content(content: list[dict[str, Any]], response_type: type[T]) -> T:
16+
def parse_mcp_content(content: list[dict[str, Any] | 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.
25+
2326
For AdCP, we expect JSON data in text content items.
2427
2528
Args:
26-
content: MCP content array
29+
content: MCP content array (list of dicts or Pydantic objects)
2730
response_type: Expected Pydantic model type
2831
2932
Returns:
@@ -35,10 +38,19 @@ def parse_mcp_content(content: list[dict[str, Any]], response_type: type[T]) ->
3538
if not content:
3639
raise ValueError("Empty MCP content array")
3740

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+
3848
# Look for text content items that might contain JSON
3949
for item in content:
40-
if item.get("type") == "text":
41-
text = item.get("text", "")
50+
item_type = get_field(item, "type")
51+
52+
if item_type == "text":
53+
text = get_field(item, "text", "")
4254
if not text:
4355
continue
4456

@@ -55,7 +67,7 @@ def parse_mcp_content(content: list[dict[str, Any]], response_type: type[T]) ->
5567
f"MCP content doesn't match expected schema {response_type.__name__}: {e}"
5668
)
5769
raise ValueError(f"MCP response doesn't match expected schema: {e}") from e
58-
elif item.get("type") == "resource":
70+
elif item_type == "resource":
5971
# Resource content might have structured data
6072
try:
6173
return response_type.model_validate(item)
@@ -69,9 +81,12 @@ def parse_mcp_content(content: list[dict[str, Any]], response_type: type[T]) ->
6981
if len(content_preview) > 500:
7082
content_preview = content_preview[:500] + "..."
7183

84+
# Extract types for error message
85+
content_types = [get_field(item, "type") for item in content]
86+
7287
raise ValueError(
7388
f"No valid {response_type.__name__} data found in MCP content. "
74-
f"Content types: {[item.get('type') for item in content]}. "
89+
f"Content types: {content_types}. "
7590
f"Content preview:\n{content_preview}"
7691
)
7792

tests/test_response_parser.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ 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+
2128
class TestParseMCPContent:
2229
"""Tests for parse_mcp_content function."""
2330

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

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+
95146

96147
class TestParseJSONOrText:
97148
"""Tests for parse_json_or_text function."""

0 commit comments

Comments
 (0)