Skip to content

feat: Tasks extension for server and client (ext/tasks subpaths) - #2782

Draft
mattzcarey wants to merge 6 commits into
feat/server-extensionsfrom
feat/pluggable-task-workflow
Draft

mattzcarey wants to merge 6 commits into
feat/server-extensionsfrom
feat/pluggable-task-workflow

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Draft, second of a stack on #2820 (server extensions). Also carries #2599 merged in, because tasks/get and tasks/cancel were 2025-11-25 core methods and are unreachable on the 2026-07-28 era without its explicit-schema era-gate exemption. Review the last commit only; the rest is the base.

What this adds

The Tasks extension (io.modelcontextprotocol/tasks, SEP-2663) as a pair of extensions: @modelcontextprotocol/server/ext/tasks (a ServerExtension) and @modelcontextprotocol/client/ext/tasks (a ClientExtension).

const store = new InMemoryTaskStore();
const tasks = new TasksExtension(store);
const server = new McpServer(info, { extensions: [tasks] });

server.registerTool('send_report', { inputSchema }, async ({ to }, ctx) => {
    const task = await tasks.create(ctx, { ttlMs: 3_600_000 });
    void sendReport(store.handle(task.taskId), to); // the server's own execution
    return task;
});

The SDK owns the wire, nothing else. TasksExtension advertises the capability, serves tasks/get / tasks/update / tasks/cancel as explicit-schema custom methods, refuses requests that did not declare the extension (-32021), binds tasks to the request principal, and gives a tool handler tasks.create(ctx) to answer with a flat CreateTaskResult (resultType: "task", forwarded verbatim by the 2026 result encoder). tools/call middleware gates task handles minted some other way (an external engine's own create) on the same capability.

The server owns the task. TaskStore is four methods over JSON — create, get, update, cancel — that a server implements over whatever holds its task state. How the work behind a task runs (a queue, a workflow engine, a durable-execution runtime) is invisible to the SDK and deliberately unspecified. InMemoryTaskStore is the in-process reference; it adds a writer handle(taskId) (status, requireInput, complete, fail, an abort signal) so a plain async function can drive a task, and records an opaque context per task for an execution to pick up.

The client half. new TasksClientExtension() in ClientOptions.extensions declares the capability on every request, accepts resultType: "task" on tools/call (via acceptResultType from #2820), and wraps the extension's methods:

const tasks = new TasksClientExtension();
const client = new Client(info, { versionNegotiation: { mode: 'auto' }, extensions: [tasks] });

const outcome = await tasks.callTool({ name: 'send_report', arguments: { to: 'ops' } });
if (outcome.kind === 'task') {
    const done = await tasks.waitFor(outcome.task.taskId, {
        onUpdate: task => { if (task.status === 'input_required') void tasks.update(task.taskId, answers); }
    });
}

callTool yields { kind: 'task', task } or { kind: 'result', result }; get / update / cancel are the three methods one to one; waitFor polls tasks/get at the server's pollIntervalMs until a terminal snapshot, reporting every snapshot through onUpdate, and stops on abort without touching the task.

Wire types and zod schemas are authored against ext-tasks 2026-07-28, live at @modelcontextprotocol/core-internal/ext/tasks, and are re-exported from both subpaths.

Tests

packages/server/test/ext/tasks (10) and packages/client/test/ext/tasks (4); full suites core-internal 1464, server 497, client 887, all green.

  • tasks.e2e.test.ts — a real Client with TasksClientExtension against the stateless createMcpHandler (fresh McpServer per request, the store as the only shared state): capability advertised, task handle on tools/call, waitFor to completion with status, input_requiredtasks/update → result, failure, cooperative cancel via the handle signal, -32602 unknown task, -32021 as a JSON-RPC error from tasks/*, from tasks.create, and from the tools/call middleware.
  • tasksClientExtension.test.ts — one instance per client, the capability on every request, task handle vs plain result split, waitFor polling at the task interval through workinginput_requiredcompleted, abort stops polling without cancelling.
  • inMemoryStore.test.ts — durable create with context, partial answers accumulate and the first answer wins, cancel rejects a pending input wait and later writes are ignored, TTL purge, principal fail-closed, argument validation.

SDK interactions worth knowing about

  1. inputResponses is a reserved multi-round-trip name. The protocol layer lifts it out of every client request's params on the 2026 era and surfaces it at ctx.mcpReq.inputResponses. tasks/update uses the same name, so the handler reads it back from the context (handler-side schema optional, wire schema required). Possibly worth a note in the extension spec.
  2. The Client rejects resultType: "task" on tools/call ([v2] Tasks extension: tools/call rejects CreateTaskResult but accepts an omitted discriminator as complete #2637) unless an extension declared it with acceptResultType, which the client extension does. The plain client.callTool still rejects a task handle, correctly: a CallToolResult schema cannot describe one. ext-tasks#21 (@modelcontextprotocol/ext-tasks) is a separate, richer requester; this client half is the minimal one that pairs with the server half.
  3. notifications/tasks over subscriptions/listen is not implemented (subscriptions/listen cannot carry extension notifications (blocks notifications/tasks) #2569); polling only.

Design notes

  • An earlier revision of this PR shipped a workflow layer (registerTask with a replayable step.do / sleep / elicit API and an in-memory execution engine). That is out: execution is the server's concern, and the spec-level story is only the API shape. The workflow layer lives on in durable-mcp-server, which becomes one TaskStore implementation among many.
  • No new package: a subpath of @modelcontextprotocol/server so extensions have a home next to the server they extend.

Refs #2189, #2598, #2637, #2569. Requires #2820 and #2599.

@changeset-bot

changeset-bot Bot commented Sep 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 100decd

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/client Minor
@modelcontextprotocol/server Minor
@modelcontextprotocol/core-internal Minor
@modelcontextprotocol/codemod Minor
@modelcontextprotocol/core Minor
@modelcontextprotocol/server-legacy Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Sep 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2782

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2782

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2782

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2782

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2782

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2782

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2782

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2782

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2782

commit: 100decd

@mattzcarey mattzcarey changed the title feat(tasks): server-side Tasks extension package with a pluggable execution engine feat(server): Tasks extension at @modelcontextprotocol/server/ext/tasks with a pluggable execution engine Sep 11, 2026
@mattzcarey
mattzcarey force-pushed the feat/pluggable-task-workflow branch from 774c3f9 to b04649e Compare September 17, 2026 07:24
@mattzcarey
mattzcarey changed the base branch from main to feat/server-extensions September 17, 2026 07:24
@mattzcarey mattzcarey changed the title feat(server): Tasks extension at @modelcontextprotocol/server/ext/tasks with a pluggable execution engine feat(server): Tasks extension at @modelcontextprotocol/server/ext/tasks Sep 17, 2026
@mattzcarey mattzcarey changed the title feat(server): Tasks extension at @modelcontextprotocol/server/ext/tasks feat: Tasks extension for server and client (ext/tasks subpaths) Sep 17, 2026
@mattzcarey
mattzcarey added this pull request to stack #2826 September 17, 2026 14:18
@mattzcarey
mattzcarey force-pushed the feat/pluggable-task-workflow branch from 46c22c6 to b6f148b Compare September 17, 2026 14:19
freya0926 and others added 6 commits September 17, 2026 16:20
…-universe gate

The inbound and outbound era gates rejected any method name that ever
appeared in a past protocol revision's registry but is absent from the
current era's registry, even when the consumer explicitly registered a
handler (or supplied a schema on send) for it. This made extension
methods that reuse a historical core method name unreachable: the Tasks
extension (SEP-2663) defines `tasks/get` and `tasks/cancel`, both of
which the 2025-11-25 revision used for now-removed core methods, so a
2026-era server could never serve them and a 2026-era client could
never send them — every attempt answered -32601 or threw
MethodNotSupportedByProtocolVersion before the handler or the transport
were ever consulted.

Both gates now only apply to TYPED dispatch (setRequestHandler(method,
handler) inbound, request(method, options) outbound) — exactly the path
the SDK's own built-ins (initialize, ping, logging/setLevel) use, which
correctly stays era-gated. A method registered or sent with an EXPLICIT
schema (setRequestHandler(method, schemas, handler) /
request(request, resultSchema, options)) is the extension-authoring
path: the consumer supplied their own validation, so a historical
registry collision no longer blocks it.

Fixes #2598
TasksExtension is the server side of io.modelcontextprotocol/tasks as a
ServerExtension: it advertises the capability, serves tasks/get,
tasks/update and tasks/cancel, gates task handles on the client
capability (-32021, including through a tools/call override for handles
minted outside the extension), and offers tasks.create(ctx) for a tool
handler to answer with a task handle.

TaskStore (create / get / update / cancel over JSON) is the seam a server
implements over its own state and execution; how the work runs is the
server's own. InMemoryTaskStore is the in-process reference with a writer
handle (status, requireInput, complete, fail, cancel signal). Wire types
and zod schemas for the extension's 2026-07-28 schema are exported.

Stacked on the server extensions seam and on #2599 (explicit-schema
handlers escape the era gate), which tasks/get and tasks/cancel need.
TasksClientExtension declares io.modelcontextprotocol/tasks on every
request, accepts task handles on tools/call through acceptResultType, and
wraps the extension's methods: callTool (a task handle or the plain
result), get, update, cancel, and waitFor, which polls at the server's
suggested interval to a terminal snapshot.

The wire types and zod schemas move to
@modelcontextprotocol/core-internal/ext/tasks, shared by both halves and
re-exported from each subpath. The server e2e test now drives tools/call
through the client extension instead of a raw POST.
@mattzcarey
mattzcarey force-pushed the feat/pluggable-task-workflow branch from b6f148b to 100decd Compare September 17, 2026 14:23

This branch has not been deployed

No deployments
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.

2 participants