Skip to content

fix: propagate is_error flag from SDK MCP tool results#717

Merged
qing-ant merged 4 commits intomainfrom
fix/is-error-flag-propagation
Mar 25, 2026
Merged

fix: propagate is_error flag from SDK MCP tool results#717
qing-ant merged 4 commits intomainfrom
fix/is-error-flag-propagation

Conversation

@qing-ant
Copy link
Copy Markdown
Contributor

Summary

The call_tool handler in create_sdk_mcp_server() was discarding the is_error field from tool result dicts. It returned content as a bare list, which the MCP framework then wrapped in a CallToolResult(isError=False) regardless of what the tool returned.

This meant tools that returned {"content": [...], "is_error": True} would have their error status silently dropped — Claude would see the error text but treat it as a successful result, breaking error recovery logic.

Fix

Return a CallToolResult directly instead of a bare content list. The MCP framework already checks isinstance(results, CallToolResult) and passes it through as-is (confirmed in MCP server source).

Before:

return content  # bare list, framework always wraps with isError=False

After:

return CallToolResult(content=content, isError=result.get("is_error", False))

The companion code path in _internal/query.py (lines 503-504) already correctly propagates is_error, confirming this was an oversight in the MCP server handler path.

Test

Added test_is_error_flag_propagated to tests/test_sdk_mcp_integration.py:

  • Verifies is_error: True is propagated through the full handler chain
  • Verifies successful results default to isError=False

All 312 existing tests continue to pass.

Fixes #564

The call_tool handler in create_sdk_mcp_server() was discarding the
is_error field from tool result dicts, always returning content as a
bare list which the MCP framework wrapped with isError=False.

Return a CallToolResult directly so the is_error flag is preserved.
The MCP framework already checks isinstance(results, CallToolResult)
and passes it through as-is.

Fixes #564
Comment thread src/claude_agent_sdk/__init__.py
…ror in JSONRPC bridge

Widen the content list type annotation in create_sdk_mcp_server to match
CallToolResult's expected parameter type, fixing a mypy arg-type error
caused by list invariance.

Also fix the JSONRPC bridge in query.py to use camelCase 'isError' instead
of snake_case 'is_error' when checking and setting the error flag on
CallToolResult, which uses camelCase Pydantic field names.
…ror in JSONRPC bridge

Widen the content list type annotation in create_sdk_mcp_server to match
CallToolResult's expected parameter type, fixing a mypy arg-type error
caused by list invariance.

Also fix the JSONRPC bridge in query.py to use camelCase 'isError' instead
of snake_case 'is_error', matching the Pydantic field name on CallToolResult.
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the isError propagation fix and casing correction in query.py both look correct.

Extended reasoning...

Overview

This PR fixes is_error flag propagation from SDK MCP tool results across two code paths: (1) __init__.py now returns a CallToolResult directly instead of a bare content list, preserving the isError flag, and (2) query.py fixes the casing from is_error to isError in the JSONRPC bridge hasattr check and response dict key. A new test verifies the direct handler path.

Security risks

No security concerns. The changes are limited to error flag propagation in internal MCP tool handling — no auth, crypto, or permissions code is affected.

Level of scrutiny

This is a small, focused bug fix touching well-scoped internal plumbing. The pattern is straightforward: return a typed result object instead of a bare list, and fix an attribute name casing mismatch. The author addressed the casing bug I flagged in a prior review.

Other factors

The remaining bug report is a minor test coverage nit — the new test exercises the direct handler path but not the JSONRPC bridge path. This is a valid observation but does not indicate a code correctness issue; the actual code changes in both paths are correct. The PR includes 312 passing existing tests.

Comment thread tests/test_sdk_mcp_integration.py
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the camelCase fix in query.py addresses my earlier feedback, and the core fix in init.py is straightforward.

Extended reasoning...

Overview

This PR touches three files to fix is_error propagation from SDK MCP tool results:

  • __init__.py: Returns CallToolResult(content=content, isError=...) instead of a bare content list, so the MCP framework preserves the error flag.
  • query.py: Fixes the JSONRPC bridge to use camelCase isError (matching the Pydantic field name) instead of snake_case is_error — this was the bug I reported in a previous review, now resolved.
  • test_sdk_mcp_integration.py: Adds test_is_error_flag_propagated covering the direct handler path.

Security risks

None. The change only affects error flag propagation in tool results — no auth, crypto, or permissions code is touched.

Level of scrutiny

Low-to-medium. This is a small, focused bug fix with clear intent. The content type annotation expansion (AudioContent, ResourceLink, EmbeddedResource) aligns with what CallToolResult accepts. The query.py fix is a mechanical camelCase correction.

Other factors

My previous review flagged the snake_case bug in query.py lines 503-504, which has been addressed in the current diff (inline comment resolved). The remaining suggestion about adding a JSONRPC bridge path test is a minor coverage gap, not a correctness issue — both code paths are correct. No outstanding reviewer comments remain unaddressed.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the isError propagation fix is correct in both code paths, and the prior snake_case bug in query.py has been addressed.

Extended reasoning...

Overview

This PR fixes is_error flag propagation across two code paths: the direct call_tool handler in __init__.py (now returns CallToolResult directly instead of a bare content list) and the JSONRPC bridge in query.py (fixed snake_case is_error to camelCase isError to match the Pydantic field name). A new test covers the direct handler path.

Security risks

No security concerns. The change is about error flag propagation for MCP tool results — no auth, crypto, or permissions code is touched.

Level of scrutiny

This is a straightforward bug fix with clear intent. The scope is small (3 files, ~30 lines of meaningful change), the fix follows existing patterns, and the correctness is easy to verify by inspection. The content type annotation broadening (AudioContent, ResourceLink, EmbeddedResource) aligns with MCP library types and is a safe improvement.

Other factors

My previous red bug report (snake_case is_error in query.py) has been addressed in this revision. Both inline comments are marked resolved. The test coverage suggestion (testing the JSONRPC bridge path) was a minor improvement suggestion, not a blocker. The existing test_error_handling and new test_is_error_flag_propagated tests provide reasonable coverage for the fix.

@qing-ant
Copy link
Copy Markdown
Contributor Author

E2E Test Results for PR #717

Summary

Tested the is_error flag propagation fix end-to-end. The fix works correctly.

Unit Tests

All 9 SDK MCP integration tests pass, including the new test_is_error_flag_propagated test:

tests/test_sdk_mcp_integration.py::test_sdk_mcp_server_handlers PASSED
tests/test_sdk_mcp_integration.py::test_tool_creation PASSED
tests/test_sdk_mcp_integration.py::test_error_handling PASSED
tests/test_sdk_mcp_integration.py::test_is_error_flag_propagated PASSED
tests/test_sdk_mcp_integration.py::test_mixed_servers PASSED
tests/test_sdk_mcp_integration.py::test_server_creation PASSED
tests/test_sdk_mcp_integration.py::test_image_content_support PASSED
tests/test_sdk_mcp_integration.py::test_tool_annotations PASSED
tests/test_sdk_mcp_integration.py::test_tool_annotations_in_jsonrpc PASSED

9 passed in 0.53s

E2E Test

Created an SDK MCP server with two tools:

  • success_tool: returns normal text content
  • failing_tool: returns {"is_error": True, "content": [{"type": "text", "text": "Something went wrong: invalid input ..."}]}

Asked Claude to call both tools and summarize results. Claude correctly distinguished between them:

success_tool - Status: Success - Return value: "Success! Processed input: test123"

failing_tool - Status: Error - Error message: "Something went wrong: invalid input 'test123'" - The tool returned an error-typed MCP response (i.e., the isError flag was set in the response). This is a deliberate, handled error from the tool itself.

What the fix does

Two changes make this work:

  1. __init__.py line 349: CallToolResult(content=content, isError=result.get("is_error", False)) -- reads the is_error flag from the tool handler's return dict and passes it to the MCP CallToolResult

  2. query.py lines 502-504: The JSONRPC bridge now checks result.root.isError and propagates "isError": True in the JSONRPC response sent back to the CLI, so Claude sees the error flag

Previously, isError was always hardcoded to False (or not set), so Claude had no way to know a tool returned an error via the MCP protocol.

Verdict

PASS -- both unit tests and e2e test confirm the fix works as intended.

@qing-ant qing-ant enabled auto-merge (squash) March 25, 2026 20:30
@qing-ant qing-ant merged commit 582cdf7 into main Mar 25, 2026
10 checks passed
@qing-ant qing-ant deleted the fix/is-error-flag-propagation branch March 25, 2026 21:06
qing-ant added a commit that referenced this pull request Apr 28, 2026
create_sdk_mcp_server tool handlers return CallToolResult objects since
v0.1.51 (PR #717). mcp<1.19.0 cannot handle this return type from
@server.call_tool() decorated functions — it falls through the iterable
branch, fails pydantic validation, and silently swallows the tool output
into an error result.

Users upgrading claude-agent-sdk in an existing env with old mcp would
see in-process SDK MCP tools run but return validation error blobs
instead of actual results.
qing-ant added a commit that referenced this pull request Apr 28, 2026
## Problem

`create_sdk_mcp_server` tool handlers have returned `CallToolResult`
objects since v0.1.51 (PR #717). The `mcp` package's
`@server.call_tool()` decorator only accepts `CallToolResult` returns
from `mcp>=1.19.0`. With older versions it falls through the iterable
branch (pydantic models iterate as `(field, value)` tuples), fails
validation, and swallows the result into `_make_error_result(str(e))`.

Effect: in-process SDK MCP tools run their handler, but the model
receives a ~5KB pydantic "20 validation errors for CallToolResult" blob
with `is_error=True` instead of the actual output. Silent data loss.

This only bites users who `pip install -U claude-agent-sdk` in an
existing env — fresh installs pull latest `mcp` and work fine.
stdio/HTTP/SSE MCP servers are unaffected.

## Fix

Bump the floor: `mcp>=0.1.0` → `mcp>=1.19.0`.

## Verification

Boundary-tested with a direct `request_handlers[CallToolRequest]` repro:

| mcp version | result |
|---|---|
| 1.12.4 | ❌ `isError: True`, validation error blob |
| 1.18.0 | ❌ `isError: True`, validation error blob |
| **1.19.0** | ✅ `isError: False`, tool output returned |
| 1.27.0 | ✅ `isError: False`, tool output returned |

- `pip install -e . 'mcp==1.12.4'` → `ResolutionImpossible` (pin
enforced)
- Full `ClaudeSDKClient` e2e with `create_sdk_mcp_server`: tool result
reaches the model
- `pytest`: 755 passed, 3 skipped · `mypy`: clean · `ruff`: clean

Reported internally.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The tool result does not take into account the is_error flag.

2 participants