Skip to content

Let a legacy-era tools/call answer with a CreateTaskResult - #3161

Open
maxisbey wants to merge 1 commit into
mainfrom
legacy-tasks
Open

Let a legacy-era tools/call answer with a CreateTaskResult#3161
maxisbey wants to merge 1 commit into
mainfrom
legacy-tasks

Conversation

@maxisbey

@maxisbey maxisbey commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Lets a server on a 2025-11-25 connection answer a task-augmented tools/call with a CreateTaskResult, which SEP-1686 requires and the SDK currently makes impossible.

Motivation and Context

v2 ships the request half of SEP-1686 tasks and not the response half. CallToolRequestParams.task exists, every Task* type is in mcp_types, the tasks capability fields survive the initialize sieve, and Tool.execution.taskSupport survives the tools/list sieve. But SERVER_RESULTS[("tools/call", "2025-11-25")] holds CallToolResult alone, so:

  • a handler returning a CreateTaskResult gets -32603 "Handler returned an invalid result"
  • a handler returning {"content": [...], "task": {...}} succeeds and the task key is silently dropped by the extra="ignore" sieve
  • a client receiving one raises ValidationError in send_request before its caller sees anything, so it cannot even read the taskId to cancel the task it just caused

The v1.x line has CreateTaskResult in both its ServerResultType and ClientResultType unions, so a v1.x server can express that response and a v2 server cannot. What v2 announced removing was the experimental task runtime; the protocol coverage went with it, on a revision this SDK still negotiates and still lets a server advertise capabilities.tasks for. docs/whats-new.md promises that "serving the new revision does not strand a client on the old one".

2026-07-28 removed tasks from the core protocol in favour of the io.modelcontextprotocol/tasks extension (SEP-2663), a different protocol that reuses some method names. A CreateTaskResult on a 2026 tools/call still fails, which is correct.

What changed

The result arm. AnyCallToolResult = CallToolResult | CreateTaskResult is generated into the 2025-11-25 surface package (mcp_types._v2025_11_25, private per #3191) next to the 2026 aliases, and the tools/call rows in SERVER_RESULTS use it. The 2025-11-25 schema states the augmented-result rule in prose only, leaving CreateTaskResult out of its own ServerResult union, so the alias is spelled in the generator's epilogue rather than derived. The arms have disjoint required fields (content vs task) and discriminate identically in either order.

Only the 2025-11-25 row gets it. The earlier pre-2026 rows share v2025.CallToolRequest and so already parse params.task, but that is an artifact of the shared schema era rather than a licence to answer: a client sending task to a 2024-11-05 server is off-spec, and a clean INTERNAL_ERROR serves it better than a result shape its revision has no definition for. MONOLITH_RESULTS["tools/call"] gains the arm too, so the exported parse_server_result can parse everything the surface now admits.

The handler type. The lowlevel Server's on_call_tool return union gains CreateTaskResult. Return types are covariant, so every handler that compiles today still compiles.

Task.ttl. It is required and nullable (number | null, null meaning unlimited retention), and every dump path in the SDK passes exclude_none=True. That flag cannot tell a required null from an unset optional, so it dropped ttl and produced a body that failed the very surface it had just been validated against. Models carrying such a field now take a KeepRequiredNullable base that puts it back, and only that: a field the caller removed with include/exclude, or one never set at all, stays absent.

Two live bugs of the same shape fall out of the rule. LoggingMessageNotificationParams.data is required and declared with no type, so null is a legal value. ctx.log("info", None) dropped the key, and the receiving session rejected the notification against its own schema before dispatch: the message vanished with no error to either side, at every protocol version. It now arrives, with a test.

JSONRPCError.id is the other: required and nullable per JSON-RPC 2.0, where "id": null means the request id could not be determined. _streamable_http_modern.py patched it back by hand at one call site, with a comment reading "exclude_none would otherwise drop it". That patch is deleted; the base covers every writer.

Four notes on the shape of KeepRequiredNullable:

  • The field set resolves on first dump rather than at class creation, because the generated modules defer annotations and finish with model_rebuild(); resolving early would silently see nothing for a forward-referenced field and make the base inert.
  • It is applied per model, not on MCPModel/WireModel. A wrap serializer costs per dump and pushes pydantic off its fast path for every model in the tree; on the base classes, ListToolsResult with 20 tools went from 9.0 to 49.5 us. Per model it measures the same as before (8.9 us).
  • The generator derives which classes need it from the schema, so it is not a list anyone maintains, and tests/types/test_parity.py applies the same rule to the built models across _types, jsonrpc, and both surfaces. The test is the authority: a spelling the generator's schema walk does not recognise fails the suite rather than the wire, and says so.
  • Its return is deliberately unannotated. Pydantic builds the serialization JSON schema from that signature, and annotating it (as dict[str, Any] or Any) collapses the whole model's serialization schema to an opaque object for anyone generating schemas over these types.

The tasks/* lifecycle methods are deliberately untouched. They are absent from SPEC_CLIENT_METHODS, so the runner skips both its inbound gate and its outbound sieve for them and Server.add_request_handler serves them today. Adding registry rows would add those names to that version-flattened set, which makes MethodBinding reject tasks/get outright and gates the 2026 tasks extension's own tasks/get to METHOD_NOT_FOUND. The docs describe the add_request_handler route instead, along with its sharp edges: the handler serves every negotiated version, a raised exception on a custom method reaches the client unmapped, and get_capabilities will not advertise tasks for you. The same era caveat applies to on_call_tool itself: the handler is given the version-free params model, which carries task at every version, so ctx.protocol_version is the era test and params.task is only the opt-in within it.

How Has This Been Tested?

  • tests/interaction/lowlevel/test_tools.py gains a public-API test under a new tools:call:task-augmented requirement, running over all four transports. It fails on main with ValidationError for CallToolResult.
  • tests/interaction/lowlevel/test_logging.py gains one for the null-data notification, which on main never reaches the client at all.
  • tests/types/ covers the widened rows, a ttl round trip through serialize_server_result with both null and a number, the required-nullable invariant across both surfaces and the monolith, and that the base leaves include/exclude/non-exclude_none dumps alone.
  • Driven by hand end to end against a real stdio subprocess server: task-augmented tools/calltasks/get polling → tasks/resulttasks/listtasks/cancel, with capabilities.tasks advertised and taskSupport: "optional" on the tool. The same server on a 2026-07-28 connection correctly refuses the CreateTaskResult.
  • Full suite, pyright, coverage at 100%, and the generator's --check drift guard all pass.

Breaking Changes

None. The handler union only widens, which is covariant. The one behaviour change is that a required field whose value is null now appears in an exclude_none dump instead of being dropped, which is what the schema always said should happen.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

The SDK is supplying vocabulary here, not a task runtime. There is no task store, no polling helper, and no capability enforcement, and this PR does not add any: a server author brings those, exactly as they would for any other stateful protocol feature. That is a deliberate stopping point rather than a first slice. For the same reason ClientSession.call_tool still resolves to the two core arms, so a client consuming a task drops to send_request with an explicit result type, as the docs and the new test both do.

The 2025-11-25 spec also allows a client to answer a task-augmented sampling/createMessage or elicitation/create with a CreateTaskResult; their CLIENT_RESULTS rows are still single-arm. That is the same defect in the same table, left out to keep this to one direction, and the docs say so rather than claiming the whole vocabulary works.

Two pre-existing behaviours surfaced while testing and are worth separate looks. Server.add_request_handler has no protocol_versions parameter, so a handler for a method whose meaning differs across revisions cannot be scoped (MethodBinding can). And an exception escaping a custom-method handler reaches the client as an error with code 0 carrying the exception text, which is neither a valid JSON-RPC code nor safe to echo. The docs added here warn about both rather than working around them.

AI Disclaimer

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3161.mcp-python-docs.pages.dev
Deployment https://66c9ccfe.mcp-python-docs.pages.dev
Commit b9f183b
Triggered by @maxisbey
Updated 2026-09-01 17:31:22 UTC

@maxisbey
maxisbey marked this pull request as ready for review July 24, 2026 15:04

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 17 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/mcp-types/mcp_types/_wire_base.py Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Beyond the inline findings, the KeepRequiredNullable wrap serializer was also examined for resurrecting never-set fields (violating exclude_unset) — ruled out as a live issue: a validated instance always has its required fields set, no SDK dump path passes exclude_unset, and the only reach is unvalidated model_construct, where the restored null still matches the schema. I reproduced that corner on this branch to confirm the scope.

Extended reasoning...

This run's finder agents raised (and verifiers refuted) a claim that KeepRequiredNullable in src/mcp-types/mcp_types/_wire_base.py violates the exclude_unset contract by restoring fields that were never set. I re-checked this directly against the branch: the serializer keys off info.exclude_none only and does setdefault when getattr(self, name, None) is None, so a field left unset via model_construct under exclude_none + exclude_unset does reappear as null. The refutation nonetheless holds as not-a-real-bug: required fields cannot be unset on any validated instance, the SDK's own dump paths (serialize_server_result, the transports) never pass exclude_unset, and the resurrected null is exactly what the schema requires for these required-nullable fields, so no invalid wire body can result. The inline nits (docs overstating the era gate, the requirement-manifest double-entry, and the un-widened public ServerResult union) stand on their own; the serializer and result-arm changes are otherwise the parts deserving the human reviewer's closest look, and the era-scoping of the widened row to 2025-11-25 matches the code.

Comment thread docs/advanced/low-level-server.md Outdated
Comment thread tests/interaction/_requirements.py
Comment thread src/mcp-types/mcp_types/methods.py

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 6 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread docs/migration.md

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Beyond the inline nit on the model-level __all__ include/exclude wildcard, this run also examined and ruled out two candidates: the KeepRequiredNullable docstring's claim that a never-set field stays absent out of an exclude_none dump (behaviour matches the docstring), and the newly wire-live Task.ttl rejecting spec-legal non-integer values via the schema.ts number-as-integer rendering (not reproducible on this branch).

Extended reasoning...

Bugs were found this run (one nit-level inline comment on the __all__ wildcard handling in KeepRequiredNullable), so per policy no approval or standalone deferral is posted — the inline comment already signals what needs attention. This note only records what else was examined and refuted this run, since no prior run of mine left such a note: the docstring-accuracy candidate for KeepRequiredNullable and the Task.ttl integer-vs-number candidate were both investigated by verifier agents and concluded not to be real issues. My three findings from the previous run were all addressed in 269c219 (two fixed, one declined with reasoning that holds), so nothing from that round remains open.

Comment on lines +62 to +87
# only the argument would restore the one key under a spelling the rest of the dump did
# not use.
by_alias = info.by_alias or type(self).model_config.get("serialize_by_alias", False)
for name, alias in _nullable_required_fields(type(self)):
if getattr(self, name, None) is not None:
continue
if info.include is not None and name not in info.include:
continue
if _is_excluded(name, info.exclude):
continue
data.setdefault(alias if by_alias else name, None)
return data


def _is_excluded(name: str, exclude: Any) -> bool:
"""Whether `exclude` drops `name` outright, as opposed to selecting within it.

A mapping entry carrying anything other than `True`/`...` descends into the field, so
pydantic keeps the field itself and its null still has to go back.
"""
if exclude is None:
return False
if isinstance(exclude, Mapping):
marker: Any = cast("Mapping[Any, Any]", exclude).get(name)
return marker is True or marker is Ellipsis
return name in cast("Container[Any]", exclude)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 KeepRequiredNullable's include/exclude checks look up only the field's own key, but pydantic also honors a model-level __all__ wildcard in both mappings: include={'__all__': True} + exclude_none=True drops a required null (e.g. Task.ttl), producing exactly the schema-invalid body this base exists to prevent, while exclude={'__all__': True} + exclude_none=True spuriously restores the excluded field as {'ttl': None}. Fix by consulting the wildcard in both checks: treat name as excluded when exclude.get(name) or exclude.get('__all__') is True/Ellipsis, and as included when include is None, name in include, or '__all__' in include.

Extended reasoning...

What the bug is. Pydantic's include/exclude mappings honor a special __all__ key at model level: on a plain BaseModel, model_dump(include={'__all__': True}) keeps every field and model_dump(exclude={'__all__': True}) returns {} (verified on this branch). KeepRequiredNullable._keep_required_nullable (src/mcp-types/mcp_types/_wire_base.py:68) and _is_excluded (lines 76-87) only look up the field's own key — name not in info.include and exclude.get(name) — so a model-level wildcard is invisible to both checks, and the base makes the wrong call in both directions.

Divergence 1 — the required null is dropped. Step-by-step, reproduced on this branch:

  1. task = Task(task_id='t1', status='working', created_at='x', last_updated_at='y', ttl=None)
  2. task.model_dump(by_alias=True, exclude_none=True, include={'__all__': True}){'taskId': 't1', 'status': 'working', 'createdAt': 'x', 'lastUpdatedAt': 'y'}ttl is gone.
  3. Pydantic's include filter kept ttl (__all__ includes every field); only exclude_none removed the null. Per the class's own contract ("a field the caller filtered out with include/exclude stays absent, because there exclude_none is not why it went" — and its converse), the null must be restored.
  4. But line 68's check sees 'ttl' not in {'__all__'} and skips restoration, yielding a body that fails the 2025-11-25 surface (Task.ttl is required). A caller spelling "include everything" — semantically identical to passing no include at all — silently gets the pre-PR broken behavior back. Same failure applies to LoggingMessageNotificationParams.data and JSONRPCError.id.

Divergence 2 — an excluded field is spuriously restored. task.model_dump(by_alias=True, exclude_none=True, exclude={'__all__': True}){'ttl': None} (same with exclude={'__all__': ...}). Without exclude_none the same dump is {}, so pydantic excluded every field via the wildcard — yet _is_excluded('ttl', {'__all__': True}) does exclude.get('ttl')None → not True/EllipsisFalse, and the base re-adds ttl. This directly violates the docstring invariant that tests/types/test_parity.py::test_keep_required_nullable_only_restores_what_exclude_none_removed pins for every other spelling of exclusion.

Why the 269c219 fix missed it. That commit (responding to cubic's P2 about descending-map markers) added the True/Ellipsis marker check to _is_excluded, and the author reported checking eleven shapes including an "__all__ map" — but the __all__ shape actually tested was the nested form (exclude={'tasks': {'__all__': {'ttl'}}}), where pydantic narrows the filter to {'ttl'} before it reaches the model's SerializationInfo. The model-level wildcard path is genuinely untested and unhandled.

Impact and fix. No SDK-internal dump path passes include/exclude at all (serialize_server_result and the transport writers use only by_alias/mode/exclude_none), so nothing in any SDK flow breaks on merge — the trigger requires an external consumer of the public mcp-types package combining exclude_none=True with the model-level __all__ spelling on an exported model (Task, GetTaskResult, CancelTaskResult, LoggingMessageNotificationParams, JSONRPCError). That is legal public API but an unusual spelling, so this is a polish item rather than a blocker. The fix is small and local: in _keep_required_nullable, treat name as included when include is None, name in include, or '__all__' in include (pydantic keeps a field for any marker under its key, including False — verified include={'data': False} keeps data on a plain model); in _is_excluded, also check exclude.get('__all__') for a True/Ellipsis marker. A test pinning include={'__all__': True} and exclude={'__all__': True} alongside the existing spellings in test_keep_required_nullable_only_restores_what_exclude_none_removed would close the gap the nested-form test left open.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Beyond the inline finding on the unpatched integer rendering of the task ttl fields, this run also examined whether KeepRequiredNullable could inject a never-set field into exclude_unset dumps — ruled out: the wrap serializer only intervenes when exclude_none is set, and a required nullable field is always set on a validated instance, so an exclude_unset dump is passed through untouched.

Extended reasoning...

The one bug found this run (Task.ttl/pollInterval/TaskMetadata.ttl rendered as integer in the vendored 2025-11-25 schema, made live on the send_request validation path by AnyCallToolResult) is posted inline and is reason enough for the author to revisit before merge, so no approval. This note only records the additional candidate that finder agents raised and verifiers refuted — the claim that KeepRequiredNullable would fabricate a key in exclude_unset dumps — so a later review pass has a record that the exclude_unset interaction was already examined. It is informational, not a guarantee: the serializer's early return on 'not info.exclude_none' and the impossibility of an unset required field on a validated model are why it holds.

("resources/templates/list", "2025-11-25"): v2025.ListResourceTemplatesResult,
("resources/unsubscribe", "2025-11-25"): v2025.EmptyResult,
("tools/call", "2025-11-25"): v2025.CallToolResult,
("tools/call", "2025-11-25"): v2025.AnyCallToolResult,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 The vendored 2025-11-25 schema misrenders Task.ttl (number | null upstream), Task.pollInterval (number), and TaskMetadata.ttl (number) as integer — the exact ts→json defect SCHEMA_PATCHES exists to correct — and this PR makes all three fields live via AnyCallToolResult without patching them, so a spec-legal fractional value (e.g. ttl: 1500.5 or pollInterval: 250.5 from a non-SDK server) raises ValidationError in ClientSession.send_request before the caller can read the taskId — recreating the strandedness this PR's motivation says it fixes. Fix is the established mechanical pattern: add the three sites to SCHEMA_PATCHES['2025-11-25'] in scripts/gen_surface_types.py, regenerate, and widen the monolith Task.ttl/Task.poll_interval/TaskMetadata.ttl to int | float | None (matching the existing NumberSchema treatment).

Extended reasoning...

What the bug is. The upstream 2025-11-25 schema.ts declares TaskMetadata.ttl?: number, Task.ttl: number | null, and Task.pollInterval?: number — all TypeScript number, no integer annotation. The vendored schema/2025-11-25.json renders all three as integer (Task.ttl: ["integer","null"], Task.pollInterval: integer, TaskMetadata.ttl: integer), while e.g. ProgressNotificationParams.progress in the same file correctly renders as number — so these are misrendered sites of the selective ts→json defect that SCHEMA_PATCHES in scripts/gen_surface_types.py documents and exists to correct ('renders TypeScript number as JSON Schema integer at these sites; patch the JSON before codegen so floats validate', with a TODO to drop once upstream fixes the renderer). The PR patches none of the three, so the generated v2025.Task.ttl/poll_interval and TaskMetadata.ttl are int-typed, and the hand-written monolith mirrors them (_types.py Task.ttl/Task.poll_interval/TaskMetadata.ttl).\n\nWhy this PR makes it live. Before this PR the defect was latent: no SERVER_RESULTS/MONOLITH_RESULTS row admitted a Task, so a CreateTaskResult body never validated anywhere. This PR adds ("tools/call", "2025-11-25"): v2025.AnyCallToolResult (methods.py:289) and the CreateTaskResult arm to MONOLITH_RESULTS["tools/call"], and docs/migration.md instructs clients to consume the result via ClientSession.send_request. That path calls _methods.validate_server_result on the raw body before the caller's TypeAdapter runs.\n\nStep-by-step proof (all reproduced on this branch at d6f6c8c):\n1. A non-SDK 2025-11-25 server answers a task-augmented tools/call with {"task": {..., "ttl": 1500.5}} or {..., "ttl": null, "pollInterval": 250.5} — legal per schema.ts, where both fields are number (milliseconds).\n2. ClientSession.send_request calls validate_server_result("tools/call", "2025-11-25", raw), which validates against v2025.AnyCallToolResult.\n3. The CreateTaskResult arm rejects the fractional value (int_from_float), the union falls through, and ValidationError propagates out of send_request.\n4. The caller never sees the body, so it cannot read the taskId to poll or cancel the task it just caused — verbatim the strandedness the PR's motivation section describes fixing ('it cannot even read the taskId to cancel the task it just caused'). Integral floats (60000.0) coerce fine under pydantic lax mode; only genuinely fractional values fail, which is exactly why the PR's own tests (parametrized over [None, 60_000]) never hit it.\n\nOther affected paths. Server side: a handler returning a dict body with a fractional ttl (natural when computing remaining retention from time.monotonic()/datetime deltas, which yield floats) fails serialize_server_result, which the runner maps to the opaque -32603 "Handler returned an invalid result". Inbound request side: validate_client_request("tools/call", "2025-11-25", {"name": "render", "task": {"ttl": 60000.5}}) raises → INVALID_PARAMS for a schema.ts-legal request (this arm predates the PR, but it shares the one root cause and one fix, and this PR is what documents and ships the feature).\n\nWhy nothing else prevents it. The surface validators are deliberately strict against the vendored schema, so wherever the vendored JSON is wrong, spec-conformant wire data is rejected — which is precisely why SCHEMA_PATCHES exists. The repo's own precedent treats this exact class as a defect to patch, not intended strictness: NumberSchema.default/maximum/minimum are already patched to ["integer","number"] 'so codegen emits int | float and pydantic smart-union preserves ints on round-trip', and the ElicitResult/JSONValue null arms get the same superset-leniency treatment.\n\nHow to fix. Add three entries to SCHEMA_PATCHES["2025-11-25"] in scripts/gen_surface_types.py("$defs/Task/properties/ttl/type", ["integer","null"], ["integer","number","null"]), ("$defs/Task/properties/pollInterval/type", "integer", ["integer","number"]), ("$defs/TaskMetadata/properties/ttl/type", "integer", ["integer","number"]) — regenerate, and widen the monolith Task.ttl/Task.poll_interval/TaskMetadata.ttl in src/mcp-types/mcp_types/_types.py to int | float | None, exactly the treatment the NumberSchema fields already receive. A [None, 60_000, 1500.5] parametrization of test_serialize_server_result_keeps_a_required_nullable_task_ttl would pin it.

Comment on lines +53 to +72
@model_serializer(mode="wrap")
def _keep_required_nullable(self, handler: SerializerFunctionWrapHandler, info: SerializationInfo):
# The return is deliberately unannotated: pydantic builds the serialization JSON schema
# from this signature, and any annotation collapses the whole model's schema to an
# opaque object for anyone generating schemas over these types.
data = handler(self)
if not info.exclude_none:
return data
# `serialize_by_alias` is the config-level spelling of the `by_alias` argument; reading
# only the argument would restore the one key under a spelling the rest of the dump did
# not use.
by_alias = info.by_alias or type(self).model_config.get("serialize_by_alias", False)
for name, alias in _nullable_required_fields(type(self)):
if getattr(self, name, None) is not None:
continue
if info.include is not None and name not in info.include:
continue
if _is_excluded(name, info.exclude):
continue
data.setdefault(alias if by_alias else name, None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 KeepRequiredNullable restores fields that exclude_unset (not exclude_none) removed: a required-nullable field never set at all (via model_construct) is fabricated back as null when a caller combines exclude_unset=True with exclude_none=True — e.g. JSONRPCError gains "id": null (a semantically loaded JSON-RPC value) and Task gains "ttl": null in partial dumps, violating the base's own documented contract that a field never set stays absent. Fix by skipping restoration when info.exclude_unset is true and name not in self.__pydantic_fields_set__.

Extended reasoning...

What the bug is. The wrap serializer KeepRequiredNullable._keep_required_nullable (src/mcp-types/mcp_types/_wire_base.py:53-72) decides whether to restore a required-nullable field by checking getattr(self, name, None) is None, info.include, and info.exclude — but it never consults info.exclude_unset or self.__pydantic_fields_set__. Pydantic evaluates exclude_unset before exclude_none, so when both flags are passed, a field that was never set is dropped by exclude_unset — a removal channel the wrap cannot see. getattr returns None for the unset field (indistinguishable from a field explicitly set to None), include/exclude are None, and data.setdefault(alias, None) fires, fabricating a key the caller never provided.

Step-by-step proof (reproduced on this branch):

  1. e = JSONRPCError.model_construct(jsonrpc='2.0', error=ErrorData(code=1, message='m'))id is deliberately unset; model_construct is the documented no-validation constructor (this PR's own parity test uses it).
  2. e.model_dump(by_alias=True, exclude_unset=True){'jsonrpc': '2.0', 'error': {...}} — correct, id is absent.
  3. e.model_dump(by_alias=True, exclude_unset=True, exclude_none=True){'jsonrpc': '2.0', 'error': {...}, 'id': None} — adding exclude_none (a flag that only ever removes keys) added one.
  4. Same for Task.model_construct(task_id='t1', status='working', created_at='x', last_updated_at='y'): with exclude_unset alone ttl is absent; adding exclude_none yields 'ttl': None.

Why this violates the PR's own contract. The PR description states the base restores "only that: a field the caller removed with include/exclude, or one never set at all, stays absent," and tests/types/test_parity.py::test_keep_required_nullable_only_restores_what_exclude_none_removed pins exactly that invariant — but only for the include/exclude and no-exclude_none spellings. Here exclude_unset is why the key went, not exclude_none, so per the stated contract the field must stay absent. The exclude_unset channel is simply the one the test never covers, which is why this ships green. It is also semantically consequential rather than a harmless extra key: "id": null on a JSONRPCError is a defined JSON-RPC 2.0 claim ("the request id could not be determined") that the caller never made.

Why existing safeguards miss it. The checks at lines 65-68 and the _is_excluded helper (hardened in 269c219 for descending-map markers) model only the include/exclude filter arguments. Unset-ness is a different removal channel, evaluated by pydantic before exclude_none and invisible to those checks. This is a distinct root cause from the earlier __all__-wildcard comment on this file, which is a gap in the include/exclude mapping semantics; the fix here is independent of any wildcard handling.

Impact and why it doesn't block merge. No SDK-internal dump path can hit this: a validated instance always has its required fields in __pydantic_fields_set__, the runner and sessions pass exclude_none without exclude_unset, and the transport envelope writers pass exclude_unset with id always set. The trigger is exclusively an external consumer of the public mcp-types package building a partial model via model_construct and dumping with exclude_unset=True, exclude_none=True — a standard partial/patch-dump idiom, but outside every SDK flow. JSONRPCError, Task, and LoggingMessageNotificationParams are all affected.

How to fix. One extra condition in the restoration loop: skip when info.exclude_unset and name not in self.__pydantic_fields_set__. Worth pinning with a test alongside test_keep_required_nullable_only_restores_what_exclude_none_removed, which already covers every other removal channel.

SEP-1686 makes a task-augmented `tools/call` answer with a `CreateTaskResult`,
but `SERVER_RESULTS[("tools/call", "2025-11-25")]` held `CallToolResult` alone.
The request half shipped and the response half did not, so a server returning
a task got `-32603 "Handler returned an invalid result"`, a server returning
both shapes had `task` silently sieved away, and a client receiving one raised
`ValidationError` before its caller saw anything. The v1.x line could express
that response; v2 cannot, on a revision the SDK still negotiates and still lets
a server advertise `capabilities.tasks` for.

Give the row its second arm as a generated `AnyCallToolResult`, alongside the
2026 aliases, and widen the lowlevel `Server`'s `on_call_tool` to match. Return
types are covariant, so a handler that only returns `CallToolResult` is
unaffected. Only the 2025-11-25 row gets the arm. The earlier handshake
revisions share `_v2025.CallToolRequest` and so already parse `params.task`, but
that is an artifact of the shared schema era, not a licence to answer: they
predate SEP-1686, and a clean INTERNAL_ERROR serves an off-spec client better
than a result shape its revision never defined. `MONOLITH_RESULTS["tools/call"]`
gains the arm too, so `parse_server_result` covers everything the surface admits.

`Task.ttl` is required and nullable ("null for unlimited"), and every dump path
passes `exclude_none=True`, which cannot tell a required null from an unset
optional and dropped it, leaving a body that fails the surface it was just
validated against. Rather than patch the 27 dump sites, models carrying such a
field now take a `KeepRequiredNullable` base that puts it back, and only that:
a field the caller filtered out with `include`/`exclude`, or one that was never
set, stays absent. The generator derives which classes need it from the schema
and a test asserts the same rule against the built models, so a nullable-required
field in a future revision is covered without anyone remembering. The field set
resolves on first dump rather than at class creation, because the generated
modules defer annotations and finish with `model_rebuild()`, so resolving early
would silently see nothing for a forward-referenced field.

That rule also reaches two live bugs of the same shape.
`LoggingMessageNotificationParams.data` is required and untyped, so
`ctx.log("info", None)` dropped the key and the receiving session rejected the
notification before dispatch: the message vanished with no error to either side,
at every protocol version. And `JSONRPCError.id` is required-and-nullable per
JSON-RPC 2.0, which the streamable-HTTP writer worked around by hand at one call
site; that patch is deleted and the base covers every writer.

The base is applied per model rather than to `MCPModel`/`WireModel`: a wrap
serializer costs per dump and pushes pydantic off its fast path for every model
in the tree, which measured 5x on a 20-tool `tools/list`. Its return is
deliberately unannotated, because an annotation there collapses the model's
serialization JSON schema to an opaque object.

The `tasks/*` lifecycle methods are left where they already work. They are
absent from `SPEC_CLIENT_METHODS`, so `Server.add_request_handler` serves them
today; adding registry rows would put those names into that version-flattened
set, which would make it illegal for an extension to bind `tasks/get` and would
gate the 2026 tasks extension's own calls to METHOD_NOT_FOUND. 2026-07-28 keeps
rejecting a `CreateTaskResult` on `tools/call`, which is correct: tasks left the
core protocol there.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 src/mcp/server/_streamable_http_modern.py — nit: stale comment — _sse_event's docstring still says "the id: null case lives in _write", but this PR deleted that special case from _write (the null id is now restored by JSONRPCError's KeepRequiredNullable base), so after merging the comment points a reader at handling that no longer exists. Fix: update the docstring to say the required-nullable id survives exclude_none via KeepRequiredNullable on JSONRPCError, or drop the clause.

    Extended reasoning...

    The diff removes the if isinstance(msg, JSONRPCError) and msg.id is None: body["id"] = None block from _write (src/mcp/server/_streamable_http_modern.py:190-197) because JSONRPCError now subclasses KeepRequiredNullable (src/mcp-types/mcp_types/jsonrpc.py:134), whose wrap serializer restores the required-nullable id on any exclude_none=True dump. But the docstring of _sse_event at line 161 was not updated and still reads "a JSONRPCError here always carries the request's id; the id: null case lives in _write". On the base branch that sentence was accurate; after merge it references code that no longer exists, so a maintainer tracing where id: null is produced is sent to _write and finds nothing, and could plausibly re-add a redundant special case. No runtime effect — behavior is correct (the base class emits id: null on every serialization path, which the existing tests in tests/server/test_streamable_http_modern.py:190-206 still exercise); this is purely a stale documentation comment introduced by the change.

    Verification: nit — stale docstring, nothing breaks. src/mcp/server/_streamable_http_modern.py:161 still says "a JSONRPCError here always carries the request's id; the id: null case lives in _write", but this PR's diff deletes that case from _write (removed lines: if isinstance(msg, JSONRPCError) and msg.id is None: body["id"] = None with its comment). The null-id restoration now happens via…

Comment thread docs/migration.md
```

### Experimental Tasks support removed
### Experimental Tasks runtime removed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 nit (optional): Renaming this heading from "Experimental Tasks support removed" to "Experimental Tasks runtime removed" changes its URL fragment, so existing off-site links to migration.md#experimental-tasks-support-removed on the published v2 docs silently land at the top of this ~2000-line page instead of the section. Fix: preserve the old anchor when renaming a heading on this page — attr_list is enabled in mkdocs.yml, so ### Experimental Tasks runtime removed {#experimental-tasks-support-removed} (or an explicit <a id=...>) keeps both; in-repo links were updated, but nothing validates external ones.

Extended reasoning...

The base branch publishes the section as ### Experimental Tasks support removed, giving the rendered page the anchor #experimental-tasks-support-removed (mkdocs derives fragments from heading text). v2 is released (AGENTS.md: "v2 is released"; migration.md is "the v1 -> v2 record and is closed to new entries"), so this page has been live and is exactly the kind of page users bookmark and link from issues, release notes, and blog posts when porting the removed tasks runtime. The PR renames the heading at docs/migration.md:2011 to ### Experimental Tasks runtime removed, changing the fragment to #experimental-tasks-runtime-removed, and updates the two in-repo references (docs/migration.md:43 and the new docs/advanced/low-level-server.md:199 link) — git grep experimental-tasks-support-removed HEAD~1 confirms the only in-repo reference was the table row, so after merge no alias for the old fragment exists anywhere. Why safeguards miss it: mkdocs strict link validation only checks links that exist inside the repo; a missing fragment on an external visitor's URL produces no build…

Verification: nit — The diff removes ### Experimental Tasks support removed and adds ### Experimental Tasks runtime removed in docs/migration.md, and the base branch's own routing-table link [Experimental Tasks support removed](#experimental-tasks-support-removed) (updated in the same diff to #experimental-tasks-runtime-removed) confirms the published anchor derived from the old heading. mkdocs.yml…

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

3 issues found across 17 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="docs/whats-new.md">

<violation number="1" location="docs/whats-new.md:145">
P2: A custom store alone does not make this task flow usable: `Server` has no built-in `tasks/*` handlers or task capability advertisement. State that a low-level server must supply those pieces too, or link to the migration example.</violation>
</file>

<file name="docs/migration.md">

<violation number="1" location="docs/migration.md:2015">
P2: A server created from this example cannot complete the documented Tasks flow because it registers only `tasks/get`; `Server` does not register the other three methods automatically. Register handlers for `tasks/result`, `tasks/list`, and `tasks/cancel` as well, or label the example explicitly as partial and explain that all four methods must be added.</violation>
</file>

<file name="src/mcp-types/mcp_types/_wire_base.py">

<violation number="1" location="src/mcp-types/mcp_types/_wire_base.py:64">
P2: When `serialize_by_alias=True` but a caller explicitly passes `by_alias=False`, this falls back to the config and restores nullable fields under aliases while the handler emits field names. Preserve an explicit `False` by checking `info.by_alias is not None` before consulting the config.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread docs/whats-new.md

* The **WebSocket transport**, both sides, and the `mcp[ws]` extra. It was never part of the MCP specification.
* The **experimental Tasks** API (`mcp.*.experimental`). 2026-07-28 moves tasks out of the core protocol and into an official extension ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)), which this SDK does not implement yet.
* The **experimental Tasks** runtime (`mcp.*.experimental`): the task store, the polling helper, and the automatic `tasks/*` routing. The task types stay, so a server can still answer a task-augmented `tools/call` on a 2025-11-25 connection by bringing its own store; 2026-07-28 moves tasks out of the core protocol into an official extension ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)), which this SDK does not implement yet.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: A custom store alone does not make this task flow usable: Server has no built-in tasks/* handlers or task capability advertisement. State that a low-level server must supply those pieces too, or link to the migration example.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/whats-new.md, line 145:

<comment>A custom store alone does not make this task flow usable: `Server` has no built-in `tasks/*` handlers or task capability advertisement. State that a low-level server must supply those pieces too, or link to the migration example.</comment>

<file context>
@@ -142,7 +142,7 @@ The renames announce themselves. These do not:
 
 * The **WebSocket transport**, both sides, and the `mcp[ws]` extra. It was never part of the MCP specification.
-* The **experimental Tasks** API (`mcp.*.experimental`). 2026-07-28 moves tasks out of the core protocol and into an official extension ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)), which this SDK does not implement yet.
+* The **experimental Tasks** runtime (`mcp.*.experimental`): the task store, the polling helper, and the automatic `tasks/*` routing. The task types stay, so a server can still answer a task-augmented `tools/call` on a 2025-11-25 connection by bringing its own store; 2026-07-28 moves tasks out of the core protocol into an official extension ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)), which this SDK does not implement yet.
 * `mcp.shared.version`, `mcp.shared.progress`, and `mcp.shared.session` (with the `RequestResponder` stub v1 `message_handler` annotations imported) as import paths. (`mcp.types` is *not* removed: it remains as a permanent alias for the standalone `mcp_types` package.)
 * The deprecated `streamablehttp_client` spelling, and the `get_session_id` callback from `streamable_http_client` (which now yields exactly two streams).
</file context>
Suggested change
* The **experimental Tasks** runtime (`mcp.*.experimental`): the task store, the polling helper, and the automatic `tasks/*` routing. The task types stay, so a server can still answer a task-augmented `tools/call` on a 2025-11-25 connection by bringing its own store; 2026-07-28 moves tasks out of the core protocol into an official extension ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)), which this SDK does not implement yet.
* The **experimental Tasks** runtime (`mcp.*.experimental`): the task store, the polling helper, and the automatic `tasks/*` routing. The task types stay, so a low-level `Server` can still answer a task-augmented `tools/call` on a 2025-11-25 connection by supplying its own store, `tasks/*` handlers, and task capability advertisement; 2026-07-28 moves tasks out of the core protocol into an official extension ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)), which this SDK does not implement yet. See the [Migration Guide](migration.md#experimental-tasks-runtime-removed) for the required wiring.

Comment thread docs/migration.md
The task runtime that shipped behind the `experimental` properties is gone. The `mcp.client.experimental`, `mcp.server.experimental`, `mcp.shared.experimental`, and `mcp.server.lowlevel.experimental` modules have been removed, along with the `experimental` properties on `ClientSession`, `ServerSession`, `Server`, and `ServerRequestContext`. There is no built-in task store, no polling helper, and no automatic `tasks/*` routing. The `TaskExecutionMode` alias is also gone; its literal is inlined on `ToolExecution.task_support`.

The 2026-07-28 revision reintroduces Tasks as an official extension: [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663), `io.modelcontextprotocol/tasks`, redesigned around polling (`tasks/get`) instead of a blocking `tasks/result`. This SDK does not implement the extension yet.
The task types stay, so a server can still serve Tasks ([SEP-1686](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1686)) on a 2025-11-25 connection by supplying the parts the runtime used to provide. 2025-11-25 is the only revision that defines them: the earlier handshake revisions predate SEP-1686, so a `CreateTaskResult` there is rejected as an internal error even though `params.task` reaches the handler. This covers the server side of a task-augmented `tools/call`; the client side of a task-augmented `sampling/createMessage` or `elicitation/create` is not wired, so a client cannot answer one of those with a `CreateTaskResult`. A task-augmented `tools/call` arrives with `params.task` set and may be answered with a `CreateTaskResult`, and the `tasks/get`, `tasks/result`, `tasks/list`, and `tasks/cancel` methods are registered with `Server.add_request_handler`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: A server created from this example cannot complete the documented Tasks flow because it registers only tasks/get; Server does not register the other three methods automatically. Register handlers for tasks/result, tasks/list, and tasks/cancel as well, or label the example explicitly as partial and explain that all four methods must be added.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/migration.md, line 2015:

<comment>A server created from this example cannot complete the documented Tasks flow because it registers only `tasks/get`; `Server` does not register the other three methods automatically. Register handlers for `tasks/result`, `tasks/list`, and `tasks/cancel` as well, or label the example explicitly as partial and explain that all four methods must be added.</comment>

<file context>
@@ -2008,11 +2008,59 @@ async def elicitation_callback(
+The task runtime that shipped behind the `experimental` properties is gone. The `mcp.client.experimental`, `mcp.server.experimental`, `mcp.shared.experimental`, and `mcp.server.lowlevel.experimental` modules have been removed, along with the `experimental` properties on `ClientSession`, `ServerSession`, `Server`, and `ServerRequestContext`. There is no built-in task store, no polling helper, and no automatic `tasks/*` routing. The `TaskExecutionMode` alias is also gone; its literal is inlined on `ToolExecution.task_support`.
 
-The 2026-07-28 revision reintroduces Tasks as an official extension: [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663), `io.modelcontextprotocol/tasks`, redesigned around polling (`tasks/get`) instead of a blocking `tasks/result`. This SDK does not implement the extension yet.
+The task types stay, so a server can still serve Tasks ([SEP-1686](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1686)) on a 2025-11-25 connection by supplying the parts the runtime used to provide. 2025-11-25 is the only revision that defines them: the earlier handshake revisions predate SEP-1686, so a `CreateTaskResult` there is rejected as an internal error even though `params.task` reaches the handler. This covers the server side of a task-augmented `tools/call`; the client side of a task-augmented `sampling/createMessage` or `elicitation/create` is not wired, so a client cannot answer one of those with a `CreateTaskResult`. A task-augmented `tools/call` arrives with `params.task` set and may be answered with a `CreateTaskResult`, and the `tasks/get`, `tasks/result`, `tasks/list`, and `tasks/cancel` methods are registered with `Server.add_request_handler`.
+
+```python
</file context>

# `serialize_by_alias` is the config-level spelling of the `by_alias` argument; reading
# only the argument would restore the one key under a spelling the rest of the dump did
# not use.
by_alias = info.by_alias or type(self).model_config.get("serialize_by_alias", False)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When serialize_by_alias=True but a caller explicitly passes by_alias=False, this falls back to the config and restores nullable fields under aliases while the handler emits field names. Preserve an explicit False by checking info.by_alias is not None before consulting the config.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp-types/mcp_types/_wire_base.py, line 64:

<comment>When `serialize_by_alias=True` but a caller explicitly passes `by_alias=False`, this falls back to the config and restores nullable fields under aliases while the handler emits field names. Preserve an explicit `False` by checking `info.by_alias is not None` before consulting the config.</comment>

<file context>
@@ -1,9 +1,87 @@
+        # `serialize_by_alias` is the config-level spelling of the `by_alias` argument; reading
+        # only the argument would restore the one key under a spelling the rest of the dump did
+        # not use.
+        by_alias = info.by_alias or type(self).model_config.get("serialize_by_alias", False)
+        for name, alias in _nullable_required_fields(type(self)):
+            if getattr(self, name, None) is not None:
</file context>
Suggested change
by_alias = info.by_alias or type(self).model_config.get("serialize_by_alias", False)
by_alias = info.by_alias if info.by_alias is not None else type(self).model_config.get("serialize_by_alias", False)

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.

1 participant