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.
13 changes: 13 additions & 0 deletions .changeset/tasks-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@modelcontextprotocol/core-internal': minor
'@modelcontextprotocol/client': minor
'@modelcontextprotocol/server': minor
---

The MCP Tasks extension (`io.modelcontextprotocol/tasks`) as a pair of extensions.

`@modelcontextprotocol/server/ext/tasks`: `new TasksExtension(store)` in `ServerOptions.extensions` advertises the capability, serves `tasks/get`, `tasks/update` and `tasks/cancel`, gates task handles on the client capability (`-32021`), and offers `tasks.create(ctx)` for a tool handler to answer with a task handle. `TaskStore` (create / get / update / cancel over JSON) is the interface a server implements over its own state and execution; `InMemoryTaskStore` is the in-process reference with a writer `handle` for reporting status, requesting input, and settling.

`@modelcontextprotocol/client/ext/tasks`: `new TasksClientExtension()` in `ClientOptions.extensions` declares the capability on every request, accepts task handles on `tools/call`, 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.

Wire types and zod schemas for the extension's 2026-07-28 schema live at `@modelcontextprotocol/core-internal/ext/tasks` and are re-exported from both subpaths.
2 changes: 2 additions & 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 All @@ -53,6 +54,7 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [
{ text: 'Handle server requests', link: '/clients/server-requests' },
{ text: 'Roots (sunset)', link: '/clients/roots' },
{ text: 'Subscriptions', link: '/clients/subscriptions' },
{ text: 'Tasks (extension)', link: '/clients/tasks' },
{ text: 'OAuth', link: '/clients/oauth' },
{ text: 'Machine auth', link: '/clients/machine-auth' },
{ text: 'Middleware', link: '/clients/middleware' },
Expand Down
81 changes: 81 additions & 0 deletions docs/clients/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
shape: how-to
---

# Tasks (extension)

The [MCP Tasks extension](https://github.com/modelcontextprotocol/ext-tasks) (`io.modelcontextprotocol/tasks`) lets a server answer a tool call with a **task handle** instead of blocking: you poll `tasks/get`, answer `tasks/update`, and stop with `tasks/cancel`. `@modelcontextprotocol/client/ext/tasks` is the client side of that wire, as a [client extension](../advanced/extensions.md). The server side is [Tasks (extension)](../servers/tasks.md).

## Install the extension

One extension instance serves one client; pass it at construction.

```ts
import { Client } from '@modelcontextprotocol/client';
import { TasksClientExtension } from '@modelcontextprotocol/client/ext/tasks';

const tasks = new TasksClientExtension();
const client = new Client({ name: 'report-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' }, extensions: [tasks] });
await client.connect(transport);
```

The client declares `io.modelcontextprotocol/tasks` in the capabilities envelope of every request — the server refuses task handles to clients that do not — and accepts `resultType: "task"` on `tools/call`, which the plain `client.callTool` cannot describe.

## Call a tool that may become a task

`tasks.callTool` returns a discriminated outcome: a task handle to follow, or the ordinary result for tools that answered synchronously.

```ts
const outcome = await tasks.callTool({ name: 'send_report', arguments: { to: 'ops' } });
if (outcome.kind === 'result') {
console.log(outcome.result.content);
} else {
console.log(outcome.task.taskId, outcome.task.status, outcome.task.pollIntervalMs);
}
```

## Poll to the end

`waitFor` polls `tasks/get` at the server's suggested `pollIntervalMs` until the task is terminal. Every snapshot passes through `onUpdate`, which is where an `input_required` task gets answered.

```ts
const done = await tasks.waitFor(outcome.task.taskId, {
signal: controller.signal,
onUpdate: async task => {
if (task.status === 'input_required') {
const answers = await askUser(task.inputRequests);
await tasks.update(task.taskId, answers);
}
}
});

switch (done.status) {
case 'completed':
console.log(done.result);
break;
case 'failed':
console.error(done.error);
break;
case 'cancelled':
break;
}
```

Aborting the signal stops polling and nothing else. Cancelling the task is a separate, cooperative call: `await tasks.cancel(taskId)` resolves on acknowledgement, and the task may still settle `completed` or `failed`.

## The raw methods

| Method | Wire | Returns |
| -------------------------------------- | -------------- | --------------------------------------------------------------------- |
| `tasks.get(taskId)` | `tasks/get` | The `DetailedTask` snapshot: result, error, or input requests inlined |
| `tasks.update(taskId, inputResponses)` | `tasks/update` | Resolves on acknowledgement; partial answers are accepted |
| `tasks.cancel(taskId)` | `tasks/cancel` | Resolves on acknowledgement |

Each takes the usual `RequestOptions` (`signal`, `timeout`) as a last argument.

## Recap

- `new TasksClientExtension()` in `ClientOptions.extensions`; one instance per client.
- `tasks.callTool(params)` yields `{ kind: 'task', task }` or `{ kind: 'result', result }`.
- `tasks.waitFor(taskId, { onUpdate, signal })` polls at the server's interval to a terminal snapshot.
- `get`, `update`, `cancel` are the extension's three methods, one to one.
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
78 changes: 78 additions & 0 deletions docs/servers/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
shape: how-to
---

# Tasks (extension)

The [MCP Tasks extension](https://github.com/modelcontextprotocol/ext-tasks) (`io.modelcontextprotocol/tasks`) lets a tool answer with a **task handle** instead of blocking on work that takes minutes: the client polls `tasks/get`, answers `tasks/update`, and stops with `tasks/cancel`. `@modelcontextprotocol/server/ext/tasks` is the server side of that wire, as a [server extension](../advanced/extensions.md). It owns the protocol; you own the task's state and its execution, behind a `TaskStore`.

## Install the extension

`TasksExtension` takes the store. `InMemoryTaskStore` is the in-process reference; a persistent store implements the same four methods.

```ts
import { McpServer } from '@modelcontextprotocol/server';
import { InMemoryTaskStore, TasksExtension } from '@modelcontextprotocol/server/ext/tasks';

const store = new InMemoryTaskStore();
const tasks = new TasksExtension(store);

const server = new McpServer({ name: 'report-server', version: '1.0.0' }, { extensions: [tasks] });
```

The server advertises `io.modelcontextprotocol/tasks` under `capabilities.extensions` and serves `tasks/get`, `tasks/update` and `tasks/cancel`.

## Answer a tool call with a task

Inside a tool handler, `tasks.create(ctx)` creates the task in the store, bound to the request's principal, and returns the handle the handler answers with. The work itself is yours to start however your server runs things — here, an async function driving the in-memory store's writer handle.

```ts
import * as z from 'zod/v4';

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

async function sendReport(handle: TaskHandle, to: string) {
await handle.status('compiling');
const report = await compile(to);
const answers = await handle.requireInput({
approve: { method: 'elicitation/create', params: { message: `send ${report.pages} pages to ${to}?`, mode: 'form', requestedSchema: { type: 'object', properties: {} } } }
});
if (answers.approve?.action !== 'accept' || handle.signal.aborted) return handle.complete({ content: [{ type: 'text', text: 'not sent' }] });
await deliver(report, to);
await handle.complete({ content: [{ type: 'text', text: `report sent to ${to}` }] });
}
```

On the wire the tool answers a flat `CreateTaskResult` (`resultType: "task"`). `requireInput` moves the task to `input_required` and resolves once `tasks/update` has answered every key. `tasks/cancel` aborts `handle.signal`; a later `complete` or `fail` is ignored.

## Bring your own store

`TaskStore` is four methods over JSON:

| Method | Contract |
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `create(params)` | Durably create; MUST NOT resolve before a following `get` would succeed. `params.context` is yours (tool name, workflow id…). |
| `get(taskId, access?)` | The `DetailedTask` snapshot, or `undefined` for unknown, expired, or foreign-principal tasks. |
| `update(taskId, inputResponses, access?)` | Deliver answers; `false` when the task is not found. Unknown keys are ignored, partial answers accepted. |
| `cancel(taskId, access?)` | Cooperative; resolves on acknowledgement. Idempotent on terminal tasks. |

How the work behind a task runs — a queue, a workflow engine, a durable-execution runtime — is invisible to the SDK. The store is also where a writer API lives if your execution needs one; `InMemoryTaskStore.handle` is the reference shape.

## Wire notes

- The extension is served on the 2026-07-28 revision. Task tools are ordinary tools without `outputSchema`; the result encoder forwards `resultType: "task"` for `tools/call` verbatim.
- A request that does not declare `io.modelcontextprotocol/tasks` in its client capabilities is refused with `-32021` — from `tasks.create`, from the `tasks/*` methods, and by the extension's `tools/call` middleware for any handle minted some other way.
- `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 extension reads it back from `ctx.mcpReq.inputResponses`.
- The SDK `Client` rejects `resultType: "task"` on `tools/call` (typescript-sdk#2637); the requester half of the extension is `@modelcontextprotocol/ext-tasks`.
- `notifications/tasks` over `subscriptions/listen` is not implemented (typescript-sdk#2569); polling only.

## Recap

- `new TasksExtension(store)` in `ServerOptions.extensions` serves the extension.
- `tasks.create(ctx, options?)` in a tool handler returns the task handle to answer with.
- `TaskStore` is create / get / update / cancel; `InMemoryTaskStore` is the reference and adds a writer `handle`.
- Execution is the server's; the SDK only defines the API shape.
13 changes: 13 additions & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@
"default": "./dist/stdio.cjs"
}
},
"./ext/tasks": {
"import": {
"types": "./dist/ext/tasks/index.d.mts",
"default": "./dist/ext/tasks/index.mjs"
},
"require": {
"types": "./dist/ext/tasks/index.d.cts",
"default": "./dist/ext/tasks/index.cjs"
}
},
"./validators/ajv": {
"import": {
"types": "./dist/validators/ajv.d.mts",
Expand Down Expand Up @@ -115,6 +125,9 @@
],
"stdio": [
"dist/stdio.d.mts"
],
"ext/tasks": [
"dist/ext/tasks/index.d.mts"
]
}
},
Expand Down
62 changes: 62 additions & 0 deletions packages/client/src/ext/tasks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* `@modelcontextprotocol/client/ext/tasks` — the client side of the MCP
* Tasks extension (`io.modelcontextprotocol/tasks`).
*
* `TasksClientExtension` advertises the capability, accepts task handles on
* `tools/call`, and wraps `tasks/get`, `tasks/update` and `tasks/cancel`,
* plus `waitFor` to poll a task to a terminal status.
*/

export type { CallToolOutcome, TerminalTask, WaitForOptions } from './tasksClientExtension';
export { TasksClientExtension } from './tasksClientExtension';
export type {
CancelledTask,
CancelTaskParams,
CancelTaskRequest,
CancelTaskResult,
CompletedTask,
CreateTaskResult,
DetailedTask,
FailedTask,
GetTaskParams,
GetTaskRequest,
GetTaskResult,
InputRequest,
InputRequests,
InputRequiredTask,
InputResponse,
InputResponses,
Task,
TasksExtensionCapability,
TaskStatus,
UpdateTaskParams,
UpdateTaskRequest,
UpdateTaskResult,
WorkingTask
} from '@modelcontextprotocol/core-internal/ext/tasks';
export {
cancelledTaskSchema,
cancelTaskParamsSchema,
cancelTaskRequestSchema,
cancelTaskResultSchema,
completedTaskSchema,
createTaskResultSchema,
detailedTaskSchema,
failedTaskSchema,
getTaskParamsSchema,
getTaskRequestSchema,
getTaskResultSchema,
inputRequestSchema,
inputRequestsSchema,
inputRequiredTaskSchema,
inputResponseSchema,
inputResponsesSchema,
taskSchema,
tasksExtensionCapabilitySchema,
taskStatusSchema,
updateTaskParamsSchema,
updateTaskRequestSchema,
updateTaskResultSchema,
workingTaskSchema
} from '@modelcontextprotocol/core-internal/ext/tasks';
export { TASK_STATUSES, TASKS_EXTENSION_ID } from '@modelcontextprotocol/core-internal/ext/tasks';
Loading
Loading