Add the SEP-2663 Tasks extension (core)#3005
Conversation
Implement io.modelcontextprotocol/tasks per SEP-2663 (Final), wire-incompatible with the 2025-11-25 in-core design still carried (types-only) in mcp_types, so the extension defines its own SEP-2663-shaped models: - The server decides task augmentation per request; the legacy params.task field is ignored. Only a client that declared the extension on a modern (2026-07-28) connection is augmented - a legacy handshake cannot carry the capability, so it is never augmented. - A task-augmented tools/call returns a flat CreateTaskResult (resultType: "task", taskId/status/createdAt/lastUpdatedAt/ttlMs). - tasks/get returns a DetailedTask (resultType: "complete"); a completed task inlines the original CallToolResult. isError: true is a completed task (failed is reserved for JSON-RPC errors). - tasks/cancel is an empty ack. tasks/result is not registered, so it returns -32601. A tasks/* call from a non-declaring client returns -32003 with a requiredCapabilities payload. Task ids are entropy-bearing. Ships a runnable tasks story (server-decided augmentation + tasks/get polling) and a migration note. Deferred to follow-ups (each needs deeper SDK plumbing): tasks/update + the MRTR input_required loop, ToolExecution.taskSupport gating with -32021, notifications/tasks, and SEP-2243 task routing headers.
There was a problem hiding this comment.
4 issues found across 9 files
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="examples/stories/tasks/README.md">
<violation number="1" location="examples/stories/tasks/README.md:3">
P3: Docs are inconsistent: the Tasks story is documented as implemented/runnable here, but the stories index still labels `tasks/` as "not yet implemented".</violation>
<violation number="2" location="examples/stories/tasks/README.md:11">
P2: README run instructions imply stdio demonstrates tasks flow, but stdio cannot negotiate the tasks extension. Users running the default command will not see the documented task behavior.</violation>
</file>
<file name="src/mcp/server/tasks.py">
<violation number="1" location="src/mcp/server/tasks.py:170">
P2: A failing tool call leaves a pre-created task permanently stored, causing in-memory task leaks on error paths.</violation>
<violation number="2" location="src/mcp/server/tasks.py:171">
P2: `intercept_tool_call` drops valid `BaseModel` results to `{}` before persisting task output. In extension chains this can make `tasks/get` return an empty `result` instead of the real tool payload.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
| ## Run it | ||
|
|
||
| ```bash | ||
| # stdio (default — the client spawns the server as a subprocess) |
There was a problem hiding this comment.
P2: README run instructions imply stdio demonstrates tasks flow, but stdio cannot negotiate the tasks extension. Users running the default command will not see the documented task behavior.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At examples/stories/tasks/README.md, line 11:
<comment>README run instructions imply stdio demonstrates tasks flow, but stdio cannot negotiate the tasks extension. Users running the default command will not see the documented task behavior.</comment>
<file context>
@@ -1,24 +1,48 @@
+## Run it
+
+```bash
+# stdio (default — the client spawns the server as a subprocess)
+uv run python -m stories.tasks.client
+
</file context>
| # stdio (default — the client spawns the server as a subprocess) | |
| # stdio (legacy handshake only; cannot negotiate `io.modelcontextprotocol/tasks` yet) |
| now = self._clock() | ||
| task = self._store.create(now, self._default_ttl_ms) | ||
| result = await call_next(ctx) | ||
| payload = result if isinstance(result, dict) else {} |
There was a problem hiding this comment.
P2: intercept_tool_call drops valid BaseModel results to {} before persisting task output. In extension chains this can make tasks/get return an empty result instead of the real tool payload.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/server/tasks.py, line 171:
<comment>`intercept_tool_call` drops valid `BaseModel` results to `{}` before persisting task output. In extension chains this can make `tasks/get` return an empty `result` instead of the real tool payload.</comment>
<file context>
@@ -0,0 +1,232 @@
+ now = self._clock()
+ task = self._store.create(now, self._default_ttl_ms)
+ result = await call_next(ctx)
+ payload = result if isinstance(result, dict) else {}
+ # A tool result (even isError: true) is a completed task; `failed` is for
+ # JSON-RPC errors, which surface as a raised MCPError, not a result here.
</file context>
| return await call_next(ctx) | ||
| now = self._clock() | ||
| task = self._store.create(now, self._default_ttl_ms) | ||
| result = await call_next(ctx) |
There was a problem hiding this comment.
P2: A failing tool call leaves a pre-created task permanently stored, causing in-memory task leaks on error paths.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/server/tasks.py, line 170:
<comment>A failing tool call leaves a pre-created task permanently stored, causing in-memory task leaks on error paths.</comment>
<file context>
@@ -0,0 +1,232 @@
+ return await call_next(ctx)
+ now = self._clock()
+ task = self._store.create(now, self._default_ttl_ms)
+ result = await call_next(ctx)
+ payload = result if isinstance(result, dict) else {}
+ # A tool result (even isError: true) is a completed task; `failed` is for
</file context>
| `resultType: "task"` envelope, `execution.taskSupport` gating, and `ttlMs` | ||
| fields — so it lands in a separate PR with the conformance `tasks-*` scenarios | ||
| wired in. | ||
| Task-augmented execution (SEP-2663). A client declares the |
There was a problem hiding this comment.
P3: Docs are inconsistent: the Tasks story is documented as implemented/runnable here, but the stories index still labels tasks/ as "not yet implemented".
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At examples/stories/tasks/README.md, line 3:
<comment>Docs are inconsistent: the Tasks story is documented as implemented/runnable here, but the stories index still labels `tasks/` as "not yet implemented".</comment>
<file context>
@@ -1,24 +1,48 @@
-`resultType: "task"` envelope, `execution.taskSupport` gating, and `ttlMs`
-fields — so it lands in a separate PR with the conformance `tasks-*` scenarios
-wired in.
+Task-augmented execution (SEP-2663). A client declares the
+`io.modelcontextprotocol/tasks` extension; the server may then answer a
+`tools/call` with a `CreateTaskResult` (carrying a task id) instead of blocking,
</file context>
| MISSING_REQUIRED_CLIENT_CAPABILITY = -32003 | ||
| """JSON-RPC error code: a `tasks/*` call from a client that did not declare the extension.""" |
There was a problem hiding this comment.
MISSING_REQUIRED_CLIENT_CAPABILITY redefined as -32003 — schema (and the rest of the SDK) use -32021
The core schema defines MISSING_REQUIRED_CLIENT_CAPABILITY = -32021 (schema.ts L418), and the SDK's own mcp_types.MISSING_REQUIRED_CLIENT_CAPABILITY, the generated MissingRequiredClientCapabilityError.code: Literal[-32021], require_client_extension(), and the shared/inbound.py HTTP-status map all use -32021 for this exact semantic. SEP-2663's prose still prints -32003 in three places, but that predates the spec PR #2907 error-code renumber and was never updated — the schema is the source of truth for codes.
Import the constant from mcp_types (or just call require_client_extension) so _require_tasks_capability() is consistent with the rest of the SDK and the conformance harness; update the module docstring, docs/migration.md, and the test that asserts this code.
| result = await call_next(ctx) | ||
| payload = result if isinstance(result, dict) else {} | ||
| # A tool result (even isError: true) is a completed task; `failed` is for | ||
| # JSON-RPC errors, which surface as a raised MCPError, not a result here. | ||
| self._store.complete(task.task_id, self._clock(), payload) |
There was a problem hiding this comment.
InputRequiredResult is augmented into a task and misreported as completed
An InputRequiredResult reaching the interceptor is stored via _store.complete(...) and reported by tasks/get as status: "completed" with the InputRequiredResult inlined as result — but CompletedTask.result for a tools/call task must be a CallToolResult (SEP-2663 §Task Status).
Per SEP-2663 §Task Creation, MRTR exchanges SHOULD resolve synchronously before CreateTaskResult — task status: "input_required" is a separate inputRequests/tasks/update mechanism, not a mapping for InputRequiredResult. So even with the tasks/update loop deferred, the interceptor should inspect payload.get("resultType") and pass an "input_required" result through unchanged (no task created), letting the MRTR loop run on the original request and augmenting only the eventual CallToolResult.
| def cancel(self, task_id: str, now: str) -> None: | ||
| task = self._tasks[task_id] | ||
| self._tasks[task_id] = task.model_copy(update={"status": "cancelled", "last_updated_at": now}) |
There was a problem hiding this comment.
tasks/cancel clobbers terminal status and drops the completed result
tasks/cancel unconditionally overwrites status to "cancelled" with no terminal-state guard, so a completed task regresses to cancelled and the subsequent tasks/get drops the already-computed result (it only inlines on status == "completed"). In this inline core every reachable task is already terminal when tasks/cancel arrives, so this is the handler's only reachable behavior — and test_cancel_task_... snapshots the broken transition.
Terminal statuses (completed/failed/cancelled) should be immutable: SEP-2663 §Cancellation makes cancel cooperative and ack-only, and explicitly allows a task to "reach a terminal status other than cancelled if the work finished before cancellation could take effect." Guard in _handle_cancel (or TaskStore.cancel) and return the empty ack without mutating a terminal task.
| def _fixed_clock() -> str: | ||
| return "1970-01-01T00:00:00Z" |
There was a problem hiding this comment.
Default clock is a fixed epoch stub, not a real wallclock
The default clock is _fixed_clock, a hard-coded "1970-01-01T00:00:00Z" stub — so every server using the documented Tasks() / Tasks(default_ttl_ms=...) emits the Unix epoch for createdAt/lastUpdatedAt on the wire, breaking client TTL-expiry math and making the SEP-2663 timestamp fields useless.
The default should be a real UTC wallclock (e.g. datetime.now(timezone.utc).isoformat()); the fixed clock belongs only in tests. test_create_task_result_uses_default_clock_when_none_injected currently pins the wrong behaviour and will need updating.
| now = self._clock() | ||
| task = self._store.create(now, self._default_ttl_ms) | ||
| result = await call_next(ctx) | ||
| payload = result if isinstance(result, dict) else {} | ||
| # A tool result (even isError: true) is a completed task; `failed` is for | ||
| # JSON-RPC errors, which surface as a raised MCPError, not a result here. | ||
| self._store.complete(task.task_id, self._clock(), payload) |
There was a problem hiding this comment.
Interceptor mishandles exceptional and non-dict call_next outcomes
intercept_tool_call mishandles non-dict and exceptional call_next outcomes (the carry-forward from #3003):
_store.create()runs beforeawait call_next(ctx)with notry/except, so when the tool call raises (MCPErrorfrom an unknown tool or a handler, aValidationError, cancellation) the task entry leaks in the store atstatus="working"forever, and thefailedstatus the docstring reserves for JSON-RPC errors is unreachable.payload = result if isinstance(result, dict) else {}silently discards aBaseModel(orNone) returned by a nested extension interceptor —HandlerResultisBaseModel | dict | None— so the completed task inlines"result": {}.
Wrap call_next in try/except to transition the task to failed (or drop the entry) on error, and serialize a BaseModel result with model_dump(by_alias=True, mode="json", exclude_none=True) (mirror _dump_result in runner.py) instead of replacing it with {}.
| class TaskStore: | ||
| """In-memory record of tasks and their completed `CallToolResult` payloads.""" | ||
|
|
||
| def __init__(self) -> None: | ||
| self._tasks: dict[str, Task] = {} | ||
| self._results: dict[str, dict[str, Any]] = {} | ||
|
|
||
| def create(self, now: str, ttl_ms: int | None) -> Task: | ||
| # Task IDs are bearer capabilities for tasks/get|cancel, so they need | ||
| # entropy a third party cannot guess or enumerate (SEP-2663 security). | ||
| task_id = f"task_{secrets.token_urlsafe(16)}" | ||
| task = Task(task_id=task_id, status="working", created_at=now, last_updated_at=now, ttl_ms=ttl_ms) | ||
| self._tasks[task_id] = task | ||
| return task | ||
|
|
||
| def complete(self, task_id: str, now: str, result: dict[str, Any]) -> None: | ||
| task = self._tasks[task_id] | ||
| self._tasks[task_id] = task.model_copy(update={"status": "completed", "last_updated_at": now}) | ||
| self._results[task_id] = result | ||
|
|
||
| def cancel(self, task_id: str, now: str) -> None: | ||
| task = self._tasks[task_id] | ||
| self._tasks[task_id] = task.model_copy(update={"status": "cancelled", "last_updated_at": now}) | ||
|
|
||
| def get(self, task_id: str) -> Task | None: | ||
| return self._tasks.get(task_id) | ||
|
|
||
| def result(self, task_id: str) -> dict[str, Any] | None: | ||
| return self._results.get(task_id) |
There was a problem hiding this comment.
[nit] TaskStore grows unboundedly; ttlMs never enforced
TaskStore only ever inserts into _tasks/_results and never removes entries, and the single Tasks instance lives for the server's lifetime — so every augmented tools/call permanently retains its full CallToolResult payload. ttl_ms is stamped on the wire but never enforced server-side, so Tasks(default_ttl_ms=60_000) as shown in the story is advisory-only.
For a long-running HTTP server this is unbounded growth (the store-growth/cleanup concern carried forward from #3003). Worth at least a TTL-based eviction on tasks/get/create, a size bound, or an explicit "no eviction — deferred" note in the module docstring alongside the other deferrals.
| def methods(self) -> Sequence[MethodBinding]: | ||
| return [ | ||
| MethodBinding("tasks/get", GetTaskRequestParams, self._handle_get), | ||
| MethodBinding("tasks/cancel", CancelTaskRequestParams, self._handle_cancel), | ||
| ] |
There was a problem hiding this comment.
[nit] tasks/get / tasks/cancel not version-scoped; legacy clients get the missing-capability error instead of -32601
These bindings omit protocol_versions, so tasks/get/tasks/cancel are served on legacy (≤2025-11-25) connections too. There _require_tasks_capability returns the missing-capability error with a requiredCapabilities: {extensions: ...} payload the client structurally cannot satisfy — since the extension "only exists on the modern wire" (and SEP-2663 §Backward Compatibility says it "is not defined under the 2025-11-25 protocol version"), these should be -32601 instead. Add protocol_versions=frozenset(MODERN_PROTOCOL_VERSIONS) to both bindings.
Summary
The SEP-2663 Tasks extension (
io.modelcontextprotocol/tasks) — the conformant core. Built on the extension API from #3003 (this PR is based on that branch; merge after it).SEP-2663 (Final) is wire-incompatible with the 2025-11-25 in-core Tasks design still carried (types-only) in
mcp_types, so the extension defines its own SEP-2663-shaped models.What's implemented (conformant core)
tools/callas a task. The legacyparams.taskfield is ignored (it is not the opt-in). Only a client that declared the extension on a modern (2026-07-28) connection is augmented — a legacy handshake cannot carry the capability back, so it is never augmented.tools/callreturns a flatCreateTaskResult(resultType: "task",taskId/status/createdAt/lastUpdatedAt/ttlMs).tasks/getreturns aDetailedTask(resultType: "complete"); a completed task inlines the originalCallToolResult. A tool result withisError: trueis a completed task (failedis reserved for JSON-RPC errors).tasks/cancelis an empty ack.tasks/resultis not registered →-32601(removed in SEP-2663). Atasks/*call from a non-declaring client →-32003with arequiredCapabilitiespayload. Task ids are entropy-bearing bearer capabilities.Ships a runnable
tasksstory and a migration note.Deferred to follow-ups
Each needs deeper SDK plumbing and is called out in the module/README/migration:
tasks/update+ the MRTRinput_requiredloopToolExecution.taskSupportgating with the-32021required-task errornotifications/tasksThese map to the remaining conformance
tasks-*scenarios; the core targetstasks-dispatch-and-envelope,tasks-capability-negotiation,tasks-wire-fields,tasks-lifecycle(partial),tasks-request-state-removal.Testing
14 spec-derived in-memory
Client(server)tests, 100% coverage oftasks.py,strict-no-coverclean, pyright + ruff + markdownlint green, both story legs (in-memory + http-asgi) pass.AI Disclaimer
This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.