Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 22 additions & 21 deletions src/adcp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")


Expand Down
3 changes: 3 additions & 0 deletions src/adcp/protocols/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
)
Expand Down
36 changes: 30 additions & 6 deletions src/adcp/protocols/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,25 +239,49 @@ 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:
is_text = isinstance(item, dict) and item.get("type") == "text"
if is_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,
)
Expand Down
1 change: 1 addition & 0 deletions src/adcp/types/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 48 additions & 3 deletions tests/test_protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down