Initial Checks
Description
Bug: task_result_handler.py serializes None optional fields as JSON null,
breaking Node SDK Zod validation
What the spec says
The MCP spec defines TextContent with optional fields:
interface TextContent {
type: "text";
text: string;
annotations?: Annotations; // optional — may be absent
_meta?: Record<string, unknown>; // optional — may be absent
}
"Optional" in the spec means the field may be absent from the JSON. Both SDKs
agree on this:
- Python SDK's TextContentSchema defines annotations and _meta with
Optional[...] = None
- Node SDK's TextContentSchema defines them with .optional() — accepts
undefined (absent) but not null
What the Python SDK does
The Python SDK has two serialization paths for CallToolResult:
- Normal responses (_send_response in session.py): uses
model_dump(by_alias=True, mode="json", exclude_none=True). This correctly omits
None fields from the JSON. Works fine.
- Task result delivery (handle in task_result_handler.py, line 131): uses
result.model_dump(by_alias=True) without exclude_none=True. This serializes
None fields as explicit JSON null:
task_result_handler.py line 131
result_data = result.model_dump(by_alias=True) # ← missing exclude_none=True
Produces:
{
"content": [{
"type": "text",
"text": "counted to 20",
"annotations": null,
"_meta": null
}],
"isError": false
}
What breaks
The Node SDK's requestStream polls tasks/result when a task completes, then
parses the response with CallToolResultSchema. The Zod discriminated union for
content blocks uses:
annotations: AnnotationsSchema.optional() // accepts undefined, rejects null
_meta: z.record(z.string(), z.unknown()).optional() // accepts undefined,
rejects null
null ≠ undefined in Zod. The parse fails:
"expected object, received null" at path ["annotations"]
"expected record, received null" at path ["_meta"]
This kills the task result stream. The runtime falls back to polling tasks/get
- tasks/result, but by then the result may already be consumed.
Fix
One line — add exclude_none=True to the model_dump call in
task_result_handler.py:
Before (line 131):
result_data = result.model_dump(by_alias=True)
After:
result_data = result.model_dump(by_alias=True, exclude_none=True)
This matches the pattern used everywhere else in the SDK (_send_response,
send_notification).
Reproduction
- Python MCP server with a task-aware tool that returns
CallToolResult(content=[TextContent(type="text", text="hello")])
- Node MCP client connects and calls the tool with task: {}
- Task completes → client calls tasks/result → Zod parse fails on annotations:
null
Example Code
Python & MCP Python SDK
- mcp (Python): 1.27.0
- @modelcontextprotocol/sdk (Node): 1.29.0
Initial Checks
Description
Bug: task_result_handler.py serializes None optional fields as JSON null,
breaking Node SDK Zod validation
What the spec says
The MCP spec defines TextContent with optional fields:
interface TextContent {
type: "text";
text: string;
annotations?: Annotations; // optional — may be absent
_meta?: Record<string, unknown>; // optional — may be absent
}
"Optional" in the spec means the field may be absent from the JSON. Both SDKs
agree on this:
Optional[...] = None
undefined (absent) but not null
What the Python SDK does
The Python SDK has two serialization paths for CallToolResult:
model_dump(by_alias=True, mode="json", exclude_none=True). This correctly omits
None fields from the JSON. Works fine.
result.model_dump(by_alias=True) without exclude_none=True. This serializes
None fields as explicit JSON null:
task_result_handler.py line 131
result_data = result.model_dump(by_alias=True) # ← missing exclude_none=True
Produces:
{
"content": [{
"type": "text",
"text": "counted to 20",
"annotations": null,
"_meta": null
}],
"isError": false
}
What breaks
The Node SDK's requestStream polls tasks/result when a task completes, then
parses the response with CallToolResultSchema. The Zod discriminated union for
content blocks uses:
annotations: AnnotationsSchema.optional() // accepts undefined, rejects null
_meta: z.record(z.string(), z.unknown()).optional() // accepts undefined,
rejects null
null ≠ undefined in Zod. The parse fails:
"expected object, received null" at path ["annotations"]
"expected record, received null" at path ["_meta"]
This kills the task result stream. The runtime falls back to polling tasks/get
Fix
One line — add exclude_none=True to the model_dump call in
task_result_handler.py:
Before (line 131):
result_data = result.model_dump(by_alias=True)
After:
result_data = result.model_dump(by_alias=True, exclude_none=True)
This matches the pattern used everywhere else in the SDK (_send_response,
send_notification).
Reproduction
CallToolResult(content=[TextContent(type="text", text="hello")])
null
Example Code
Python & MCP Python SDK