Skip to content
Open
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
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': 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 `McpServerOptions.extensions` advertises the capability, serves `tasks/get`, `tasks/update` and `tasks/cancel`, gates task handles on the client capability (`-32021`), binds each task to the creating request's principal (`principal` option; by default the OAuth client id plus the token subject), serves its methods on 2026-07-28 only (`-32601` on 2025-11-25), 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.

`@modelcontextprotocol/core/ext/tasks`: the extension's wire constants (`TASKS_EXTENSION_ID`, `TASK_STATUSES`), zod schemas and the types inferred from them, runtime-neutral. The server and client subpaths re-export them, plus the SDK's precise `InputRequest(s)` / `InputResponse(s)` types that `requireInput` and `update` take.
2 changes: 2 additions & 0 deletions docs/.vitepress/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,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 @@ -54,6 +55,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.
94 changes: 94 additions & 0 deletions docs/servers/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
---
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 type { TaskHandle } from '@modelcontextprotocol/server/ext/tasks';
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 });
const handle = store.handle(task.taskId);
// The work outlives the request: settle its failures on the task, not as an unhandled rejection.
void sendReport(handle, to).catch(error => handle.fail({ code: -32_603, message: String(error) }));
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; it rejects if the task is cancelled or purged meanwhile, which is why the work's promise needs a `catch`. `tasks/cancel` aborts `handle.signal`; a later `complete` or `fail` is ignored.

## Bind tasks to the caller

Every `tasks/*` request is checked against the principal the task was created for, and a request from a different principal gets "Task not found". By default the principal is the OAuth client id, plus the token subject when your token verifier puts it at `authInfo.extra.sub`. If users share one client and your verifier sets no subject, say which identity to bind to:

```ts
const tasks = new TasksExtension(store, {
principal: ctx => ctx.http?.authInfo?.extra?.['userId'] as string | undefined
});
```

Without authentication the principal is `undefined` and the task is unbound: anyone holding its id can reach it.

## 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. On a 2025-11-25 request, `tasks/get`, `tasks/update` and `tasks/cancel` answer `-32601`, and `tasks.create` refuses with `-32021`. 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`.
- A plain `client.callTool` rejects `resultType: "task"`; the requester half of the extension is `TasksClientExtension` from `@modelcontextprotocol/client/ext/tasks` ([Tasks on the client](../clients/tasks.md)).
- The wire schemas, types and constants live in `@modelcontextprotocol/core/ext/tasks`, for code that implements either side without the SDK's server or client; both subpaths re-export them.
- `notifications/tasks` over `subscriptions/listen` is not implemented (typescript-sdk#2569); polling only.

## Recap

- `new TasksExtension(store)` in `McpServerOptions.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.
Loading
Loading