Skip to content

Bug: task_result_handler.py serializes None optional fields as JSON null, breaking Node SDK Zod validation #2539

@rynowak

Description

@rynowak

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:

  1. 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.
  2. 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

  1. Python MCP server with a task-aware tool that returns
    CallToolResult(content=[TextContent(type="text", text="hello")])
  2. Node MCP client connects and calls the tool with task: {}
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions