Skip to content

Commit 52956bc

Browse files
bokelleyclaude
andauthored
fix: handle MCP error responses without structuredContent (#34)
* fix: handle MCP error responses without structuredContent gracefully When an MCP tool returns an error (isError=True), it may only populate the content field with an error message and leave structuredContent as None. The SDK now handles this case gracefully by: 1. Checking isError flag before requiring structuredContent 2. Extracting error message from content field for error responses 3. Returning a proper TaskResult with success=False and the error message 4. Only requiring structuredContent for successful responses (isError=False) This allows the CLI to display agent-provided error messages instead of raising SDK validation errors. Updated tests to cover both cases: - Error responses without structuredContent (now valid) - Success responses without structuredContent (still invalid) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: split long error message to satisfy line length limit Split the structuredContent error message across multiple lines to keep each line under 100 characters for linter compliance. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: explicitly set isError=False in test mocks for successful responses MagicMock objects return truthy MagicMock instances for any attribute access, so mock_result.isError was returning a truthy value even when not explicitly set. This caused the code to incorrectly treat successful test responses as errors. Now explicitly setting isError=False for all successful response mocks. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5202141 commit 52956bc

File tree

2 files changed

+65
-19
lines changed

2 files changed

+65
-19
lines changed

src/adcp/protocols/mcp.py

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -245,24 +245,12 @@ async def _call_mcp_tool(self, tool_name: str, params: dict[str, Any]) -> TaskRe
245245
# Call the tool using MCP client session
246246
result = await session.call_tool(tool_name, params)
247247

248-
# This SDK requires MCP tools to return structuredContent
249-
# The content field may contain human-readable messages but the actual
250-
# response data must be in structuredContent
251-
if not hasattr(result, "structuredContent") or result.structuredContent is None:
252-
raise ValueError(
253-
f"MCP tool {tool_name} did not return structuredContent. "
254-
f"This SDK requires MCP tools to provide structured responses. "
255-
f"Got content: {result.content if hasattr(result, 'content') else 'none'}"
256-
)
248+
# Check if this is an error response
249+
is_error = hasattr(result, "isError") and result.isError
257250

258-
# Extract the structured data (required)
259-
data_to_return = result.structuredContent
260-
261-
# Extract human-readable message from content (optional)
262-
# This is typically a status message like "Found 42 creative formats"
251+
# Extract human-readable message from content
263252
message_text = None
264253
if hasattr(result, "content") and result.content:
265-
# Serialize content using the same method used for backward compatibility
266254
serialized_content = self._serialize_mcp_content(result.content)
267255
if isinstance(serialized_content, list):
268256
for item in serialized_content:
@@ -271,14 +259,48 @@ async def _call_mcp_tool(self, tool_name: str, params: dict[str, Any]) -> TaskRe
271259
message_text = item["text"]
272260
break
273261

262+
# Handle error responses
263+
if is_error:
264+
# For error responses, structuredContent is optional
265+
# Use the error message from content as the error
266+
error_message = message_text or "Tool execution failed"
267+
if self.agent_config.debug and start_time:
268+
duration_ms = (time.time() - start_time) * 1000
269+
debug_info = DebugInfo(
270+
request=debug_request,
271+
response={
272+
"error": error_message,
273+
"is_error": True,
274+
},
275+
duration_ms=duration_ms,
276+
)
277+
return TaskResult[Any](
278+
status=TaskStatus.FAILED,
279+
error=error_message,
280+
success=False,
281+
debug_info=debug_info,
282+
)
283+
284+
# For successful responses, structuredContent is required
285+
if not hasattr(result, "structuredContent") or result.structuredContent is None:
286+
raise ValueError(
287+
f"MCP tool {tool_name} did not return structuredContent. "
288+
f"This SDK requires MCP tools to provide structured responses "
289+
f"for successful calls. "
290+
f"Got content: {result.content if hasattr(result, 'content') else 'none'}"
291+
)
292+
293+
# Extract the structured data (required for success)
294+
data_to_return = result.structuredContent
295+
274296
if self.agent_config.debug and start_time:
275297
duration_ms = (time.time() - start_time) * 1000
276298
debug_info = DebugInfo(
277299
request=debug_request,
278300
response={
279301
"data": data_to_return,
280302
"message": message_text,
281-
"is_error": result.isError if hasattr(result, "isError") else False,
303+
"is_error": False,
282304
},
283305
duration_ms=duration_ms,
284306
)

tests/test_protocols.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ async def test_call_tool_success(self, mcp_config):
164164
# Mock MCP result with structuredContent (required for AdCP)
165165
mock_result.content = [{"type": "text", "text": "Success"}]
166166
mock_result.structuredContent = {"products": [{"id": "prod1"}]}
167+
mock_result.isError = False
167168
mock_session.call_tool.return_value = mock_result
168169

169170
with patch.object(adapter, "_get_session", return_value=mock_session):
@@ -193,6 +194,7 @@ async def test_call_tool_with_structured_content(self, mcp_config):
193194
# Mock MCP result with structuredContent (preferred over content)
194195
mock_result.content = [{"type": "text", "text": "Found 42 creative formats"}]
195196
mock_result.structuredContent = {"formats": [{"id": "format1"}, {"id": "format2"}]}
197+
mock_result.isError = False
196198
mock_session.call_tool.return_value = mock_result
197199

198200
with patch.object(adapter, "_get_session", return_value=mock_session):
@@ -207,24 +209,46 @@ async def test_call_tool_with_structured_content(self, mcp_config):
207209

208210
@pytest.mark.asyncio
209211
async def test_call_tool_missing_structured_content(self, mcp_config):
210-
"""Test tool call fails when structuredContent is missing."""
212+
"""Test tool call fails when structuredContent is missing on successful response."""
211213
adapter = MCPAdapter(mcp_config)
212214

213215
mock_session = AsyncMock()
214216
mock_result = MagicMock()
215-
# Mock MCP result WITHOUT structuredContent (invalid for AdCP)
217+
# Mock MCP result WITHOUT structuredContent and isError=False (invalid)
216218
mock_result.content = [{"type": "text", "text": "Success"}]
217219
mock_result.structuredContent = None
220+
mock_result.isError = False
218221
mock_session.call_tool.return_value = mock_result
219222

220223
with patch.object(adapter, "_get_session", return_value=mock_session):
221224
result = await adapter._call_mcp_tool("get_products", {"brief": "test"})
222225

223-
# Verify error handling for missing structuredContent
226+
# Verify error handling for missing structuredContent on success
224227
assert result.success is False
225228
assert result.status == TaskStatus.FAILED
226229
assert "did not return structuredContent" in result.error
227230

231+
@pytest.mark.asyncio
232+
async def test_call_tool_error_without_structured_content(self, mcp_config):
233+
"""Test tool call handles error responses without structuredContent gracefully."""
234+
adapter = MCPAdapter(mcp_config)
235+
236+
mock_session = AsyncMock()
237+
mock_result = MagicMock()
238+
# Mock MCP error response WITHOUT structuredContent (valid for errors)
239+
mock_result.content = [{"type": "text", "text": "brand_manifest must provide brand information"}]
240+
mock_result.structuredContent = None
241+
mock_result.isError = True
242+
mock_session.call_tool.return_value = mock_result
243+
244+
with patch.object(adapter, "_get_session", return_value=mock_session):
245+
result = await adapter._call_mcp_tool("get_products", {"brief": "test"})
246+
247+
# Verify error is handled gracefully
248+
assert result.success is False
249+
assert result.status == TaskStatus.FAILED
250+
assert result.error == "brand_manifest must provide brand information"
251+
228252
@pytest.mark.asyncio
229253
async def test_call_tool_error(self, mcp_config):
230254
"""Test tool call error via MCP."""

0 commit comments

Comments
 (0)