From 56a0727b4310ca0aca72ee0b267804094dc4ea48 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 7 Nov 2025 05:38:33 -0500 Subject: [PATCH 1/2] fix: return both message and structured content in MCP responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/adcp/__main__.py | 43 ++++++++++++++++---------------- src/adcp/protocols/base.py | 3 +++ src/adcp/protocols/mcp.py | 35 +++++++++++++++++++++----- src/adcp/types/core.py | 1 + tests/test_protocols.py | 51 +++++++++++++++++++++++++++++++++++--- 5 files changed, 103 insertions(+), 30 deletions(-) diff --git a/src/adcp/__main__.py b/src/adcp/__main__.py index 0577d50..8a87d44 100644 --- a/src/adcp/__main__.py +++ b/src/adcp/__main__.py @@ -23,37 +23,38 @@ def print_json(data: Any) -> None: """Print data as JSON.""" - print(json.dumps(data, indent=2, default=str)) + from pydantic import BaseModel + + # Handle Pydantic models + if isinstance(data, BaseModel): + print(data.model_dump_json(indent=2, exclude_none=True)) + else: + print(json.dumps(data, indent=2, default=str)) def print_result(result: Any, json_output: bool = False) -> None: """Print result in formatted or JSON mode.""" if json_output: - print_json( - { - "status": result.status.value, - "success": result.success, - "data": result.data, - "error": result.error, - "metadata": result.metadata, - "debug_info": ( - { - "request": result.debug_info.request, - "response": result.debug_info.response, - "duration_ms": result.debug_info.duration_ms, - } - if result.debug_info - else None - ), - } - ) + # Match JavaScript client: output just the data for scripting + if result.success and result.data: + print_json(result.data) + else: + # On error, output error info + print_json({"error": result.error, "success": False}) else: - print(f"\nStatus: {result.status.value}") + # Pretty output with message and data (like JavaScript client) if result.success: + print("\nSUCCESS\n") + # Show protocol message if available + if hasattr(result, "message") and result.message: + print("Protocol Message:") + print(result.message) + print() if result.data: - print("\nResult:") + print("Response:") print_json(result.data) else: + print("\nFAILED\n") print(f"Error: {result.error}") diff --git a/src/adcp/protocols/base.py b/src/adcp/protocols/base.py index 18a29e7..4e35e9d 100644 --- a/src/adcp/protocols/base.py +++ b/src/adcp/protocols/base.py @@ -49,6 +49,7 @@ def _parse_response(self, raw_result: TaskResult[Any], response_type: type[T]) - return TaskResult[T]( status=raw_result.status, data=None, + message=raw_result.message, success=False, error=raw_result.error or "No data returned from adapter", metadata=raw_result.metadata, @@ -66,6 +67,7 @@ def _parse_response(self, raw_result: TaskResult[Any], response_type: type[T]) - return TaskResult[T]( status=raw_result.status, data=parsed_data, + message=raw_result.message, # Preserve human-readable message from protocol success=raw_result.success, error=raw_result.error, metadata=raw_result.metadata, @@ -76,6 +78,7 @@ def _parse_response(self, raw_result: TaskResult[Any], response_type: type[T]) - return TaskResult[T]( status=TaskStatus.FAILED, error=f"Failed to parse response: {e}", + message=raw_result.message, success=False, debug_info=raw_result.debug_info, ) diff --git a/src/adcp/protocols/mcp.py b/src/adcp/protocols/mcp.py index 4fc6ce2..e848941 100644 --- a/src/adcp/protocols/mcp.py +++ b/src/adcp/protocols/mcp.py @@ -239,25 +239,48 @@ 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) + # This SDK requires MCP tools to return structuredContent + # The content field may contain human-readable messages but the actual + # response data must be in structuredContent + if not hasattr(result, "structuredContent") or result.structuredContent is None: + raise ValueError( + f"MCP tool {tool_name} did not return structuredContent. " + f"This SDK requires MCP tools to provide structured responses. " + f"Got content: {result.content if hasattr(result, 'content') else 'none'}" + ) + + # Extract the structured data (required) + data_to_return = result.structuredContent + + # Extract human-readable message from content (optional) + # This is typically a status message like "Found 42 creative formats" + message_text = None + if hasattr(result, "content") and result.content: + # Serialize content using the same method used for backward compatibility + serialized_content = self._serialize_mcp_content(result.content) + if isinstance(serialized_content, list): + for item in serialized_content: + if isinstance(item, dict) and item.get("type") == "text" and item.get("text"): + message_text = item["text"] + break if self.agent_config.debug and start_time: duration_ms = (time.time() - start_time) * 1000 debug_info = DebugInfo( request=debug_request, response={ - "content": serialized_content, + "data": data_to_return, + "message": message_text, "is_error": result.isError if hasattr(result, "isError") else False, }, duration_ms=duration_ms, ) - # MCP tool results contain a list of content items - # For AdCP, we expect the data in the content + # Return both the structured data and the human-readable message return TaskResult[Any]( status=TaskStatus.COMPLETED, - data=serialized_content, + data=data_to_return, + message=message_text, success=True, debug_info=debug_info, ) diff --git a/src/adcp/types/core.py b/src/adcp/types/core.py index d1a3798..319a6b4 100644 --- a/src/adcp/types/core.py +++ b/src/adcp/types/core.py @@ -127,6 +127,7 @@ class TaskResult(BaseModel, Generic[T]): status: TaskStatus data: T | None = None + message: str | None = None # Human-readable message from agent (e.g., MCP content text) submitted: SubmittedInfo | None = None needs_input: NeedsInputInfo | None = None error: str | None = None diff --git a/tests/test_protocols.py b/tests/test_protocols.py index b10e801..7f1fc4e 100644 --- a/tests/test_protocols.py +++ b/tests/test_protocols.py @@ -155,13 +155,15 @@ class TestMCPAdapter: @pytest.mark.asyncio async def test_call_tool_success(self, mcp_config): - """Test successful tool call via MCP.""" + """Test successful tool call via MCP with proper structuredContent.""" adapter = MCPAdapter(mcp_config) # Mock MCP session mock_session = AsyncMock() mock_result = MagicMock() + # Mock MCP result with structuredContent (required for AdCP) mock_result.content = [{"type": "text", "text": "Success"}] + mock_result.structuredContent = {"products": [{"id": "prod1"}]} mock_session.call_tool.return_value = mock_result with patch.object(adapter, "_get_session", return_value=mock_session): @@ -175,10 +177,53 @@ async def test_call_tool_success(self, mcp_config): assert call_args[0][0] == "get_products" assert call_args[0][1] == {"brief": "test"} - # Verify result parsing + # Verify result uses structuredContent + assert result.success is True + assert result.status == TaskStatus.COMPLETED + assert result.data == {"products": [{"id": "prod1"}]} + + @pytest.mark.asyncio + async def test_call_tool_with_structured_content(self, mcp_config): + """Test successful tool call via MCP with structuredContent field.""" + adapter = MCPAdapter(mcp_config) + + # Mock MCP session + mock_session = AsyncMock() + mock_result = MagicMock() + # Mock MCP result with structuredContent (preferred over content) + mock_result.content = [{"type": "text", "text": "Found 42 creative formats"}] + mock_result.structuredContent = {"formats": [{"id": "format1"}, {"id": "format2"}]} + mock_session.call_tool.return_value = mock_result + + with patch.object(adapter, "_get_session", return_value=mock_session): + result = await adapter._call_mcp_tool("list_creative_formats", {}) + + # Verify result uses structuredContent, not content array assert result.success is True assert result.status == TaskStatus.COMPLETED - assert result.data == [{"type": "text", "text": "Success"}] + assert result.data == {"formats": [{"id": "format1"}, {"id": "format2"}]} + # Verify message extraction from content array + assert result.message == "Found 42 creative formats" + + @pytest.mark.asyncio + async def test_call_tool_missing_structured_content(self, mcp_config): + """Test tool call fails when structuredContent is missing.""" + adapter = MCPAdapter(mcp_config) + + mock_session = AsyncMock() + mock_result = MagicMock() + # Mock MCP result WITHOUT structuredContent (invalid for AdCP) + mock_result.content = [{"type": "text", "text": "Success"}] + mock_result.structuredContent = None + mock_session.call_tool.return_value = mock_result + + with patch.object(adapter, "_get_session", return_value=mock_session): + result = await adapter._call_mcp_tool("get_products", {"brief": "test"}) + + # Verify error handling for missing structuredContent + assert result.success is False + assert result.status == TaskStatus.FAILED + assert "did not return structuredContent" in result.error @pytest.mark.asyncio async def test_call_tool_error(self, mcp_config): From ecf9115d5219a643ece10bdce1cb29d72f3ee2dd Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 7 Nov 2025 05:41:07 -0500 Subject: [PATCH 2/2] fix: split long line to meet linting requirements --- src/adcp/protocols/mcp.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/adcp/protocols/mcp.py b/src/adcp/protocols/mcp.py index e848941..1dfd177 100644 --- a/src/adcp/protocols/mcp.py +++ b/src/adcp/protocols/mcp.py @@ -260,7 +260,8 @@ async def _call_mcp_tool(self, tool_name: str, params: dict[str, Any]) -> TaskRe serialized_content = self._serialize_mcp_content(result.content) if isinstance(serialized_content, list): for item in serialized_content: - if isinstance(item, dict) and item.get("type") == "text" and item.get("text"): + is_text = isinstance(item, dict) and item.get("type") == "text" + if is_text and item.get("text"): message_text = item["text"] break