Skip to content

Support the MCP 2026-07-28 tasks extension on top of the SDK (learnings from #423) #430

Closed

Description

@jancurn

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/):

SDK 2.3.0 (release, #2599)

  • tasks/get and tasks/cancel are 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) and server.setRequestHandler('tasks/get', { params, result }, handler). The typed request(method, options) overload still refuses them. tasks/update is not exempt (isExtensionReusedRequestMethod lists exactly two names); tasks/result and tasks/list stay legacy-only, as the spec intends.
  • The Streamable HTTP client transport now sets Mcp-Name: <taskId> from params.taskId for tasks/get, tasks/update, tasks/cancel (mcpNameSource table), and still treats mcp-name as a reserved header a caller cannot set per request. The shim's withTaskRoutingHeader fetch wrapper is therefore obsolete; keep the e2e assertion that the header arrives (/control/get-task-routing on the test server).
  • Not shipped: any io.modelcontextprotocol/tasks string, ttlMs/pollIntervalMs, resultType: "task" decoding, the capability declaration, notifications/tasks, taskIds on subscriptions/listen. The only Task types 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 every resultType other than complete and input_required before the caller's result schema runs, with SdkErrorCode.UnsupportedResultType. A tools/call answered with resultType: "task" is lost inside the SDK unless something intercepts it. There is a protected seam, Protocol._resolveNonCompleteResult(decoded, flow) ("extension point for non-complete decoded results"), but the decoder only produces complete, input_required or invalid, so task never reaches it today.
  • client.callTool() still throws when a tool has an outputSchema and the result has no structuredContent unless isError is set, and it still knows nothing about tasks/*.

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/call may come back as resultType: "task", and a server may ignore --task/--detach intent and answer synchronously. The client's only lever is declaring the extension in capabilities.extensions["io.modelcontextprotocol/tasks"], which rides every request's _meta["io.modelcontextprotocol/clientCapabilities"]. Servers reject tools/call from a client that did not declare it with -32021; the e2e server enforces this.
  • CreateTaskResult is a flat Task plus resultType: "task" (taskId, status, createdAt, lastUpdatedAt, ttlMs, pollIntervalMs?, statusMessage?), not the 2025 { task: {...} } wrapper.
  • tasks/get returns the task with its outcome inlined once terminal: result (a CallToolResult) when completed, error ({ code, message, data? }) when failed, inputRequests when input_required. There is no tasks/result; tasks-result is "poll tasks/get until terminal, then unwrap". Validate the inlined result with CallToolResultSchema['~standard'].validate before rendering it.
  • No tasks/list. mcpc keeps its own per-session record (sessions.json → activeTasks, already there for crash recovery) and tasks-list on a modern connection resolves each recorded id through tasks/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/cancel returns an empty acknowledgement, not the task; the task may stay working for a moment. Support the MCP 2026-07-28 tasks extension #423 followed the ack with a tasks/get and printed that status: exit 0 while pending, exit 2 only if the task had already completed or failed.
  • Mcp-Name: <taskId> is a MUST on tasks/get, tasks/update, tasks/cancel over Streamable HTTP (2.3.0 now does it; still assert it in e2e).
  • Durations are ttlMs/pollIntervalMs; the 2025 core uses ttl/pollInterval in seconds. Clamp the poll interval (Support the MCP 2026-07-28 tasks extension #423 used 100 ms to 5 min, default 2 s): setTimeout above 2^31-1 ms fires immediately and would hammer the server.
  • input_required means 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-call with 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: prints Task started: <id> plus the next-step hint; with --json prints the whole created Task (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 for taskId.
  • 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-list on 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); offer tasks-* in the commands block only when the era can serve them (isModernProtocolVersion() from src/core/protocol.ts).
  • Type discriminator: isExtensionTask(task) must decide on ttlMs alone. The extension parser passes unknown fields through, so a modern task may carry a stray ttl and 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 in src/bridge/index.ts (handlePaymentRequiredRetry, withSettlementReceipt) must unwrap .result for 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 in 0471e11).
  • releaseFinishedTask must be a no-op on modern connections (tracksTasksLocally()), otherwise tasks-list forgets 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 and resultType: "task" from handlers. With 2.3.0 the handler can register tasks/get/tasks/cancel via the explicit-schema setRequestHandler; tasks/update and the resultType: "task" answer to tools/call still need the front layer until #2782 lands. Keep NO_TASKS=true (withhold the extension) and /control/get-task-routing (records Mcp-Name of the latest tasks/* requests). startSlowTask({ needsInput }) parks a task in input_required.
  • test/e2e/suites/sessions/tasks-extension.test.sh (modern only, require_server_protocol modern): server-directed creation on a plain tools-call, the poll loop and inlined result, --detach --json whole-Task shape, tasks-list from the record, Mcp-Name assertion, cooperative cancel exit codes, input_required message, -32021 when the client does not declare.
  • test/e2e/suites/sessions/async-tasks.test.sh runs the shared command surface in both eras; sessions/tasks-unsupported.test.sh uses NO_TASKS=true with era-specific expected messages.
  • Unit: test/unit/core/tasks-schema.test.ts (validators: strict on taskId/status/timestamps/result when completed, lenient ttlMs normalised to null, placeholder error for failed without one), test/unit/core/mcp-client.test.ts (poll clamp incl. 2^40 and Infinity, -32602 pruning predicate, input_required abort), test/unit/lib/task-types.test.ts (ttlMs discriminator with stray ttl), test/unit/cli/output.test.ts (formatTask/formatTasks for both shapes).
  • test/e2e/suites/basic/help.test.sh had a pre-existing printf | grep -q under pipefail flake; 0471e11 replaced it with a [[ "$STDOUT" != *"JSON output (--json):"* ]] substring check. Keep that.
  • Conformance: no upstream client scenario for the extension yet; test/conformance/README.md in 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 (and server/node for the e2e server) to a version that ships the tasks extension, or to ≥ 2.3.0 if building the interception by hand; verify by grepping dist/ for io.modelcontextprotocol/tasks, not by the release notes
  • Declare the extension in client capabilities (src/core/extensions.ts entry support: 'full', declaredByClient: true)
  • Follow a task answer to tools/call on the plain, --task and --detach paths; --detach may return a result
  • tasks-get/tasks-result/tasks-cancel over tasks/get + tasks/cancel with the inlined outcome and cooperative cancel semantics
  • tasks-list from sessions.json activeTasks, prune only on -32602
  • Poll at pollIntervalMs, clamped; stop on input_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; drop tasks-transport-shim.ts and 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)

https://claude.ai/code/session_01HXvpsSa4d6RkPP4FqhuDak

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions