Skip to content
Closed
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-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 @@ -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.
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.
Loading
Loading