Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/era-gate-explicit-schema-handlers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/server': patch
---

Fixed the inbound era gate rejecting explicit-schema request handlers (`setRequestHandler(method, schemas, handler)`) for method names that a past protocol revision used for an unrelated core method, even though the current era's registry no longer defines that name at all. This made extension methods reusing a historical core name — like the Tasks extension's (SEP-2663) `tasks/get` and `tasks/cancel`, which collide with the 2025-11-25 core methods of the same name — permanently unreachable on the 2026-07-28 era: every inbound request answered `-32601 Method not found` before the registered handler was ever consulted, regardless of what the handler or its schema accepted.

The era gate now only blocks methods registered through the typed `setRequestHandler(method, handler)` overload (the SDK's own built-ins, like `initialize` or `ping`, correctly keep answering by absence once an era moves past them). A method registered with an explicit schema is the extension-authoring path, and the consumer's own schema now takes precedence over the historical registry collision.
5 changes: 5 additions & 0 deletions .changeset/tasks-extension-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/server': minor
---

New subpath `@modelcontextprotocol/server/ext/tasks`: the server side of the MCP Tasks extension (`io.modelcontextprotocol/tasks`) with a pluggable execution engine. `installTasks(server, { engine })` serves `tasks/get`, `tasks/update` and `tasks/cancel` and returns `registerTask`, which is `registerTool` for long-running work: the handler runs as a replayable workflow against a `Step` API (`do`, `sleep`, `sleepUntil`, `elicit`, `offer`, `checkInput`, `status`). Engines implement two interfaces — `TaskEngine` (create / get / update / cancel) and `StepJournal` (what the step API drives) — and `InMemoryTaskEngine` is the in-process reference. Durable engines live outside the SDK.
1 change: 1 addition & 0 deletions docs/.vitepress/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [
{ text: 'Elicitation', link: '/servers/elicitation' },
{ text: 'Sampling (sunset)', link: '/servers/sampling' },
{ text: 'Input required', link: '/servers/input-required' },
{ text: 'Tasks (extension)', link: '/servers/tasks' },
{ text: 'Notifications', link: '/servers/notifications' },
{ text: 'Errors', link: '/servers/errors' }
]
Expand Down
23 changes: 16 additions & 7 deletions docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,10 +349,17 @@ mismatch is rejected as an entry/routing error (`-32022 Unsupported protocol ver
for requests; drop + `onerror` for notifications).

Methods deleted by a protocol revision are **physically absent** from that era's
registry: an inbound `tasks/get` on a 2026-era connection gets `-32601` even if a
handler is registered, and sending an era-mismatched spec method (e.g. `server/discover`
toward a 2025-era peer, or any `tasks/*` method toward a 2026-era peer) throws
`SdkError(MethodNotSupportedByProtocolVersion)` before anything reaches the transport.
registry for TYPED dispatch: an inbound `tasks/get` handler registered via
`setRequestHandler('tasks/get', handler)` on a 2026-era connection still gets `-32601`,
and sending an era-mismatched spec method via the typed `request(method, options)` form
(e.g. `server/discover` toward a 2025-era peer, or any `tasks/*` method toward a 2026-era
peer) throws `SdkError(MethodNotSupportedByProtocolVersion)` before anything reaches the
transport. An EXPLICIT SCHEMA is the extension-authoring escape hatch and is exempt from
this gate in both directions — `setRequestHandler('tasks/get', { params, result },
handler)` is reachable, and `request({ method: 'tasks/get', params },
GetTaskResultSchema)` is sendable, on every era, so a historical core name an extension
reuses (e.g. the Tasks extension, SEP-2663) is never permanently blocked by a past
revision's registry entry.

If you were on a v2 alpha and consumed wire schemas directly:

Expand Down Expand Up @@ -700,9 +707,11 @@ methods at compile time. `ResultTypeMap['tools/call']` is plain `CallToolResult`
maps still carry the `tasks/*` entries and the `CreateTaskResult` unions; narrow with
the `isCallToolResult` guard if you are pinned to one of those alphas. `2.0.0-alpha.4`
and later include the exclusion.) Where
task interop is genuinely required, use the explicit-schema custom-method form
(`request({ method: 'tasks/get', params }, GetTaskResultSchema)`). Inbound `tasks/*`
requests → `-32601`.
task interop is genuinely required, use the explicit-schema custom-method form on both
sides: `request({ method: 'tasks/get', params }, GetTaskResultSchema)` to send, and
`setRequestHandler('tasks/get', { params, result }, handler)` to serve — both reach the
wire/handler on every era, unlike the typed 2-arg overloads, which stay `-32601`/typed-error
gated for `tasks/*` on the 2026 era exactly like any other era-deleted spec method.

The experimental tasks **interception** layer is removed entirely — see
[upgrade-to-v2.md › Experimental tasks interception removed](./upgrade-to-v2.md#experimental-tasks-interception-removed).
Expand Down
65 changes: 65 additions & 0 deletions docs/servers/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
shape: how-to
---

# Tasks

Server-side [MCP Tasks extension](https://github.com/modelcontextprotocol/ext-tasks) (`io.modelcontextprotocol/tasks`) with a pluggable execution engine.

`registerTask` is `registerTool` for long-running work: the handler runs as a replayable workflow (`step.do`, `step.sleep`, `step.elicit`, `step.status`, `step.offer`), and the extension's `tasks/get`, `tasks/update` and `tasks/cancel` methods route to per-task state that outlives the request that created it.

```ts
import { McpServer } from '@modelcontextprotocol/server';
import { InMemoryTaskEngine, installTasks } from '@modelcontextprotocol/server/ext/tasks';
import * as z from 'zod/v4';

const engine = new InMemoryTaskEngine();

export function createServer() {
const server = new McpServer({ name: 'report-server', version: '1.0.0' });
const tasks = installTasks(server, { engine });

tasks.registerTask('send_report', { description: 'Compile and send a report', inputSchema: z.object({ to: z.string() }) }, async (input, step) => {
const report = await step.do('fetch-data', () => fetchReportData(input.to));
await step.status(`compiled ${report.pages} pages`);
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;
}
```

A `tools/call` of `send_report` from a client that declared the extension answers a task handle (`resultType: "task"`) immediately. The handler then runs on the engine; `tasks/get` polls it, `tasks/update` answers a `step.elicit`, `tasks/cancel` stops it at the next step.

## Two seams

Handlers never touch an engine directly. Two interfaces keep them engine-invariant:

| Seam | Interface | Who calls it |
| ----------- | ------------- | -------------------------------------------------------------------------------- |
| **control** | `TaskEngine` | the `tasks/*` request handlers and the task tool: create / get / update / cancel |
| **step** | `StepJournal` | the replay-aware `Step` API while a handler runs |

`InMemoryTaskEngine` implements both in-process and is the reference: task records and step journals in a `Map`, one timer per task computed from the rows, handlers run through the attached executor. State does not survive the process.

A durable engine implements the same two interfaces and lives outside the SDK: `TaskEngine` over a database or durable-execution runtime, `StepJournal` over its journal rows, and either `attach`es the executor (`createTaskExecutor(tasks)`) to run handlers in-process or builds one where the handlers run. Swapping engines changes the `installTasks` call and nothing else.

## Step API

Step names are journal keys, unique per task. All side effects belong inside `step.do`; the handler body re-runs from the top on every resume, with completed steps returning their persisted results.

- `step.do(name, [config], fn)` — journaled closure with per-step retries (`{ retries: { limit, baseDelayMs, maxDelayMs }, timeoutMs }`). Throw `NonRetryableError` to skip retries. Results must be JSON-serializable.
- `step.sleep(name, "5m" | ms)` / `step.sleepUntil(name, when)` — durable sleep; suspends the run, the engine resumes it.
- `step.elicit(name, inputRequest, [{ timeoutMs }])` — moves the task to `input_required` and suspends until `tasks/update` answers `name` (or the deadline passes, resolving `{ outcome: "timed_out" }`).
- `step.offer(key, inputRequest)` + `step.checkInput(name, key)` — a standing, non-blocking input channel: the task stays `working`; an answer wakes it.
- `step.status(message)` — writes `statusMessage` for pollers. The handler is its only writer.

## Wire notes

- The extension is served on the 2026-07-28 revision. The task tool is advertised as a normal tool without `outputSchema`; the encode seam forwards `resultType: "task"` for `tools/call` verbatim.
- A request that does not declare `io.modelcontextprotocol/tasks` in its client capabilities is refused with `-32021`. For `tools/call` the SDK's tool dispatch surfaces that refusal as an `isError` tool result.
- `tasks/update`'s `inputResponses` shares its name with the multi-round-trip retry field: the protocol layer lifts it out of the params and the handler reads it back from `ctx.mcpReq.inputResponses`.
- Serving `tasks/get` and `tasks/cancel` needs the explicit-schema era-gate exemption from typescript-sdk#2599 (those names were 2025-11-25 core methods).
- The SDK `Client` rejects `resultType: "task"` on `tools/call` (typescript-sdk#2637); the requester half of the extension is `@modelcontextprotocol/ext-tasks`.
60 changes: 53 additions & 7 deletions packages/core-internal/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,16 @@ export abstract class Protocol<ContextT extends BaseContext> {
private _transport?: Transport;
private _requestMessageId = 0;
private _requestHandlers: Map<string, (request: JSONRPCRequest, ctx: ContextT) => Promise<Result>> = new Map();
/**
* Methods registered through the explicit-schema `setRequestHandler(method, schemas,
* handler)` overload — the consumer supplied their own params/result schema rather than
* relying on a spec-registry entry. A name in this set is exempt from the spec-universe
* era gate in `_onrequest` (see the comment there): the consumer explicitly declared how
* to validate the method, so a historical core name reused by an extension (e.g.
* `tasks/get`) is served by the registered handler even on an era whose registry no
* longer defines it as a core method.
*/
private _customSchemaRequestMethods = new Set<string>();
private _requestHandlerAbortControllers: Map<RequestId, AbortController> = new Map();
private _notificationHandlers: Map<string, (notification: JSONRPCNotification, codec: WireCodec) => Promise<void>> = new Map();
private _responseHandlers: Map<number, (response: JSONRPCResultResponse | Error) => void> = new Map();
Expand Down Expand Up @@ -996,11 +1006,27 @@ export abstract class Protocol<ContextT extends BaseContext> {

// Era gate — deletions are physical: a spec method that is not in
// this era's registry is −32601 BY ABSENCE, before any handler
// lookup, even when a handler is registered (a custom handler cannot
// shadow a deleted spec method across eras). Methods outside the
// spec universe are consumer-owned extension methods and stay
// era-blind.
if (isSpecRequestMethod(request.method) && !codec.hasRequestMethod(request.method)) {
// lookup, even when a TYPED spec handler is registered for it (a
// `setRequestHandler(method, handler)` registration cannot shadow a
// deleted spec method across eras — this is how `initialize` stays
// unreachable once a 2026-era connection has negotiated past it).
// Methods outside the spec universe are consumer-owned extension
// methods and stay era-blind.
//
// A name that IS in the spec universe but was registered through the
// EXPLICIT-SCHEMA overload (`setRequestHandler(method, schemas,
// handler)`, tracked in `_customSchemaRequestMethods`) is exempt: the
// consumer supplied their own schema rather than relying on the
// era-registry entry, which is exactly the extension-method
// authoring path — some extensions (e.g. the Tasks extension,
// SEP-2663) reuse a name that a past core revision also used for an
// unrelated core method, and that historical collision should not
// make the extension's own handler unreachable.
if (
isSpecRequestMethod(request.method) &&
!codec.hasRequestMethod(request.method) &&
!this._customSchemaRequestMethods.has(request.method)
) {
sendErrorResponse(ProtocolErrorCode.MethodNotFound, 'Method not found');
return;
}
Expand Down Expand Up @@ -1270,10 +1296,16 @@ export abstract class Protocol<ContextT extends BaseContext> {
): Promise<StandardSchemaV1.InferOutput<T>>;
request(request: Request, schemaOrOptions?: StandardSchemaV1 | RequestOptions, maybeOptions?: RequestOptions): Promise<unknown> {
const codec = this._resolveOutboundCodec(request.method);
this._assertOutboundRequestInEra(codec, request.method);
if (isStandardSchema(schemaOrOptions)) {
// Explicit schema: the extension-authoring path, symmetric with the
// inbound `setRequestHandler(method, schemas, handler)` exemption
// (#2598) — a name a past era's registry used for an unrelated core
// method (e.g. the Tasks extension's `tasks/get`) must not block a
// consumer's own schema-driven call just because the CURRENT era's
// registry doesn't define it as a core method either.
return this._requestWithSchemaViaCodec(codec, request, schemaOrOptions, maybeOptions);
}
this._assertOutboundRequestInEra(codec, request.method);
const validate = codecResultValidator(codec, request.method);
if (validate === undefined) {
throw new TypeError(`'${request.method}' is not a spec method; pass a result schema as the second argument to request().`);
Expand Down Expand Up @@ -1322,7 +1354,11 @@ export abstract class Protocol<ContextT extends BaseContext> {
* directions: sending a spec method that the resolved era does not define
* dies locally with a typed error before anything reaches the transport.
* Methods outside the spec universe are consumer-owned extension methods
* and stay era-blind.
* and stay era-blind. Only applies to the TYPED dispatch path
* (`request(method, options)`, resolved via `codecResultValidator`) — the
* public `request()` overload skips this gate entirely when the caller
* passes an explicit result schema (mirrors the inbound exemption for
* `setRequestHandler(method, schemas, handler)`; see the comment there).
*/
private _assertOutboundRequestInEra(codec: WireCodec, method: string): void {
if (isSpecRequestMethod(method) && !codec.hasRequestMethod(method)) {
Expand Down Expand Up @@ -1751,6 +1787,15 @@ export abstract class Protocol<ContextT extends BaseContext> {
throw new TypeError('setRequestHandler: handler is required');
}

// Track explicit-schema registrations (see `_customSchemaRequestMethods`'s
// declaration) so the era gate can exempt them; a re-registration through the
// typed 2-arg overload reverts a method back to ordinary era gating.
if (typeof schemasOrHandler === 'function') {
this._customSchemaRequestMethods.delete(method);
} else {
this._customSchemaRequestMethods.add(method);
}

this._requestHandlers.set(method, this._wrapHandler(method, stored));
}

Expand Down Expand Up @@ -1787,6 +1832,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
*/
removeRequestHandler(method: RequestMethod | string): void {
this._requestHandlers.delete(method);
this._customSchemaRequestMethods.delete(method);
}

/**
Expand Down
29 changes: 20 additions & 9 deletions packages/core-internal/src/wire/codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,29 @@
*
* Deletions are physical: registry membership is the deletion story. The
* 2026-era registry has no `tasks/*`, `initialize`, `ping`, `logging/setLevel`,
* `resources/(un)subscribe` or server→client wire-request entries, so an
* inbound era-mismatched method falls to −32601 by absence — even when a
* handler is registered — and an outbound one dies locally with a typed
* `SdkError` before anything reaches the transport. The 2025-era registry has
* no `server/discover`/`subscriptions/listen`/MRTR entries, symmetrically.
* `resources/(un)subscribe` or server→client wire-request entries, so a
* TYPED-dispatch era-mismatched method falls to −32601 by absence inbound
* (`setRequestHandler(method, handler)`) or dies locally with a typed
* `SdkError` outbound (`request(method, options)`), before anything reaches
* the transport either way. The 2025-era registry has no
* `server/discover`/`subscriptions/listen`/MRTR entries, symmetrically.
*
* Custom-handler shadowing policy (both directions): a method that belongs to
* the SPEC-METHOD UNIVERSE — the union of every codec's registry, derived,
* not hand-curated — is ALWAYS era-gated, so a custom handler registered for
* a deleted spec method (e.g. `tasks/get`) serves it only on the era that
* defines it. Methods outside the universe are consumer-owned extension
* methods: they are era-blind and require explicit schemas, exactly as today.
* not hand-curated — is era-gated UNLESS the consumer supplied their own
* schema for it: inbound via the explicit-schema overload
* (`setRequestHandler(method, schemas, handler)`, tracked by
* `Protocol#_customSchemaRequestMethods`), outbound via `request(request,
* resultSchema, options)`. A TYPED spec handler/call for a deleted spec
* method (e.g. legacy `tasks/get`) still serves/sends only on the era that
* defines it — that's how `initialize` stays unreachable once a 2026-era
* connection has negotiated past it. But an explicit schema is the
* extension-authoring path, and some extensions (e.g. the Tasks extension,
* SEP-2663) intentionally reuse a name a past core revision used for an
* unrelated core method — the historical collision must not make the
* extension's own handler/call unreachable in either direction. Methods
* outside the spec universe were always consumer-owned extension methods:
* they are era-blind and require explicit schemas, exactly as today.
*
* Everything in `wire/` is internal to the bundled, `private: true` core —
* nothing per-revision is public surface, and nothing here may ever be
Expand Down
Loading
Loading