Skip to content

feat(server): Tasks extension at @modelcontextprotocol/server/ext/tasks with a pluggable execution engine - #2782

Draft
mattzcarey wants to merge 5 commits into
mainfrom
feat/pluggable-task-workflow
Draft

feat(server): Tasks extension at @modelcontextprotocol/server/ext/tasks with a pluggable execution engine#2782
mattzcarey wants to merge 5 commits into
mainfrom
feat/pluggable-task-workflow

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Draft / RFC. Opening this to discuss where the server half of the Tasks extension should live and what the pluggable-engine seam looks like. Not asking for a merge yet — see "Open questions".

What this adds

A new subpath export, @modelcontextprotocol/server/ext/tasks: the server side of the Tasks extension (io.modelcontextprotocol/tasks, SEP-2663) for @modelcontextprotocol/server, with the execution engine pluggable.

const engine = new InMemoryTaskEngine();

function createServer() {
    const server = new McpServer({ name: 'report-server', version: '1.0.0' });
    const tasks = installTasks(server, { engine });
    tasks.registerTask('send_report', { inputSchema: z.object({ to: z.string() }) }, async (input, step) => {
        const report = await step.do('fetch-data', () => fetchReportData(input.to));
        await step.sleep('cool-off', '5s');
        await step.do('send', { retries: { limit: 10 } }, () => sendReport(input.to, report));
        return { content: [{ type: 'text', text: `report sent to ${input.to}` }] };
    });
    return server;
}
  • installTasks(server, { engine }) serves tasks/get, tasks/update, tasks/cancel as explicit-schema custom methods (server.server.setRequestHandler(method, { params }, …)), advertises the extension capability once a task exists, and returns registerTask.
  • registerTask is registerTool for long-running work: the tool answers a flat CreateTaskResult (resultType: "task") and the handler runs as a replayable workflow against a Step API: do (journaled closure with retries and timeout), sleep / sleepUntil, elicit (→ input_required, answered by tasks/update, optional deadline), offer + checkInput (a standing non-blocking input channel), status.
  • Two seams keep handler code engine-invariant. TaskEngine is the control seam (create / get / update / cancel, plus an optional attach(executor) for engines that run handlers in-process). StepJournal is the step seam (what ReplayStep drives: beginStep / completeStep / failStep / recordSleep / recordElicit / recordOffer / checkInput / setStatus / checkCancel, generation-fenced with StaleLeaseError).
  • InMemoryTaskEngine is the in-process reference implementation: records and journals in a Map, one timer per task computed from the rows, TTL purge, elicit deadlines, principal binding. Nothing durable ships in the SDK. A durable engine (a database plus workers, Durable Objects, a durable-execution runtime) implements the same two interfaces and lives outside the SDK; swapping engines changes the installTasks call and nothing else.

The step and journal layers are ported from durable-mcp-server (Cloudflare Durable Objects engine, on npm as durable-mcp-server@0.1.0), which would become the first external engine. The wire types and zod schemas are authored against ext-tasks 2026-07-28 (dcc8d2b).

Tests

packages/server/test/ext/tasks: 15 tests (the full server suite is 497 green).

  • tasks.e2e.test.ts — a real Client against the stateless createMcpHandler (fresh McpServer per request, the engine as the only shared state): task handle on tools/call, do/sleep/status with replay verified (the closure runs once across the suspend), input_requiredtasks/update → result, step retries with the task policy, NonRetryableError, handler throw → isError result, cooperative cancel, -32602 unknown task, -32021 without the extension capability.
  • inMemoryEngine.test.ts — journal semantics a durable engine must reproduce, against a scripted executor: durable create, replay hits and the latest suspension boundary, StaleLeaseError fencing, blocking input, elicit timeout, offers (never block, answer cuts a pending sleep, consume-once), cancel idle vs running, TTL purge, principal fail-closed.

SDK interactions worth knowing about

  1. Stacked on fix(core-internal): let explicit-schema handlers/calls escape the era-universe gate #2599. tasks/get and tasks/cancel were 2025-11-25 core methods, so the era gate answers -32601 before an explicit-schema handler is consulted (v2: extension methods shadowed by legacy spec-method registry — custom tasks/get & tasks/cancel handlers unreachable (-32601 before handler lookup) #2598). This branch merges fix(core-internal): let explicit-schema handlers/calls escape the era-universe gate #2599 so the handlers are reachable; the first two commits are that PR.
  2. inputResponses is a reserved MRTR name. The protocol layer lifts inputResponses out of every client request's params on the 2026-07-28 era and surfaces it at ctx.mcpReq.inputResponses. tasks/update uses the same name, so the handler reads it back from the context (its handler-side schema makes the field optional; the wire schema keeps it required). Documented in docs/servers/tasks.md; possibly worth a note in the extension spec.
  3. tools/call throws become isError results. The -32021 refusal for a non-declaring client therefore reaches the caller as a tool error rather than a JSON-RPC error. A router in front of the SDK can enforce the spec'd -32021 / HTTP 400 (durable-mcp-server does); the SDK-level option would be to let MissingRequiredClientCapabilityError pass through tool dispatch the way UrlElicitationRequiredError does.
  4. The Client rejects resultType: "task" on tools/call ([v2] Tasks extension: tools/call rejects CreateTaskResult but accepts an omitted discriminator as complete #2637). The e2e test posts tools/call raw for that reason; every other method goes through the real Client. The requester half of the extension is ext-tasks#21 (@modelcontextprotocol/ext-tasks), which has no server side — this package is the complementary half.
  5. notifications/tasks over subscriptions/listen is not implemented (blocked by subscriptions/listen cannot carry extension notifications (blocks notifications/tasks) #2569); polling only.

Open questions

  • Home. It lives in the server package as a subpath (@modelcontextprotocol/server/ext/tasks, source under packages/server/src/ext/tasks) so extensions have a home next to the server they extend without a new package per extension. The alternative is a server sub-package of @modelcontextprotocol/ext-tasks in the ext-tasks repo once params required to be specified even when empty #21 lands. The SDK's own tasksPlugin sketch on fweinberger/f3-tasks-replace suggests there is appetite for an SDK-side hook.
  • Whether the Step surface should be narrower for a first release (do / sleep / elicit / status, with offer / checkInput held back).

Refs #2189, #2598, #2637, #2569.

freya0926 and others added 4 commits August 2, 2026 00:30
…-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
…cution engine

Adds @modelcontextprotocol/tasks: installTasks(server, { engine }) serves
tasks/get, tasks/update and tasks/cancel as explicit-schema custom methods
and returns registerTask, which runs a tool handler as a replayable
workflow against a Step API (do, sleep, sleepUntil, elicit, offer,
checkInput, status).

Two interfaces keep handlers engine-invariant: TaskEngine (create / get /
update / cancel) and StepJournal (what the step API drives). The package
ships InMemoryTaskEngine as the in-process reference; durable engines
implement the same two seams outside the SDK.

Stacked on #2599 (explicit-schema handlers escape the era gate), which
tasks/get and tasks/cancel need on the 2026-07-28 era.
@changeset-bot

changeset-bot Bot commented Sep 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 774c3f9

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/codemod Minor
@modelcontextprotocol/core Minor
@modelcontextprotocol/server-legacy Minor
@modelcontextprotocol/core-internal Patch

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: 774c3f9

…server/ext/tasks

The extension is a subpath export of the server package rather than a
separate package: src/ext/tasks, built as dist/ext/tasks/index.*, exported
as @modelcontextprotocol/server/ext/tasks. Tests move to
packages/server/test/ext/tasks; the package README becomes
docs/servers/tasks.md.
@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
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