Repository navigation
Support the MCP 2026-07-28 tasks extension on top of the SDK (learnings from #423) #430
Description
Context
#423 implemented the MCP 2026-07-28 tasks extension (io.modelcontextprotocol/tasks, SEP-2663) by hand on top of SDK 2.0.0, with a transport shim to get around the SDK's era gates. The TypeScript SDK has since started landing the extension upstream, so #423 is dropped and the feature should be rebuilt on the SDK's primitives. This issue records what #423 learned so the next implementation does not rediscover it. The branch claude/dazzling-fermi-dbdfqb (commits 11d572a, 0471e11) stays as reference; the tests, output formatting and docs in it are reusable almost verbatim, the shim is not.
What the SDK gives you, and what it still does not
Checked against the published packages, not just the release notes (npm install @modelcontextprotocol/{client,core,server}@2.3.0 and grep dist/):
tasks/getandtasks/cancelare exempt from the outbound and inbound era gate on a 2026-07-28 connection, but only via the explicit-schema overload:client.request({ method: 'tasks/get', params }, resultSchema, options)andserver.setRequestHandler('tasks/get', { params, result }, handler). The typedrequest(method, options)overload still refuses them.tasks/updateis not exempt (isExtensionReusedRequestMethodlists exactly two names);tasks/resultandtasks/liststay legacy-only, as the spec intends.- The Streamable HTTP client transport now sets
Mcp-Name: <taskId>fromparams.taskIdfortasks/get,tasks/update,tasks/cancel(mcpNameSourcetable), and still treatsmcp-nameas a reserved header a caller cannot set per request. The shim'swithTaskRoutingHeaderfetch wrapper is therefore obsolete; keep the e2e assertion that the header arrives (/control/get-task-routingon the test server). - Not shipped: any
io.modelcontextprotocol/tasksstring,ttlMs/pollIntervalMs,resultType: "task"decoding, the capability declaration,notifications/tasks,taskIdsonsubscriptions/listen. The onlyTasktypes exported are still the 2025-11-25 core shapes (ttl,pollInterval,tasks/result), and the migration docs call them "deprecated wire vocabulary". - The 2026 result decoder (core-internal
decodeResult) still rejects everyresultTypeother thancompleteandinput_requiredbefore the caller's result schema runs, withSdkErrorCode.UnsupportedResultType. Atools/callanswered withresultType: "task"is lost inside the SDK unless something intercepts it. There is a protected seam,Protocol._resolveNonCompleteResult(decoded, flow)("extension point for non-completedecoded results"), but the decoder only producescomplete,input_requiredorinvalid, sotasknever reaches it today. client.callTool()still throws when a tool has anoutputSchemaand the result has nostructuredContentunlessisErroris set, and it still knows nothing abouttasks/*.
Draft PR #2782 "feat: Tasks extension for server and client (ext/tasks subpaths)" is the real implementation, unreleased at the time of writing. Its shape is what mcpc should target when it ships: TasksClientExtension (a ClientExtension) with callTool() returning { kind: 'task', task } | { kind: 'result', result }, get, update, cancel, waitFor(taskId, { onUpdate }) polling at the server's pollIntervalMs; server side TasksExtension, TaskStore/InMemoryTaskStore, tasks.create(ctx) for tool handlers, ttlMs. It explicitly keeps polling only ("subscriptions/listen cannot carry extension notifications", notifications/tasks not implemented), which matches what #423 concluded independently.
Decision for the implementer: if #2782 has been released, build on TasksClientExtension and delete everything below under "shim". If not, 2.3.0 lets you drop two of the three shim concerns (method alias, Mcp-Name fetch wrapper) but you still need the CreateTaskResult interception, either by overriding _resolveNonCompleteResult once the decoder learns task, or by the transport-level onmessage rewrite #423 used (src/core/tasks-transport-shim.ts, placeholder CallToolResult with isError: true carrying the task in _meta, recognised by object identity so a server cannot forge it). Check the SDK version first; do not assume.
Protocol facts #423 had to get right
These are spec behaviours, independent of the SDK:
- Server-directed creation. The client never asks for a task on 2026-07-28. Any plain
tools/callmay come back asresultType: "task", and a server may ignore--task/--detachintent and answer synchronously. The client's only lever is declaring the extension incapabilities.extensions["io.modelcontextprotocol/tasks"], which rides every request's_meta["io.modelcontextprotocol/clientCapabilities"]. Servers rejecttools/callfrom a client that did not declare it with-32021; the e2e server enforces this. CreateTaskResultis a flatTaskplusresultType: "task"(taskId,status,createdAt,lastUpdatedAt,ttlMs,pollIntervalMs?,statusMessage?), not the 2025{ task: {...} }wrapper.tasks/getreturns the task with its outcome inlined once terminal:result(aCallToolResult) whencompleted,error({ code, message, data? }) whenfailed,inputRequestswheninput_required. There is notasks/result;tasks-resultis "polltasks/getuntil terminal, then unwrap". Validate the inlinedresultwithCallToolResultSchema['~standard'].validatebefore rendering it.- No
tasks/list. mcpc keeps its own per-session record (sessions.json→activeTasks, already there for crash recovery) andtasks-liston a modern connection resolves each recorded id throughtasks/get. Prune a record only when the server answers JSON-RPC-32602(unknown task); a timeout, auth failure or malformed answer must fail the listing and keep the record (Copilot caught Support the MCP 2026-07-28 tasks extension #423 pruning on any error). - Cooperative cancel.
tasks/cancelreturns an empty acknowledgement, not the task; the task may stayworkingfor a moment. Support the MCP 2026-07-28 tasks extension #423 followed the ack with atasks/getand printed that status: exit 0 while pending, exit 2 only if the task had already completed or failed. Mcp-Name: <taskId>is a MUST ontasks/get,tasks/update,tasks/cancelover Streamable HTTP (2.3.0 now does it; still assert it in e2e).- Durations are
ttlMs/pollIntervalMs; the 2025 core usesttl/pollIntervalin seconds. Clamp the poll interval (Support the MCP 2026-07-28 tasks extension #423 used 100 ms to 5 min, default 2 s):setTimeoutabove 2^31-1 ms fires immediately and would hammer the server. input_requiredmeans the task waits for elicitation/sampling answers. mcpc never answers those (no LLM, no prompting), so report "waiting for: " and stop; do not spin.- Double declaration on legacy connections is harmless: declare the 2025
tasks: { list: {}, cancel: {} }and the extension entry; each era reads its own.
CLI decisions that were made and reviewed (keep them)
tools-callwith no flags on a modern server that returns a task: follow it to completion transparently and print the tool result. The user never sees the task unless they asked.--task: same spinner / Ctrl+C-detach UX; on modern it means "show progress if this becomes a task".--detach: printsTask started: <id>plus the next-step hint; with--jsonprints the whole createdTask(not{ taskId, status }). On a modern server that answered synchronously, print the tool result with an info line explaining why (nothing to detach from), so scripts check fortaskId.- Both flags fail up front when the server has no task support, in either era, instead of silently running synchronously (they change the output shape). Modern check: extension declared in server capabilities; legacy check:
capabilities.tasks.requests.tools.call. tasks-liston modern prints "Tasks started from this session (N):" with a dim note that 2026-07-28 servers keep no listing.- Era-aware hints and capability lists:
tasks (extension)on modern; on a legacy connection to a server that also declares the extension,tasks (extension, not usable on MCP 2025-11-25); offertasks-*in the commands block only when the era can serve them (isModernProtocolVersion()fromsrc/core/protocol.ts). - Type discriminator:
isExtensionTask(task)must decide onttlMsalone. The extension parser passes unknown fields through, so a modern task may carry a strayttland must not be misclassified as legacy. - Error messages live in
src/core/protocol.ts(tasksNotDeclaredMessage,tasksListUnavailableMessage,taskInputRequiredMessage,tasksUnsupportedByServerMessage), no trailing periods, each naming the recovery command.
Bridge and x402 interplay
- A detached call travels through IPC as
DetachedToolCall = { task?: AnyTask; result?: CallToolResult }. The x402 layer insrc/bridge/index.ts(handlePaymentRequiredRetry,withSettlementReceipt) must unwrap.resultfor a detached call and re-wrap afterwards, otherwise a payment challenge answered synchronously by a modern server is rendered instead of signed and retried (Copilot finding, fixed in0471e11). releaseFinishedTaskmust be a no-op on modern connections (tracksTasksLocally()), otherwisetasks-listforgets a task the moment it completes.
Tests worth porting as-is
test/e2e/server/index-v2.ts: an HTTP-level tasks layer (serveTasksExtension) in front of the SDK v2 server handler, because the 2.0.0 server also refused the extension's methods andresultType: "task"from handlers. With 2.3.0 the handler can registertasks/get/tasks/cancelvia the explicit-schemasetRequestHandler;tasks/updateand theresultType: "task"answer totools/callstill need the front layer until #2782 lands. KeepNO_TASKS=true(withhold the extension) and/control/get-task-routing(recordsMcp-Nameof the latesttasks/*requests).startSlowTask({ needsInput })parks a task ininput_required.test/e2e/suites/sessions/tasks-extension.test.sh(modern only,require_server_protocol modern): server-directed creation on a plaintools-call, the poll loop and inlined result,--detach --jsonwhole-Task shape,tasks-listfrom the record,Mcp-Nameassertion, cooperative cancel exit codes,input_requiredmessage,-32021when the client does not declare.test/e2e/suites/sessions/async-tasks.test.shruns the shared command surface in both eras;sessions/tasks-unsupported.test.shusesNO_TASKS=truewith era-specific expected messages.- Unit:
test/unit/core/tasks-schema.test.ts(validators: strict ontaskId/status/timestamps/resultwhen completed, lenientttlMsnormalised tonull, placeholdererrorforfailedwithout one),test/unit/core/mcp-client.test.ts(poll clamp incl. 2^40 andInfinity,-32602pruning predicate,input_requiredabort),test/unit/lib/task-types.test.ts(ttlMsdiscriminator with strayttl),test/unit/cli/output.test.ts(formatTask/formatTasksfor both shapes). test/e2e/suites/basic/help.test.shhad a pre-existingprintf | grep -qunderpipefailflake;0471e11replaced it with a[[ "$STDOUT" != *"JSON output (--json):"* ]]substring check. Keep that.- Conformance: no upstream client scenario for the extension yet;
test/conformance/README.mdin the branch documents the gap and what the e2e suite covers instead.
Docs that need the same edits again
README feature table, extension table and "Async tasks" section with era bullets; skills/mcpc/SKILL.md async-tasks block (--detach prints the ID, --json the whole Task; tasks-list on 2026-07-28 lists the session's tasks); CHANGELOG.md Added/Changed entries (the --detach --json shape change and cooperative cancel are user-visible); CLAUDE.md era-dependent behaviour paragraph and the sessions.json note; docs/REFERENCE.md via pnpm run build:reference. Help text: tools-call "Async tasks" notes, tasks-list "(see below)" notes, jsonHelp() shapes shared by tasks-get/tasks-result/tasks-cancel.
Checklist for the SDK-based implementation
- Bump
@modelcontextprotocol/client/core(andserver/nodefor the e2e server) to a version that ships the tasks extension, or to ≥ 2.3.0 if building the interception by hand; verify by greppingdist/forio.modelcontextprotocol/tasks, not by the release notes - Declare the extension in client capabilities (
src/core/extensions.tsentrysupport: 'full', declaredByClient: true) - Follow a task answer to
tools/callon the plain,--taskand--detachpaths;--detachmay return a result -
tasks-get/tasks-result/tasks-cancelovertasks/get+tasks/cancelwith the inlined outcome and cooperative cancel semantics -
tasks-listfromsessions.jsonactiveTasks, prune only on-32602 - Poll at
pollIntervalMs, clamped; stop oninput_required - x402 unwrap/re-wrap for detached calls
- Era-aware help, hints, capability list, error messages
- Port the e2e suites and unit tests from
claude/dazzling-fermi-dbdfqb; droptasks-transport-shim.tsand its test - Docs: README, SKILL.md, CHANGELOG, CLAUDE.md, REFERENCE.md, conformance README
Refs #423, #401 (the skills extension, same "ahead of the SDK" pattern)
Activity
Shorten the task help text
Make the task record atomic and keep the receipt of a detached paid call
Follow a task handed out already finished through tasks/get
Support the io.modelcontextprotocol/tasks extension (MCP 2026-07-28) (a…