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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions