Skip to content

Commit 696a3aa

Browse files
bokelleyclaude
andauthored
fix: return both message and structured content in MCP responses (#16)
* fix: return both message and structured content in MCP responses Updated Python SDK to match JavaScript client behavior: MCP responses now return both the human-readable message (from content array) and structured data (from structuredContent). Added message field to TaskResult, updated MCP adapter to extract both fields, and improved CLI output with message display and --json mode for scripting. All tests pass with new message extraction coverage. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: split long line to meet linting requirements --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5a849a9 commit 696a3aa

File tree

5 files changed

+104
-30
lines changed

5 files changed

+104
-30
lines changed

src/adcp/__main__.py

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,37 +23,38 @@
2323

2424
def print_json(data: Any) -> None:
2525
"""Print data as JSON."""
26-
print(json.dumps(data, indent=2, default=str))
26+
from pydantic import BaseModel
27+
28+
# Handle Pydantic models
29+
if isinstance(data, BaseModel):
30+
print(data.model_dump_json(indent=2, exclude_none=True))
31+
else:
32+
print(json.dumps(data, indent=2, default=str))
2733

2834

2935
def print_result(result: Any, json_output: bool = False) -> None:
3036
"""Print result in formatted or JSON mode."""
3137
if json_output:
32-
print_json(
33-
{
34-
"status": result.status.value,
35-
"success": result.success,
36-
"data": result.data,
37-
"error": result.error,
38-
"metadata": result.metadata,
39-
"debug_info": (
40-
{
41-
"request": result.debug_info.request,
42-
"response": result.debug_info.response,
43-
"duration_ms": result.debug_info.duration_ms,
44-
}
45-
if result.debug_info
46-
else None
47-
),
48-
}
49-
)
38+
# Match JavaScript client: output just the data for scripting
39+
if result.success and result.data:
40+
print_json(result.data)
41+
else:
42+
# On error, output error info
43+
print_json({"error": result.error, "success": False})
5044
else:
51-
print(f"\nStatus: {result.status.value}")
45+
# Pretty output with message and data (like JavaScript client)
5246
if result.success:
47+
print("\nSUCCESS\n")
48+
# Show protocol message if available
49+
if hasattr(result, "message") and result.message:
50+
print("Protocol Message:")
51+
print(result.message)
52+
print()
5353
if result.data:
54-
print("\nResult:")
54+
print("Response:")
5555
print_json(result.data)
5656
else:
57+
print("\nFAILED\n")
5758
print(f"Error: {result.error}")
5859

5960

src/adcp/protocols/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def _parse_response(self, raw_result: TaskResult[Any], response_type: type[T]) -
4949
return TaskResult[T](
5050
status=raw_result.status,
5151
data=None,
52+
message=raw_result.message,
5253
success=False,
5354
error=raw_result.error or "No data returned from adapter",
5455
metadata=raw_result.metadata,
@@ -66,6 +67,7 @@ def _parse_response(self, raw_result: TaskResult[Any], response_type: type[T]) -
6667
return TaskResult[T](
6768
status=raw_result.status,
6869
data=parsed_data,
70+
message=raw_result.message, # Preserve human-readable message from protocol
6971
success=raw_result.success,
7072
error=raw_result.error,
7173
metadata=raw_result.metadata,
@@ -76,6 +78,7 @@ def _parse_response(self, raw_result: TaskResult[Any], response_type: type[T]) -
7678
return TaskResult[T](
7779
status=TaskStatus.FAILED,
7880
error=f"Failed to parse response: {e}",
81+
message=raw_result.message,
7982
success=False,
8083
debug_info=raw_result.debug_info,
8184
)

src/adcp/protocols/mcp.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -239,25 +239,49 @@ async def _call_mcp_tool(self, tool_name: str, params: dict[str, Any]) -> TaskRe
239239
# Call the tool using MCP client session
240240
result = await session.call_tool(tool_name, params)
241241

242-
# Serialize MCP SDK types to plain dicts at protocol boundary
243-
serialized_content = self._serialize_mcp_content(result.content)
242+
# This SDK requires MCP tools to return structuredContent
243+
# The content field may contain human-readable messages but the actual
244+
# response data must be in structuredContent
245+
if not hasattr(result, "structuredContent") or result.structuredContent is None:
246+
raise ValueError(
247+
f"MCP tool {tool_name} did not return structuredContent. "
248+
f"This SDK requires MCP tools to provide structured responses. "
249+
f"Got content: {result.content if hasattr(result, 'content') else 'none'}"
250+
)
251+
252+
# Extract the structured data (required)
253+
data_to_return = result.structuredContent
254+
255+
# Extract human-readable message from content (optional)
256+
# This is typically a status message like "Found 42 creative formats"
257+
message_text = None
258+
if hasattr(result, "content") and result.content:
259+
# Serialize content using the same method used for backward compatibility
260+
serialized_content = self._serialize_mcp_content(result.content)
261+
if isinstance(serialized_content, list):
262+
for item in serialized_content:
263+
is_text = isinstance(item, dict) and item.get("type") == "text"
264+
if is_text and item.get("text"):
265+
message_text = item["text"]
266+
break
244267

245268
if self.agent_config.debug and start_time:
246269
duration_ms = (time.time() - start_time) * 1000
247270
debug_info = DebugInfo(
248271
request=debug_request,
249272
response={
250-
"content": serialized_content,
273+
"data": data_to_return,
274+
"message": message_text,
251275
"is_error": result.isError if hasattr(result, "isError") else False,
252276
},
253277
duration_ms=duration_ms,
254278
)
255279

256-
# MCP tool results contain a list of content items
257-
# For AdCP, we expect the data in the content
280+
# Return both the structured data and the human-readable message
258281
return TaskResult[Any](
259282
status=TaskStatus.COMPLETED,
260-
data=serialized_content,
283+
data=data_to_return,
284+
message=message_text,
261285
success=True,
262286
debug_info=debug_info,
263287
)

src/adcp/types/core.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ class TaskResult(BaseModel, Generic[T]):
127127

128128
status: TaskStatus
129129
data: T | None = None
130+
message: str | None = None # Human-readable message from agent (e.g., MCP content text)
130131
submitted: SubmittedInfo | None = None
131132
needs_input: NeedsInputInfo | None = None
132133
error: str | None = None

tests/test_protocols.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,13 +155,15 @@ class TestMCPAdapter:
155155

156156
@pytest.mark.asyncio
157157
async def test_call_tool_success(self, mcp_config):
158-
"""Test successful tool call via MCP."""
158+
"""Test successful tool call via MCP with proper structuredContent."""
159159
adapter = MCPAdapter(mcp_config)
160160

161161
# Mock MCP session
162162
mock_session = AsyncMock()
163163
mock_result = MagicMock()
164+
# Mock MCP result with structuredContent (required for AdCP)
164165
mock_result.content = [{"type": "text", "text": "Success"}]
166+
mock_result.structuredContent = {"products": [{"id": "prod1"}]}
165167
mock_session.call_tool.return_value = mock_result
166168

167169
with patch.object(adapter, "_get_session", return_value=mock_session):
@@ -175,10 +177,53 @@ async def test_call_tool_success(self, mcp_config):
175177
assert call_args[0][0] == "get_products"
176178
assert call_args[0][1] == {"brief": "test"}
177179

178-
# Verify result parsing
180+
# Verify result uses structuredContent
181+
assert result.success is True
182+
assert result.status == TaskStatus.COMPLETED
183+
assert result.data == {"products": [{"id": "prod1"}]}
184+
185+
@pytest.mark.asyncio
186+
async def test_call_tool_with_structured_content(self, mcp_config):
187+
"""Test successful tool call via MCP with structuredContent field."""
188+
adapter = MCPAdapter(mcp_config)
189+
190+
# Mock MCP session
191+
mock_session = AsyncMock()
192+
mock_result = MagicMock()
193+
# Mock MCP result with structuredContent (preferred over content)
194+
mock_result.content = [{"type": "text", "text": "Found 42 creative formats"}]
195+
mock_result.structuredContent = {"formats": [{"id": "format1"}, {"id": "format2"}]}
196+
mock_session.call_tool.return_value = mock_result
197+
198+
with patch.object(adapter, "_get_session", return_value=mock_session):
199+
result = await adapter._call_mcp_tool("list_creative_formats", {})
200+
201+
# Verify result uses structuredContent, not content array
179202
assert result.success is True
180203
assert result.status == TaskStatus.COMPLETED
181-
assert result.data == [{"type": "text", "text": "Success"}]
204+
assert result.data == {"formats": [{"id": "format1"}, {"id": "format2"}]}
205+
# Verify message extraction from content array
206+
assert result.message == "Found 42 creative formats"
207+
208+
@pytest.mark.asyncio
209+
async def test_call_tool_missing_structured_content(self, mcp_config):
210+
"""Test tool call fails when structuredContent is missing."""
211+
adapter = MCPAdapter(mcp_config)
212+
213+
mock_session = AsyncMock()
214+
mock_result = MagicMock()
215+
# Mock MCP result WITHOUT structuredContent (invalid for AdCP)
216+
mock_result.content = [{"type": "text", "text": "Success"}]
217+
mock_result.structuredContent = None
218+
mock_session.call_tool.return_value = mock_result
219+
220+
with patch.object(adapter, "_get_session", return_value=mock_session):
221+
result = await adapter._call_mcp_tool("get_products", {"brief": "test"})
222+
223+
# Verify error handling for missing structuredContent
224+
assert result.success is False
225+
assert result.status == TaskStatus.FAILED
226+
assert "did not return structuredContent" in result.error
182227

183228
@pytest.mark.asyncio
184229
async def test_call_tool_error(self, mcp_config):

0 commit comments

Comments
 (0)