From 53662c5bfa152c6e822e03f9d36c40e0e3f496fa Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Tue, 5 May 2026 13:19:59 +0530 Subject: [PATCH] fix: exclude None optional fields from task result serialization model_dump() without exclude_none=True emits null for optional fields (e.g. TextContent.annotations), breaking Node SDK Zod validation which marks those fields as optional/absent rather than nullable. Matches the exclude_none=True, mode="json" convention used throughout the rest of the session layer's serialization paths. Fixes #2539 --- .../experimental/task_result_handler.py | 6 +++-- .../tasks/server/test_task_result_handler.py | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/mcp/server/experimental/task_result_handler.py b/src/mcp/server/experimental/task_result_handler.py index b2268bc1c..3edecbfb6 100644 --- a/src/mcp/server/experimental/task_result_handler.py +++ b/src/mcp/server/experimental/task_result_handler.py @@ -114,9 +114,11 @@ async def handle( # The stored result contains the actual payload data # Per spec: tasks/result MUST include _meta with related-task metadata related_task = RelatedTaskMetadata(task_id=task_id) - related_task_meta: dict[str, Any] = {RELATED_TASK_METADATA_KEY: related_task.model_dump(by_alias=True)} + related_task_meta: dict[str, Any] = { + RELATED_TASK_METADATA_KEY: related_task.model_dump(by_alias=True, exclude_none=True) + } if result is not None: - result_data = result.model_dump(by_alias=True) + result_data = result.model_dump(by_alias=True, mode="json", exclude_none=True) existing_meta: dict[str, Any] = result_data.get("_meta") or {} result_data["_meta"] = {**existing_meta, **related_task_meta} return GetTaskPayloadResult.model_validate(result_data) diff --git a/tests/experimental/tasks/server/test_task_result_handler.py b/tests/experimental/tasks/server/test_task_result_handler.py index 8b5a03ce2..c1b7a66ad 100644 --- a/tests/experimental/tasks/server/test_task_result_handler.py +++ b/tests/experimental/tasks/server/test_task_result_handler.py @@ -291,6 +291,33 @@ async def test_deliver_skips_resolver_registration_when_no_original_id( mock_session.send_message.assert_called_once() +@pytest.mark.anyio +async def test_handle_omits_none_optional_fields_in_result( + store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler +) -> None: + """None optional fields (e.g. TextContent.annotations) must be omitted, not serialized as null. + + The Node SDK Zod schema marks these fields as optional (absent), not nullable, + so sending null breaks validation. + """ + task = await store.create_task(TaskMetadata(ttl=60000), task_id="test-task") + result = CallToolResult(content=[TextContent(type="text", text="hello")]) + await store.store_result(task.task_id, result) + await store.update_task(task.task_id, status="completed") + + mock_session = Mock() + mock_session.send_message = AsyncMock() + + request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(task_id=task.task_id)) + response = await handler.handle(request, mock_session, "req-1") + + wire_data = response.model_dump(by_alias=True, mode="json", exclude_none=True) + content_items = wire_data.get("content", []) + assert len(content_items) == 1 + assert "annotations" not in content_items[0] + assert "_meta" not in content_items[0] + + @pytest.mark.anyio async def test_wait_for_task_update_handles_store_exception( store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler