From d9181a1fdacf85689a8267c0bb4b5e85260da753 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Fri, 23 Oct 2026 14:58:53 +0100 Subject: [PATCH 01/12] feat: Tasks extension for server and client (ext/tasks subpaths) @modelcontextprotocol/server/ext/tasks adds TasksExtension(store): it advertises io.modelcontextprotocol/tasks, serves tasks/get, tasks/update and tasks/cancel, refuses requests that did not declare the extension (-32021), and gives a tool handler tasks.create(ctx) to answer tools/call with a task handle. TaskStore is the four-method interface a server implements over its own task state; InMemoryTaskStore is the in-process reference, with a writer handle (status, requireInput, complete, fail, signal). @modelcontextprotocol/client/ext/tasks adds TasksClientExtension: callTool, get, update, cancel and waitFor, which polls tasks/get until a terminal status. Wire types and schemas live at @modelcontextprotocol/core-internal/ext/tasks. examples/ext-tasks is a minimal in-process server and client: a bake_cake tool on TasksExtension over an InMemoryTaskStore reports progress, asks for input (input_required) and honours cancellation; the client follows it with TasksClientExtension.waitFor, answers through tasks.update, and cancels a second task. --- .changeset/tasks-extension.md | 13 + docs/.vitepress/nav.ts | 2 + docs/clients/tasks.md | 81 ++++++ docs/servers/tasks.md | 78 ++++++ examples/README.md | 49 ++-- examples/ext-tasks/README.md | 13 + examples/ext-tasks/client.ts | 75 ++++++ examples/ext-tasks/package.json | 24 ++ examples/ext-tasks/server.ts | 107 ++++++++ examples/tsconfig.json | 7 +- packages/client/package.json | 13 + packages/client/src/ext/tasks/index.ts | 62 +++++ .../src/ext/tasks/tasksClientExtension.ts | 142 ++++++++++ .../ext/tasks/tasksClientExtension.test.ts | 148 ++++++++++ packages/client/tsconfig.json | 3 +- packages/client/tsdown.config.ts | 2 + packages/core-internal/package.json | 4 + packages/core-internal/src/ext/tasks/index.ts | 7 + .../core-internal/src/ext/tasks/schemas.ts | 177 ++++++++++++ packages/core-internal/src/ext/tasks/types.ts | 220 +++++++++++++++ .../test/packageTopologyPins.test.ts | 13 +- packages/server/package.json | 18 +- .../server/src/ext/tasks/inMemoryStore.ts | 254 ++++++++++++++++++ packages/server/src/ext/tasks/index.ts | 68 +++++ packages/server/src/ext/tasks/store.ts | 54 ++++ .../server/src/ext/tasks/tasksExtension.ts | 172 ++++++++++++ .../test/ext/tasks/inMemoryStore.test.ts | 82 ++++++ .../server/test/ext/tasks/tasks.e2e.test.ts | 196 ++++++++++++++ .../test/ext/tasks/withSkills.e2e.test.ts | 66 +++++ packages/server/tsconfig.json | 7 +- packages/server/tsdown.config.ts | 2 + pnpm-lock.yaml | 28 ++ 32 files changed, 2156 insertions(+), 31 deletions(-) create mode 100644 .changeset/tasks-extension.md create mode 100644 docs/clients/tasks.md create mode 100644 docs/servers/tasks.md create mode 100644 examples/ext-tasks/README.md create mode 100644 examples/ext-tasks/client.ts create mode 100644 examples/ext-tasks/package.json create mode 100644 examples/ext-tasks/server.ts create mode 100644 packages/client/src/ext/tasks/index.ts create mode 100644 packages/client/src/ext/tasks/tasksClientExtension.ts create mode 100644 packages/client/test/ext/tasks/tasksClientExtension.test.ts create mode 100644 packages/core-internal/src/ext/tasks/index.ts create mode 100644 packages/core-internal/src/ext/tasks/schemas.ts create mode 100644 packages/core-internal/src/ext/tasks/types.ts create mode 100644 packages/server/src/ext/tasks/inMemoryStore.ts create mode 100644 packages/server/src/ext/tasks/index.ts create mode 100644 packages/server/src/ext/tasks/store.ts create mode 100644 packages/server/src/ext/tasks/tasksExtension.ts create mode 100644 packages/server/test/ext/tasks/inMemoryStore.test.ts create mode 100644 packages/server/test/ext/tasks/tasks.e2e.test.ts create mode 100644 packages/server/test/ext/tasks/withSkills.e2e.test.ts diff --git a/.changeset/tasks-extension.md b/.changeset/tasks-extension.md new file mode 100644 index 0000000000..b6b80b45ff --- /dev/null +++ b/.changeset/tasks-extension.md @@ -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. diff --git a/docs/.vitepress/nav.ts b/docs/.vitepress/nav.ts index 7239b750d1..b73516d9eb 100644 --- a/docs/.vitepress/nav.ts +++ b/docs/.vitepress/nav.ts @@ -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' } ] @@ -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' }, diff --git a/docs/clients/tasks.md b/docs/clients/tasks.md new file mode 100644 index 0000000000..020c0171ca --- /dev/null +++ b/docs/clients/tasks.md @@ -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. diff --git a/docs/servers/tasks.md b/docs/servers/tasks.md new file mode 100644 index 0000000000..1d6eea2954 --- /dev/null +++ b/docs/servers/tasks.md @@ -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. diff --git a/examples/README.md b/examples/README.md index 51bc960a13..0013d27d2b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -31,30 +31,31 @@ The one exception to the generic commands is the reference pair: [`cli-client/`] ## Feature stories -| Story | What it teaches | Transports | Era | -| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | -------------- | -| [`mrtr/`](./mrtr/README.md) | Multi-round-trip write-once tool, secure `requestState` | stdio + http | modern | -| [`subscriptions/`](./subscriptions/README.md) | `subscriptions/listen`: `client.listen()` + auto-open, `handler.notify` / `ServerEventBus` | stdio + http | modern | -| [`streaming/`](./streaming/README.md) | In-flight progress, logging, cancellation | stdio + http | dual | -| [`elicitation/`](./elicitation/README.md) | Elicitation (form + URL mode), both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual | -| [`sampling/`](./sampling/README.md) | Tool that requests LLM sampling from the client, both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual | -| [`stickynotes/`](./stickynotes/README.md) | "Real app" capstone: tools mutate state, a resource per note, listChanged, elicitation-confirmed clear | stdio + http | dual | -| [`cli-client/`](./cli-client/README.md) | **Reference host**: LLM chat CLI with provider seam — tool loop, @-mention resources, prompt commands, sampling, elicitation, roots, OAuth, cancellation | stdio + http | dual | -| [`todos-server/`](./todos-server/README.md) | **Reference server** (pairs with cli-client): every server feature with a real job — CRUD tools, sampling, multi-round elicitation, subscriptions, progress | stdio + http | dual | -| [`caching/`](./caching/README.md) | `cacheHints` stamping on cacheable results (2026-07-28) | stdio + http | modern | -| [`gateway/`](./gateway/README.md) | `connect({ prior })` — probe once, zero-round-trip connect for every worker (gateway pattern) | http | modern | -| [`custom-methods/`](./custom-methods/README.md) | Vendor-prefixed methods + custom notifications | stdio + http | dual | -| [`extension-capabilities/`](./extension-capabilities/README.md) | Declaring `capabilities.extensions` and reading the negotiated map | stdio + http | dual | -| [`skills/`](./skills/README.md) | Skills over MCP (SEP-2640): `loadSkills` a folder, `SkillsClient` lists, loads and verifies | stdio + http | dual | -| [`schema-validators/`](./schema-validators/README.md) | ArkType, Valibot, Zod, and `outputSchema` | stdio + http | dual | -| [`custom-version/`](./custom-version/README.md) | `supportedProtocolVersions` / version negotiation | stdio + http | legacy | -| [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | stdio + http | dual | -| [`legacy-routing/`](./legacy-routing/README.md) | `isLegacyRequest` in front of an existing sessionful 1.x deployment + a strict modern entry on one port | http | dual (in-body) | -| [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | dual | -| [`bearer-auth-web/`](./bearer-auth-web/README.md) | Web-standard twin: host/origin guards + `requireBearerAuth` + `createMcpHandler` as one fetch handler | http | dual | -| [`oauth/`](./oauth/README.md) | OAuth `authorization_code`: in-repo AS (auto-consent) + headless redirect-following client | http | dual | -| [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | dual | -| [`scoped-tools/`](./scoped-tools/README.md) | Per-tool scope on `createMcpHandler` — bearer-verify gate + handler-level `ctx.http?.authInfo` checks | http | modern | +| Story | What it teaches | Transports | Era | +| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | -------------- | +| [`mrtr/`](./mrtr/README.md) | Multi-round-trip write-once tool, secure `requestState` | stdio + http | modern | +| [`subscriptions/`](./subscriptions/README.md) | `subscriptions/listen`: `client.listen()` + auto-open, `handler.notify` / `ServerEventBus` | stdio + http | modern | +| [`streaming/`](./streaming/README.md) | In-flight progress, logging, cancellation | stdio + http | dual | +| [`elicitation/`](./elicitation/README.md) | Elicitation (form + URL mode), both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual | +| [`sampling/`](./sampling/README.md) | Tool that requests LLM sampling from the client, both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual | +| [`stickynotes/`](./stickynotes/README.md) | "Real app" capstone: tools mutate state, a resource per note, listChanged, elicitation-confirmed clear | stdio + http | dual | +| [`cli-client/`](./cli-client/README.md) | **Reference host**: LLM chat CLI with provider seam — tool loop, @-mention resources, prompt commands, sampling, elicitation, roots, OAuth, cancellation | stdio + http | dual | +| [`todos-server/`](./todos-server/README.md) | **Reference server** (pairs with cli-client): every server feature with a real job — CRUD tools, sampling, multi-round elicitation, subscriptions, progress | stdio + http | dual | +| [`caching/`](./caching/README.md) | `cacheHints` stamping on cacheable results (2026-07-28) | stdio + http | modern | +| [`gateway/`](./gateway/README.md) | `connect({ prior })` — probe once, zero-round-trip connect for every worker (gateway pattern) | http | modern | +| [`custom-methods/`](./custom-methods/README.md) | Vendor-prefixed methods + custom notifications | stdio + http | dual | +| [`extension-capabilities/`](./extension-capabilities/README.md) | Declaring `capabilities.extensions` and reading the negotiated map | stdio + http | dual | +| [`skills/`](./skills/README.md) | Skills over MCP (SEP-2640): `loadSkills` a folder, `SkillsClient` lists, loads and verifies | stdio + http | dual | +| [`ext-tasks/`](./ext-tasks/README.md) | Tasks extension: `TasksExtension` + `InMemoryTaskStore` on the server, `TasksClientExtension` + `waitFor` on the client, in-process work with `input_required` and cancel | stdio + http | modern | +| [`schema-validators/`](./schema-validators/README.md) | ArkType, Valibot, Zod, and `outputSchema` | stdio + http | dual | +| [`custom-version/`](./custom-version/README.md) | `supportedProtocolVersions` / version negotiation | stdio + http | legacy | +| [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | stdio + http | dual | +| [`legacy-routing/`](./legacy-routing/README.md) | `isLegacyRequest` in front of an existing sessionful 1.x deployment + a strict modern entry on one port | http | dual (in-body) | +| [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | dual | +| [`bearer-auth-web/`](./bearer-auth-web/README.md) | Web-standard twin: host/origin guards + `requireBearerAuth` + `createMcpHandler` as one fetch handler | http | dual | +| [`oauth/`](./oauth/README.md) | OAuth `authorization_code`: in-repo AS (auto-consent) + headless redirect-following client | http | dual | +| [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | dual | +| [`scoped-tools/`](./scoped-tools/README.md) | Per-tool scope on `createMcpHandler` — bearer-verify gate + handler-level `ctx.http?.authInfo` checks | http | modern | ## HTTP hosting variants diff --git a/examples/ext-tasks/README.md b/examples/ext-tasks/README.md new file mode 100644 index 0000000000..46019a1125 --- /dev/null +++ b/examples/ext-tasks/README.md @@ -0,0 +1,13 @@ +# ext-tasks + +A minimal server for the [MCP Tasks extension](https://github.com/modelcontextprotocol/ext-tasks) (`io.modelcontextprotocol/tasks`), everything in-process. `TasksExtension` over an `InMemoryTaskStore` serves the wire; the work behind the one tool, `bake_cake`, is a plain async function driven by timers that reports progress, asks the client which frosting to use (`input_required`), and honours cancellation between steps. + +The client installs `TasksClientExtension`, calls the tool, follows the task with `waitFor` (answering the frosting question through `tasks/update` on the way), then starts a second task and cancels it while it waits for input. + +```bash +pnpm tsx examples/ext-tasks/client.ts # stdio, spawns the server +pnpm tsx examples/ext-tasks/server.ts --http --port 3000 # or serve over HTTP … +pnpm tsx examples/ext-tasks/client.ts --http http://127.0.0.1:3000/mcp +``` + +Modern era only: task handles and the `tasks/*` methods ride the 2026-07-28 per-request capabilities envelope. diff --git a/examples/ext-tasks/client.ts b/examples/ext-tasks/client.ts new file mode 100644 index 0000000000..f9d3981f98 --- /dev/null +++ b/examples/ext-tasks/client.ts @@ -0,0 +1,75 @@ +/** + * Connects to `./server.ts` with the Tasks client extension, calls `bake_cake` + * twice, and asserts both outcomes: one task followed to completion through + * `waitFor` (answering the frosting question on the way), one cancelled + * mid-bake. + * + * Spawns the sibling `server.ts` over stdio by default, or connects to a + * running endpoint under `--http `. See `examples/CONTRIBUTING.md` for + * the canonical shape. + */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { TasksClientExtension } from '@modelcontextprotocol/client/ext/tasks'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +const { transport, url } = parseExampleArgs(); + +const tasks = new TasksClientExtension(); +const client = new Client( + { name: 'tasks-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' }, extensions: [tasks] } +); + +await client.connect( + transport === 'stdio' + ? new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] }) + : new StreamableHTTPClientTransport(new URL(url)) +); + +check.ok('io.modelcontextprotocol/tasks' in (client.getServerCapabilities()?.extensions ?? {})); + +// 1. A task followed to the end. `tools/call` answers a handle at once. +const started = await tasks.callTool({ name: 'bake_cake', arguments: { layers: 3 } }); +check.equal(started.kind, 'task'); +if (started.kind !== 'task') throw new Error('unreachable'); +console.log(`[client] task ${started.task.taskId} created: ${started.task.status}`); + +const seen: string[] = []; +const done = await tasks.waitFor(started.task.taskId, { + onUpdate: async task => { + seen.push(`${task.status}${task.statusMessage ? ` (${task.statusMessage})` : ''}`); + if (task.status === 'input_required') { + // The server asked which frosting; answer through tasks/update. + check.deepEqual(Object.keys(task.inputRequests), ['frosting']); + await tasks.update(task.taskId, { frosting: { action: 'accept', content: { frosting: 'chocolate' } } }); + } + } +}); +console.log(`[client] saw: ${[...new Set(seen)].join(' -> ')}`); +check.equal(done.status, 'completed'); +if (done.status !== 'completed') throw new Error('unreachable'); +check.deepEqual(done.result['structuredContent'], { layers: 3, frosting: 'chocolate' }); +check.ok(seen.some(s => s.startsWith('input_required'))); + +// 2. A task cancelled while it waits for the frosting answer. +const second = await tasks.callTool({ name: 'bake_cake', arguments: { layers: 1 } }); +if (second.kind !== 'task') throw new Error('expected a task handle'); +await tasks + .waitFor(second.task.taskId, { + onUpdate: async task => { + if (task.status === 'input_required') await tasks.cancel(task.taskId); + } + }) + .then(task => { + console.log(`[client] task ${task.taskId} ended: ${task.status}`); + check.equal(task.status, 'cancelled'); + }); + +// 3. Unknown task ids are -32602. +await tasks.get('no-such-task').then( + () => check.fail('expected tasks/get to reject'), + (error: unknown) => check.equal((error as { code?: number }).code, -32_602) +); + +await client.close(); diff --git a/examples/ext-tasks/package.json b/examples/ext-tasks/package.json new file mode 100644 index 0000000000..0f852399ed --- /dev/null +++ b/examples/ext-tasks/package.json @@ -0,0 +1,24 @@ +{ + "name": "@mcp-examples/ext-tasks", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "modern", + "//": "The Tasks extension (io.modelcontextprotocol/tasks) is a 2026-07-28 extension: task handles and tasks/* ride the per-request capabilities envelope." + } +} diff --git a/examples/ext-tasks/server.ts b/examples/ext-tasks/server.ts new file mode 100644 index 0000000000..f41d172d36 --- /dev/null +++ b/examples/ext-tasks/server.ts @@ -0,0 +1,107 @@ +/** + * A minimal Tasks server (`io.modelcontextprotocol/tasks`), everything + * in-process: `InMemoryTaskStore` holds the task records, and the work behind + * a task is a plain async function driven by timers. The SDK owns the wire; + * how the work runs is this file's own business. + * + * One tool, `bake_cake`: answers with a task handle at once, then mixes, asks + * the client which frosting to use (`input_required`), bakes, and completes — + * or stops at the next step when the client cancels. + * + * One binary, either transport — selected by `--http --port ` (defaults to + * stdio). See `examples/CONTRIBUTING.md` for the canonical shape. + */ +import { serve } from '@hono/node-server'; +import { parseExampleArgs } from '@mcp-examples/shared'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import type { TaskHandle } from '@modelcontextprotocol/server/ext/tasks'; +import { InMemoryTaskStore, TasksExtension } from '@modelcontextprotocol/server/ext/tasks'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +// One store for the process: tasks outlive the request (and, under +// `createMcpHandler`, the McpServer instance) that created them. +const store = new InMemoryTaskStore(); +const tasks = new TasksExtension(store, { defaultPollIntervalMs: 50 }); + +const sleep = (ms: number, signal: AbortSignal) => + new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal.addEventListener( + 'abort', + () => { + clearTimeout(timer); + reject(new Error('cancelled')); + }, + { once: true } + ); + }); + +/** The work: reports progress, asks one question, honours cancellation between steps. */ +async function bakeCake(handle: TaskHandle, layers: number): Promise { + try { + await handle.status(`mixing ${layers} layers`); + await sleep(30, handle.signal); + + const answers = await handle.requireInput({ + frosting: { + method: 'elicitation/create', + params: { + message: 'Which frosting?', + mode: 'form', + requestedSchema: { type: 'object', properties: { frosting: { type: 'string', enum: ['vanilla', 'chocolate'] } } } + } + } + }); + const frosting = (answers['frosting'] as { content?: { frosting?: string } } | undefined)?.content?.frosting ?? 'plain'; + + await handle.status(`baking with ${frosting} frosting`); + await sleep(30, handle.signal); + + await handle.complete({ + content: [{ type: 'text', text: `${layers}-layer cake, ${frosting} frosting` }], + structuredContent: { layers, frosting } + }); + } catch (error) { + // A cancelled task ignores later writes; anything else is a real failure. + if (!handle.signal.aborted) await handle.fail({ code: -32_000, message: String(error) }); + } +} + +function buildServer(): McpServer { + const mcp = new McpServer({ name: 'tasks-example-server', version: '1.0.0' }, { extensions: [tasks] }); + + mcp.registerTool( + 'bake_cake', + { + description: 'Bake a cake as a task: mixes, asks for a frosting, bakes.', + inputSchema: z.object({ layers: z.number().int().min(1).max(5) }) + }, + async ({ layers }, ctx) => { + // Create the task (bound to the caller's principal, if any) and + // answer with its handle; the work starts in the background. + const task = await tasks.create(ctx, { ttlMs: 60_000 }); + void bakeCake(store.handle(task.taskId), layers); + return task; + } + ); + + return mcp; +} + +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + // `createMcpHonoApp()` arms localhost host/origin validation by default; + // bind loopback explicitly to match. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/tsconfig.json b/examples/tsconfig.json index 28288e5c6d..69092a7b0a 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -34,7 +34,12 @@ "@modelcontextprotocol/core-internal/ext/skills": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" ], - "@mcp-examples/shared": ["./node_modules/@mcp-examples/shared/src/index.ts"] + "@mcp-examples/shared": ["./node_modules/@mcp-examples/shared/src/index.ts"], + "@modelcontextprotocol/server/ext/tasks": ["./node_modules/@modelcontextprotocol/server/src/ext/tasks/index.ts"], + "@modelcontextprotocol/client/ext/tasks": ["./node_modules/@modelcontextprotocol/client/src/ext/tasks/index.ts"], + "@modelcontextprotocol/core-internal/ext/tasks": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/ext/tasks/index.ts" + ] } } } diff --git a/packages/client/package.json b/packages/client/package.json index df1fd506a9..3511b072b0 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -50,6 +50,16 @@ "default": "./dist/ext/skills/index.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", @@ -128,6 +138,9 @@ ], "stdio": [ "dist/stdio.d.mts" + ], + "ext/tasks": [ + "dist/ext/tasks/index.d.mts" ] } }, diff --git a/packages/client/src/ext/tasks/index.ts b/packages/client/src/ext/tasks/index.ts new file mode 100644 index 0000000000..6a58b1ccac --- /dev/null +++ b/packages/client/src/ext/tasks/index.ts @@ -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'; diff --git a/packages/client/src/ext/tasks/tasksClientExtension.ts b/packages/client/src/ext/tasks/tasksClientExtension.ts new file mode 100644 index 0000000000..79896f327f --- /dev/null +++ b/packages/client/src/ext/tasks/tasksClientExtension.ts @@ -0,0 +1,142 @@ +/** + * `TasksClientExtension` — the client side of the MCP Tasks extension + * (`io.modelcontextprotocol/tasks`) as a {@linkcode ClientExtension}. It + * advertises the capability on every request, lets `tools/call` answer with a + * task handle, and wraps the extension's methods: `get`, `update`, `cancel`, + * and a `waitFor` that polls at the server's suggested interval until the + * task is terminal. + * + * ```ts + * const tasks = new TasksClientExtension(); + * const client = new Client(info, { extensions: [tasks] }); + * await client.connect(transport); + * + * const outcome = await tasks.callTool({ name: 'send_report', arguments: { to: 'ops' } }); + * if (outcome.kind === 'task') { + * const done = await tasks.waitFor(outcome.task.taskId); + * if (done.status === 'completed') console.log(done.result); + * } + * ``` + * + * One extension instance serves one client: `setup` binds it. + */ + +import type { CallToolRequestParams, CallToolResult, InputResponses, RequestOptions } from '@modelcontextprotocol/core-internal'; +import { CompatibilityCallToolResultSchema, SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal'; +import type { CreateTaskResult, DetailedTask } from '@modelcontextprotocol/core-internal/ext/tasks'; +import { createTaskResultSchema, detailedTaskSchema, TASKS_EXTENSION_ID } from '@modelcontextprotocol/core-internal/ext/tasks'; +import * as z from 'zod/v4'; + +import type { Client } from '../../client/client'; +import type { ClientExtension, ClientExtensionContext } from '../../client/extension'; + +/** How a task-capable `tools/call` settled: a task handle to follow, or the ordinary result. */ +export type CallToolOutcome = { kind: 'task'; task: CreateTaskResult } | { kind: 'result'; result: CallToolResult }; + +/** Options for {@link TasksClientExtension.waitFor}. */ +export interface WaitForOptions { + /** Aborting stops polling; the task itself is untouched. */ + signal?: AbortSignal; + /** Overrides the server's `pollIntervalMs` hint, ms. */ + pollIntervalMs?: number; + /** Fallback when the task carries no `pollIntervalMs`, ms. Default 1_000. */ + defaultPollIntervalMs?: number; + /** Called with every snapshot, terminal one included. */ + onUpdate?: (task: DetailedTask) => void; +} + +/** A task in a terminal status. */ +export type TerminalTask = Extract; + +const TERMINAL = new Set(['completed', 'failed', 'cancelled']); + +/** + * The `Client` consumes `resultType` before a caller schema runs for every + * kind it knows, so the neutral `DetailedTask` shape is what `tasks/get` + * answers arrive as; a task handle arrives raw, discriminator included. + */ +const callToolOutcomeSchema = z.union([createTaskResultSchema, CompatibilityCallToolResultSchema]); +const ackSchema = z.looseObject({}); + +export class TasksClientExtension implements ClientExtension { + readonly id = TASKS_EXTENSION_ID; + #client: Client | undefined; + + /** + * Binds this instance to the client and lets `tools/call` answer with a + * task handle. The client calls this for each entry in `extensions`. + * + * Experimental: the extension shape may change in a minor release. + * + * @experimental + */ + setup(ctx: ClientExtensionContext): void { + if (this.#client !== undefined) throw new Error('TasksClientExtension is already set up on a client'); + this.#client = ctx.client; + ctx.acceptResultType('tools/call', 'task'); + } + + get client(): Client { + if (this.#client === undefined) throw new SdkError(SdkErrorCode.NotConnected, 'TasksClientExtension is not set up on a client'); + return this.#client; + } + + /** + * Calls a tool that may answer with a task handle. The ordinary result + * comes back as `{ kind: 'result' }` for tools that answer synchronously. + */ + async callTool(params: CallToolRequestParams, options?: RequestOptions): Promise { + const raw = await this.client.request({ method: 'tools/call', params }, callToolOutcomeSchema, options); + if ((raw as { resultType?: unknown }).resultType === 'task') { + return { kind: 'task', task: createTaskResultSchema.parse(raw) }; + } + return { kind: 'result', result: raw as CallToolResult }; + } + + /** `tasks/get`: the task's current snapshot, results and input requests inlined. */ + async get(taskId: string, options?: RequestOptions): Promise { + const task = await this.client.request({ method: 'tasks/get', params: { taskId } }, detailedTaskSchema, options); + return task as DetailedTask; + } + + /** `tasks/update`: answers outstanding input requests. Partial answers are accepted. */ + async update(taskId: string, inputResponses: InputResponses, options?: RequestOptions): Promise { + await this.client.request({ method: 'tasks/update', params: { taskId, inputResponses } }, ackSchema, options); + } + + /** `tasks/cancel`: cooperative; resolves on acknowledgement, the task may still settle. */ + async cancel(taskId: string, options?: RequestOptions): Promise { + await this.client.request({ method: 'tasks/cancel', params: { taskId } }, ackSchema, options); + } + + /** + * Polls `tasks/get` at the task's suggested interval until the task is + * terminal. `input_required` snapshots are reported through `onUpdate` + * and polling continues; answer them with {@link update}. + */ + async waitFor(taskId: string, options?: WaitForOptions): Promise { + for (;;) { + options?.signal?.throwIfAborted(); + const task = await this.get(taskId, { signal: options?.signal }); + options?.onUpdate?.(task); + if (TERMINAL.has(task.status)) return task as TerminalTask; + const delay = options?.pollIntervalMs ?? task.pollIntervalMs ?? options?.defaultPollIntervalMs ?? 1000; + await sleep(delay, options?.signal); + } + } +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(signal.reason); + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} diff --git a/packages/client/test/ext/tasks/tasksClientExtension.test.ts b/packages/client/test/ext/tasks/tasksClientExtension.test.ts new file mode 100644 index 0000000000..750144bb8c --- /dev/null +++ b/packages/client/test/ext/tasks/tasksClientExtension.test.ts @@ -0,0 +1,148 @@ +/** + * `TasksClientExtension` against a scripted 2026-07-28 server: the + * capability rides every request, `callTool` splits task handles from plain + * results, and `waitFor` polls at the task's interval until terminal, + * reporting `input_required` on the way. + */ +import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal'; +import { CLIENT_CAPABILITIES_META_KEY, InMemoryTransport } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it } from 'vitest'; + +import { Client } from '../../../src/client/client'; +import { TASKS_EXTENSION_ID, TasksClientExtension } from '../../../src/ext/tasks/index'; + +const MODERN = '2026-07-28'; +const NOW = '2026-09-17T10:00:00.000Z'; + +type Snapshot = Record; + +/** A scripted server: a task whose `tasks/get` answers walk through `script`, one per poll. */ +async function scriptedServer(script: Snapshot[]) { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + let polls = 0; + serverTx.onmessage = message => { + written.push(message); + const request = message as { id?: number | string; method?: string; params?: { name?: string; taskId?: string } }; + if (request.id === undefined) return; + const reply = (result: Snapshot) => void serverTx.send({ jsonrpc: '2.0', id: request.id as number, result }); + switch (request.method) { + case 'server/discover': { + reply({ resultType: 'complete', supportedVersions: [MODERN], capabilities: { extensions: { [TASKS_EXTENSION_ID]: {} } } }); + break; + } + case 'tools/call': { + reply( + request.params?.name === 'slow' + ? { + resultType: 'task', + taskId: 't-1', + status: 'working', + createdAt: NOW, + lastUpdatedAt: NOW, + ttlMs: null, + pollIntervalMs: 5 + } + : { resultType: 'complete', content: [{ type: 'text', text: 'fast' }] } + ); + break; + } + case 'tasks/get': { + const snapshot = script[Math.min(polls, script.length - 1)] ?? {}; + polls += 1; + reply({ + resultType: 'complete', + taskId: 't-1', + createdAt: NOW, + lastUpdatedAt: NOW, + ttlMs: null, + pollIntervalMs: 5, + ...snapshot + }); + break; + } + case 'tasks/update': + case 'tasks/cancel': { + reply({ resultType: 'complete' }); + break; + } + default: { + reply({ resultType: 'complete' }); + } + } + }; + await serverTx.start(); + const tasks = new TasksClientExtension(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' }, extensions: [tasks] }); + await client.connect(clientTx); + return { client, tasks, written, pollCount: () => polls }; +} + +const methodsWritten = (written: JSONRPCMessage[]) => written.map(message => (message as { method?: string }).method).filter(Boolean); + +describe('TasksClientExtension', () => { + it('is bound to one client and refuses use before setup', async () => { + const tasks = new TasksClientExtension(); + await expect(tasks.get('t-1')).rejects.toThrow(/not set up/); + new Client({ name: 'a', version: '1' }, { extensions: [tasks] }); + expect(() => new Client({ name: 'b', version: '1' }, { extensions: [tasks] })).toThrow(/already set up/); + }); + + it('declares the extension on every request and splits task handles from plain results', async () => { + const { client, tasks, written } = await scriptedServer([]); + const plain = await tasks.callTool({ name: 'fast', arguments: {} }); + expect(plain).toEqual({ kind: 'result', result: { content: [{ type: 'text', text: 'fast' }] } }); + const slow = await tasks.callTool({ name: 'slow', arguments: {} }); + expect(slow.kind === 'task' && slow.task).toMatchObject({ + resultType: 'task', + taskId: 't-1', + status: 'working', + pollIntervalMs: 5 + }); + const call = written.find(message => (message as { method?: string }).method === 'tools/call') as { + params: { _meta: Record }; + }; + expect(call.params._meta[CLIENT_CAPABILITIES_META_KEY]).toEqual({ extensions: { [TASKS_EXTENSION_ID]: {} } }); + await client.close(); + }); + + it('waitFor polls at the task interval, reports input_required, and resolves on the terminal snapshot', async () => { + const { client, tasks, pollCount } = await scriptedServer([ + { status: 'working', statusMessage: 'starting' }, + { + status: 'input_required', + inputRequests: { + q: { + method: 'elicitation/create', + params: { message: '?', mode: 'form', requestedSchema: { type: 'object', properties: {} } } + } + } + }, + { status: 'completed', result: { content: [{ type: 'text', text: 'done' }] } } + ]); + const seen: string[] = []; + const done = await tasks.waitFor('t-1', { + onUpdate: task => { + seen.push(task.status); + if (task.status === 'input_required') void tasks.update('t-1', { q: { action: 'accept' } }); + } + }); + expect(seen).toEqual(['working', 'input_required', 'completed']); + expect(done).toMatchObject({ status: 'completed', result: { content: [{ type: 'text', text: 'done' }] } }); + expect(pollCount()).toBe(3); + await client.close(); + }); + + it('waitFor stops on abort without touching the task; cancel is an explicit call', async () => { + const { client, tasks, written } = await scriptedServer([{ status: 'working' }]); + const controller = new AbortController(); + const waiting = tasks.waitFor('t-1', { signal: controller.signal, pollIntervalMs: 1000 }); + await new Promise(resolve => setTimeout(resolve, 10)); + controller.abort(new Error('stop polling')); + await expect(waiting).rejects.toThrow('stop polling'); + expect(methodsWritten(written)).not.toContain('tasks/cancel'); + await tasks.cancel('t-1'); + expect(methodsWritten(written)).toContain('tasks/cancel'); + await client.close(); + }); +}); diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 4fb474f8fb..97218ce9e9 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -21,7 +21,8 @@ "./node_modules/@modelcontextprotocol/core-internal/src/validators/cfWorkerProvider.ts" ], "@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"], - "@modelcontextprotocol/client/_shims": ["./src/shimsNode.ts"] + "@modelcontextprotocol/client/_shims": ["./src/shimsNode.ts"], + "@modelcontextprotocol/core-internal/ext/tasks": ["./node_modules/@modelcontextprotocol/core-internal/src/ext/tasks/index.ts"] } } } diff --git a/packages/client/tsdown.config.ts b/packages/client/tsdown.config.ts index d4a8de998a..368ff2af10 100644 --- a/packages/client/tsdown.config.ts +++ b/packages/client/tsdown.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ entry: [ 'src/index.ts', 'src/stdio.ts', + 'src/ext/tasks/index.ts', 'src/shimsNode.ts', 'src/shimsWorkerd.ts', 'src/shimsBrowser.ts', @@ -31,6 +32,7 @@ export default defineConfig({ '@modelcontextprotocol/core-internal': ['../core-internal/src/index.ts'], '@modelcontextprotocol/core-internal/public': ['../core-internal/src/exports/public/index.ts'], '@modelcontextprotocol/core-internal/ext/skills': ['../core-internal/src/ext/skills/index.ts'], + '@modelcontextprotocol/core-internal/ext/tasks': ['../core-internal/src/ext/tasks/index.ts'], '@modelcontextprotocol/core-internal/validators/ajv': ['../core-internal/src/validators/ajvProvider.ts'], '@modelcontextprotocol/core-internal/validators/cfWorker': ['../core-internal/src/validators/cfWorkerProvider.ts'] } diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json index 7c8ebaf2b8..8ecddb18c0 100644 --- a/packages/core-internal/package.json +++ b/packages/core-internal/package.json @@ -37,6 +37,10 @@ "types": "./src/ext/skills/index.ts", "import": "./src/ext/skills/index.ts" }, + "./ext/tasks": { + "types": "./src/ext/tasks/index.ts", + "import": "./src/ext/tasks/index.ts" + }, "./validators/ajv": { "types": "./src/validators/ajvProvider.ts", "import": "./src/validators/ajvProvider.ts" diff --git a/packages/core-internal/src/ext/tasks/index.ts b/packages/core-internal/src/ext/tasks/index.ts new file mode 100644 index 0000000000..f55a8bda64 --- /dev/null +++ b/packages/core-internal/src/ext/tasks/index.ts @@ -0,0 +1,7 @@ +/** + * Wire types and zod schemas of the MCP Tasks extension + * (`io.modelcontextprotocol/tasks`, schema 2026-07-28), shared by the server + * and client halves of the extension. + */ +export * from './schemas'; +export * from './types'; diff --git a/packages/core-internal/src/ext/tasks/schemas.ts b/packages/core-internal/src/ext/tasks/schemas.ts new file mode 100644 index 0000000000..2542a5fb47 --- /dev/null +++ b/packages/core-internal/src/ext/tasks/schemas.ts @@ -0,0 +1,177 @@ +/* + * Hand-written zod v4 schemas for the MCP Tasks extension wire types + * (./types). Upstream commits only generated JSON Schema + * (`schema/draft/schema.json`, vendored at `test/fixtures/ext-tasks.schema.json` + * as the conformance fixture); these runtime schemas are authored against + * modelcontextprotocol/ext-tasks pinned at commit dcc8d2b (SEP-2663 Final). + * https://github.com/modelcontextprotocol/ext-tasks + * + * Deliberate deviations from the generated fixture, which reflects the + * pre-envelope TS source rather than the wire: + * - Objects are loose (unknown keys pass through) where the fixture says + * `additionalProperties: false`: modern responses carry `resultType` and + * `_meta`, and modern request params carry the `_meta` envelope. + * - `resultType` literals are REQUIRED on result schemas (spec MUST; the + * fixture omits the field entirely). + * - `InputRequest`/`InputResponse` get minimal structural checks (the fixture + * degenerates them to `anyOf [{}, {}, {}]`). + * + * Copyright (c) Model Context Protocol contributors + */ + +import * as z from 'zod/v4'; + +import { TASK_STATUSES } from './types'; + +/** `TaskStatus` */ +export const taskStatusSchema = z.enum(TASK_STATUSES); + +const metaSchema = z.record(z.string(), z.unknown()); + +/** + * An embedded (de-JSON-RPC'd) input request: an elicitation, sampling, or + * roots request object (`{method, params}`), validated structurally. + */ +export const inputRequestSchema = z.looseObject({ + method: z.string(), + params: z.record(z.string(), z.unknown()).optional() +}); + +/** An embedded input response: the bare result object for its request. */ +export const inputResponseSchema = z.record(z.string(), z.unknown()); + +/** `InputRequests` — keyed by identifiers unique over the task's lifetime. */ +export const inputRequestsSchema = z.record(z.string(), inputRequestSchema); + +/** `InputResponses` — keys correspond to outstanding inputRequest keys. */ +export const inputResponsesSchema = z.record(z.string(), inputResponseSchema); + +const taskShape = { + taskId: z.string(), + status: taskStatusSchema, + statusMessage: z.string().optional(), + createdAt: z.string(), + lastUpdatedAt: z.string(), + ttlMs: z.int().nullable(), + pollIntervalMs: z.int().optional() +}; + +/** `Task` */ +export const taskSchema = z.looseObject(taskShape); + +/** `WorkingTask` */ +export const workingTaskSchema = z.looseObject({ + ...taskShape, + status: z.literal('working') +}); + +/** `InputRequiredTask` */ +export const inputRequiredTaskSchema = z.looseObject({ + ...taskShape, + status: z.literal('input_required'), + inputRequests: inputRequestsSchema +}); + +/** `CompletedTask` — the original request's result structure inlined. */ +export const completedTaskSchema = z.looseObject({ + ...taskShape, + status: z.literal('completed'), + result: z.record(z.string(), z.unknown()) +}); + +/** `FailedTask` — the JSON-RPC error object inlined. */ +export const failedTaskSchema = z.looseObject({ + ...taskShape, + status: z.literal('failed'), + error: z.record(z.string(), z.unknown()) +}); + +/** `CancelledTask` */ +export const cancelledTaskSchema = z.looseObject({ + ...taskShape, + status: z.literal('cancelled') +}); + +/** `DetailedTask` — discriminated on `status`. */ +export const detailedTaskSchema = z.discriminatedUnion('status', [ + workingTaskSchema, + inputRequiredTaskSchema, + completedTaskSchema, + failedTaskSchema, + cancelledTaskSchema +]); + +/** `CreateTaskResult` — flat `Result & Task` with `resultType: "task"` (MUST). */ +export const createTaskResultSchema = z.looseObject({ + ...taskShape, + resultType: z.literal('task'), + _meta: metaSchema.optional() +}); + +const completeResultShape = { + resultType: z.literal('complete'), + _meta: metaSchema.optional() +}; + +/** `GetTaskResult` — a `DetailedTask` variant with `resultType: "complete"` (MUST). */ +export const getTaskResultSchema = z.discriminatedUnion('status', [ + workingTaskSchema.extend(completeResultShape), + inputRequiredTaskSchema.extend(completeResultShape), + completedTaskSchema.extend(completeResultShape), + failedTaskSchema.extend(completeResultShape), + cancelledTaskSchema.extend(completeResultShape) +]); + +/** `UpdateTaskResult` — empty ack with `resultType: "complete"` (MUST). */ +export const updateTaskResultSchema = z.looseObject(completeResultShape); + +/** `CancelTaskResult` — empty ack with `resultType: "complete"` (MUST). */ +export const cancelTaskResultSchema = z.looseObject(completeResultShape); + +const requestIdSchema = z.union([z.string(), z.int()]); + +/** `tasks/get` params (`_meta` carries the modern per-request envelope). */ +export const getTaskParamsSchema = z.looseObject({ + taskId: z.string(), + _meta: metaSchema.optional() +}); + +/** `GetTaskRequest` */ +export const getTaskRequestSchema = z.looseObject({ + jsonrpc: z.literal('2.0'), + id: requestIdSchema, + method: z.literal('tasks/get'), + params: getTaskParamsSchema +}); + +/** `tasks/update` params (`_meta` carries the modern per-request envelope). */ +export const updateTaskParamsSchema = z.looseObject({ + taskId: z.string(), + inputResponses: inputResponsesSchema, + _meta: metaSchema.optional() +}); + +/** `UpdateTaskRequest` */ +export const updateTaskRequestSchema = z.looseObject({ + jsonrpc: z.literal('2.0'), + id: requestIdSchema, + method: z.literal('tasks/update'), + params: updateTaskParamsSchema +}); + +/** `tasks/cancel` params (`_meta` carries the modern per-request envelope). */ +export const cancelTaskParamsSchema = z.looseObject({ + taskId: z.string(), + _meta: metaSchema.optional() +}); + +/** `CancelTaskRequest` */ +export const cancelTaskRequestSchema = z.looseObject({ + jsonrpc: z.literal('2.0'), + id: requestIdSchema, + method: z.literal('tasks/cancel'), + params: cancelTaskParamsSchema +}); + +/** `TasksExtensionCapability` — an empty object declares support. */ +export const tasksExtensionCapabilitySchema = z.strictObject({}); diff --git a/packages/core-internal/src/ext/tasks/types.ts b/packages/core-internal/src/ext/tasks/types.ts new file mode 100644 index 0000000000..494b1d5a29 --- /dev/null +++ b/packages/core-internal/src/ext/tasks/types.ts @@ -0,0 +1,220 @@ +/* + * MCP Tasks extension wire types (extension id: io.modelcontextprotocol/tasks). + * + * Adapted from modelcontextprotocol/ext-tasks, pinned at commit dcc8d2b + * (SEP-2663 Final): `schema/draft/schema.ts`, re-based on + * `@modelcontextprotocol/server` v2 types — the modern SDK ships the MRTR + * `InputRequest`/`InputResponse` unions the upstream file's TODOs point at, so + * those are re-exported rather than re-declared. The JSON-RPC request shapes + * are declared standalone (not extending the SDK's `JSONRPCRequest`) so the + * wire contract stays pinned to this file. `notifications/tasks` and the + * subscription additions are omitted: v1 is polling-only (the engine contract)). + * + * This module (with ./schemas) is the ONLY import source for task wire types + * in this repo — the SDK's deprecated 2025-11-25 task exports (`Task`, + * `CreateTaskResult`, `TaskStatus`, `GetTaskRequest`, ...) carry the removed + * legacy wire shape and must not be used (the engine contract)). + * https://github.com/modelcontextprotocol/ext-tasks + * + * Copyright (c) Model Context Protocol contributors + */ + +import type { InputRequests, InputResponses, Result } from '../../types/index'; + +/** + * A single input request / response embedded in a task, re-based on the SDK + * v2 MRTR unions (sampling, roots, or elicitation). Keys in the containing + * maps MUST be unique over the lifetime of a single task. + */ +export type { InputRequest, InputRequests, InputResponse, InputResponses } from '../../types/index'; + +/** The MCP Tasks extension identifier. An empty-object capability declares support. */ +export const TASKS_EXTENSION_ID = 'io.modelcontextprotocol/tasks'; + +/** All task statuses, in lifecycle order (terminal states last). */ +export const TASK_STATUSES = ['working', 'input_required', 'completed', 'failed', 'cancelled'] as const; + +/** The status of a task. */ +export type TaskStatus = (typeof TASK_STATUSES)[number]; + +/** Data associated with a task. */ +export interface Task { + /** The task identifier. */ + taskId: string; + + /** Current task status. */ + status: TaskStatus; + + /** + * Optional human-readable message describing the current task state: + * progress descriptions for "working", blocked work for "input_required", + * reasons for "cancelled", summaries for "completed", diagnostics for + * "failed". + */ + statusMessage?: string; + + /** ISO 8601 timestamp when the task was created. */ + createdAt: string; + + /** ISO 8601 timestamp when the task was last updated. */ + lastUpdatedAt: string; + + /** + * Time-to-live duration from creation in integer milliseconds, null for + * unlimited. The server may discard the task after the TTL elapses. This + * value MAY change over the lifetime of a task. + */ + ttlMs: number | null; + + /** + * Suggested polling interval in integer milliseconds. Clients SHOULD honor + * this value to avoid overwhelming the server. This value MAY change over + * the lifetime of a task. + */ + pollIntervalMs?: number; +} + +/** A task that is in a normal working state. */ +export interface WorkingTask extends Task { + status: 'working'; +} + +/** A task that is waiting for input from the client. */ +export interface InputRequiredTask extends Task { + status: 'input_required'; + + /** + * Server-to-client requests that need to be fulfilled during task + * execution. Keys are arbitrary identifiers for matching requests to + * responses. + */ + inputRequests: InputRequests; +} + +/** A task that has completed successfully. */ +export interface CompletedTask extends Task { + status: 'completed'; + + /** + * The final result of the task. The structure matches the result type of + * the original request — for a `tools/call` task, the `CallToolResult` + * structure. + */ + result: { [key: string]: unknown }; +} + +/** A task that has failed due to a JSON-RPC error during execution. */ +export interface FailedTask extends Task { + status: 'failed'; + + /** The JSON-RPC error that caused the task to fail. */ + error: { [key: string]: unknown }; +} + +/** A task that has been cancelled. */ +export interface CancelledTask extends Task { + status: 'cancelled'; +} + +/** + * A task with status-specific fields inlined, as returned by `tasks/get`: + * terminal results or pending input requests ride on the snapshot itself. + */ +export type DetailedTask = WorkingTask | InputRequiredTask | CompletedTask | FailedTask | CancelledTask; + +/** + * The result returned by a server in lieu of a standard result shape when it + * elects to process a request asynchronously — flat `Result & Task`. The + * `resultType` field MUST be `"task"` on the wire; it is declared explicitly + * here (the upstream type leaves it to the old SDK `Result`'s index + * signature, which the v2 `Result` no longer has). + */ +export type CreateTaskResult = Result & Task & { resultType: 'task' }; + +/** Parameters of a `tasks/get` request. */ +export interface GetTaskParams { + /** The task identifier to query. */ + taskId: string; + + /** + * The modern (2026-07-28) per-request envelope: carries + * `io.modelcontextprotocol/clientCapabilities` among other keys. Not part + * of the upstream extension schema's params (which lists only `taskId`) — + * declared here because every modern request threads it. + */ + _meta?: Record; +} + +/** A request to retrieve the state of a task. */ +export interface GetTaskRequest { + jsonrpc: '2.0'; + id: string | number; + method: 'tasks/get'; + params: GetTaskParams; +} + +/** + * The response to `tasks/get`: the appropriate {@link DetailedTask} variant + * for the task's current status, with `resultType: "complete"` (MUST). + */ +export type GetTaskResult = Result & DetailedTask & { resultType: 'complete' }; + +/** Parameters of a `tasks/update` request. */ +export interface UpdateTaskParams { + /** The task identifier to update. */ + taskId: string; + + /** + * Responses to outstanding inputRequests previously surfaced by the + * server. Each key MUST correspond to a currently-outstanding inputRequest + * key (unknown keys are ignored; partial responses are accepted). + */ + inputResponses: InputResponses; + + /** The modern per-request envelope (see {@link GetTaskParams._meta}). */ + _meta?: Record; +} + +/** A request to provide input responses to a task in the input_required state. */ +export interface UpdateTaskRequest { + jsonrpc: '2.0'; + id: string | number; + method: 'tasks/update'; + params: UpdateTaskParams; +} + +/** + * The response to `tasks/update`: an empty acknowledgement (eventually + * consistent), with `resultType: "complete"` (MUST). + */ +export type UpdateTaskResult = Result & { resultType: 'complete' }; + +/** Parameters of a `tasks/cancel` request. */ +export interface CancelTaskParams { + /** The task identifier to cancel. */ + taskId: string; + + /** The modern per-request envelope (see {@link GetTaskParams._meta}). */ + _meta?: Record; +} + +/** A request to cancel a task. Cancellation is cooperative and eventually consistent. */ +export interface CancelTaskRequest { + jsonrpc: '2.0'; + id: string | number; + method: 'tasks/cancel'; + params: CancelTaskParams; +} + +/** + * The response to `tasks/cancel`: an empty acknowledgement (ack does not mean + * stopped), with `resultType: "complete"` (MUST). + */ +export type CancelTaskResult = Result & { resultType: 'complete' }; + +/** + * The extension capability declaration for the tasks extension. An empty + * object indicates support; no extension-specific settings are currently + * defined. + */ +export type TasksExtensionCapability = Record; diff --git a/packages/core-internal/test/packageTopologyPins.test.ts b/packages/core-internal/test/packageTopologyPins.test.ts index 5bc882e8e3..6716f30cfd 100644 --- a/packages/core-internal/test/packageTopologyPins.test.ts +++ b/packages/core-internal/test/packageTopologyPins.test.ts @@ -39,13 +39,22 @@ const PUBLIC_PACKAGES: Record void; +} + +interface TaskRecord { + taskId: string; + principal?: string; + context?: Record; + status: TaskStatus; + statusMessage?: string; + createdAt: number; + lastUpdatedAt: number; + ttlMs: number | null; + pollIntervalMs?: number; + result?: CallToolResult; + error?: { code: number; message: string; data?: unknown }; + pendingInput?: PendingInput; + abort: AbortController; + ttlTimer?: ReturnType; +} + +const TERMINAL: ReadonlySet = new Set(['completed', 'failed', 'cancelled']); + +/** + * The writer side of a task, for the code that does the work. Obtained from + * {@link InMemoryTaskStore.handle}; a persistent store exposes its own + * equivalent (or none, when the work reports through the store directly). + */ +export interface TaskHandle { + readonly taskId: string; + /** Aborted when `tasks/cancel` is acknowledged. */ + readonly signal: AbortSignal; + /** Writes `statusMessage` for pollers. */ + status(message: string): Promise; + /** + * Moves the task to `input_required` with `requests` outstanding and + * resolves once `tasks/update` has answered every key (partial updates + * accumulate). Rejects if the task is cancelled while waiting. + */ + requireInput(requests: InputRequests): Promise; + complete(result: CallToolResult): Promise; + fail(error: { code: number; message: string; data?: unknown }): Promise; +} + +/** Options for {@link InMemoryTaskStore}. */ +export interface InMemoryTaskStoreOptions { + /** Task id factory. Default `crypto.randomUUID()`. */ + createTaskId?: () => string; + /** Clock, for tests. Default `Date.now`. */ + now?: () => number; +} + +export class InMemoryTaskStore implements TaskStore { + readonly #tasks = new Map(); + readonly #createTaskId: () => string; + readonly #now: () => number; + + constructor(options?: InMemoryTaskStoreOptions) { + this.#createTaskId = options?.createTaskId ?? (() => crypto.randomUUID()); + this.#now = options?.now ?? (() => Date.now()); + } + + /** Clears every timer and forgets every task. For tests and shutdown. */ + close(): void { + for (const record of this.#tasks.values()) { + if (record.ttlTimer !== undefined) clearTimeout(record.ttlTimer); + record.abort.abort(); + } + this.#tasks.clear(); + } + + async create(params: CreateTaskParams): Promise { + if (params.ttlMs !== null && (!Number.isSafeInteger(params.ttlMs) || params.ttlMs < 0)) { + throw new RangeError(`ttlMs must be a non-negative integer or null, got ${params.ttlMs}`); + } + if (params.pollIntervalMs !== undefined && (!Number.isSafeInteger(params.pollIntervalMs) || params.pollIntervalMs < 0)) { + throw new RangeError(`pollIntervalMs must be a non-negative integer, got ${params.pollIntervalMs}`); + } + const now = this.#now(); + const record: TaskRecord = { + taskId: this.#createTaskId(), + ...(params.principal !== undefined && { principal: params.principal }), + ...(params.context !== undefined && { context: params.context }), + status: 'working', + createdAt: now, + lastUpdatedAt: now, + ttlMs: params.ttlMs, + ...(params.pollIntervalMs !== undefined && { pollIntervalMs: params.pollIntervalMs }), + abort: new AbortController() + }; + if (record.ttlMs !== null) { + record.ttlTimer = setTimeout(() => this.#purge(record), record.ttlMs); + record.ttlTimer.unref?.(); + } + this.#tasks.set(record.taskId, record); + return this.#snapshot(record); + } + + async get(taskId: string, access?: TaskAccess): Promise { + const record = this.#lookup(taskId, access); + return record === undefined ? undefined : this.#detailed(record); + } + + async update(taskId: string, inputResponses: InputResponses, access?: TaskAccess): Promise { + const record = this.#lookup(taskId, access); + if (record === undefined) return false; + const pending = record.pendingInput; + if (pending === undefined || TERMINAL.has(record.status)) return true; + for (const [key, response] of Object.entries(inputResponses)) { + // Unknown keys are ignored; the first answer to a key wins. + if (key in pending.requests && !(key in pending.responses)) pending.responses[key] = response; + } + this.#touch(record); + if (Object.keys(pending.requests).every(key => key in pending.responses)) { + record.pendingInput = undefined; + record.status = 'working'; + pending.resolve(pending.responses); + } + return true; + } + + async cancel(taskId: string, access?: TaskAccess): Promise { + const record = this.#lookup(taskId, access); + if (record === undefined) return false; + if (TERMINAL.has(record.status)) return true; + record.status = 'cancelled'; + record.pendingInput = undefined; + this.#touch(record); + record.abort.abort(); + return true; + } + + /** The context recorded at creation, for the server's execution to pick the work up. */ + context(taskId: string): Record | undefined { + return this.#tasks.get(taskId)?.context; + } + + /** The writer handle for a task the store holds. Throws for an unknown task. */ + handle(taskId: string): TaskHandle { + const record = this.#tasks.get(taskId); + if (record === undefined) throw new Error(`Task "${taskId}" not found`); + const live = (): TaskRecord | undefined => { + const current = this.#tasks.get(taskId); + return current === undefined || TERMINAL.has(current.status) ? undefined : current; + }; + return { + taskId, + signal: record.abort.signal, + status: async message => { + const current = live(); + if (current === undefined) return; + current.statusMessage = message; + this.#touch(current); + }, + requireInput: requests => { + const current = live(); + if (current === undefined) return Promise.reject(new Error(`Task "${taskId}" is no longer running`)); + return new Promise((resolve, reject) => { + current.pendingInput = { requests, responses: {}, resolve }; + current.status = 'input_required'; + this.#touch(current); + current.abort.signal.addEventListener('abort', () => reject(new Error(`Task "${taskId}" was cancelled`)), { + once: true + }); + }); + }, + complete: async result => { + const current = live(); + if (current === undefined) return; + current.status = 'completed'; + current.result = result; + this.#touch(current); + }, + fail: async error => { + const current = live(); + if (current === undefined) return; + current.status = 'failed'; + current.error = error; + this.#touch(current); + } + }; + } + + // ------------------------------------------------------------- internals -- + + #lookup(taskId: string, access: TaskAccess | undefined): TaskRecord | undefined { + const record = this.#tasks.get(taskId); + if (record === undefined) return undefined; + if (record.principal !== undefined && record.principal !== access?.principal) return undefined; + return record; + } + + #touch(record: TaskRecord): void { + record.lastUpdatedAt = this.#now(); + } + + #purge(record: TaskRecord): void { + record.abort.abort(); + this.#tasks.delete(record.taskId); + } + + #snapshot(record: TaskRecord): Task { + return { + taskId: record.taskId, + status: record.status, + ...(record.statusMessage !== undefined && { statusMessage: record.statusMessage }), + createdAt: new Date(record.createdAt).toISOString(), + lastUpdatedAt: new Date(record.lastUpdatedAt).toISOString(), + ttlMs: record.ttlMs, + ...(record.pollIntervalMs !== undefined && { pollIntervalMs: record.pollIntervalMs }) + }; + } + + #detailed(record: TaskRecord): DetailedTask { + const base = this.#snapshot(record); + switch (record.status) { + case 'working': { + return { ...base, status: 'working' }; + } + case 'input_required': { + const pending = record.pendingInput; + const inputRequests: InputRequests = {}; + for (const [key, request] of Object.entries(pending?.requests ?? {})) { + if (!(key in (pending?.responses ?? {}))) inputRequests[key] = request; + } + return { ...base, status: 'input_required', inputRequests }; + } + case 'completed': { + return { ...base, status: 'completed', result: (record.result ?? { content: [] }) as { [key: string]: unknown } }; + } + case 'failed': { + return { ...base, status: 'failed', error: record.error ?? { code: -32_603, message: 'Task failed' } }; + } + case 'cancelled': { + return { ...base, status: 'cancelled' }; + } + } + } +} diff --git a/packages/server/src/ext/tasks/index.ts b/packages/server/src/ext/tasks/index.ts new file mode 100644 index 0000000000..2cdcf9684a --- /dev/null +++ b/packages/server/src/ext/tasks/index.ts @@ -0,0 +1,68 @@ +/** + * `@modelcontextprotocol/server/ext/tasks` — the server side of the MCP + * Tasks extension (`io.modelcontextprotocol/tasks`). + * + * `TasksExtension` owns the wire: capability, `tasks/get` / `tasks/update` / + * `tasks/cancel`, the client-capability check, and the `tools/call` task + * handle. A `TaskStore` owns the task: `InMemoryTaskStore` is the + * in-process reference; durable stores implement the same four methods and + * live outside the SDK. How the work behind a task runs is the server's + * own. + */ + +export type { InMemoryTaskStoreOptions, TaskHandle } from './inMemoryStore'; +export { InMemoryTaskStore } from './inMemoryStore'; +export type { CreateTaskParams, TaskAccess, TaskStore } from './store'; +export type { CreateTaskOptions, TasksExtensionOptions, TaskToolResult } from './tasksExtension'; +export { declaresTasksExtension, TasksExtension } from './tasksExtension'; +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'; diff --git a/packages/server/src/ext/tasks/store.ts b/packages/server/src/ext/tasks/store.ts new file mode 100644 index 0000000000..dc655f13c9 --- /dev/null +++ b/packages/server/src/ext/tasks/store.ts @@ -0,0 +1,54 @@ +/** + * The store interface of the Tasks extension: what the `tasks/*` request handlers + * and `TasksExtension.create` call. Everything here is request/response over + * JSON. How a task's work is executed — in-process, on a queue, in a + * durable-execution runtime — is the server's business, not the SDK's: the + * SDK only cares that a task can be created, read, answered, and cancelled. + */ + +import type { InputResponses } from '@modelcontextprotocol/core-internal'; +import type { DetailedTask, Task } from '@modelcontextprotocol/core-internal/ext/tasks'; + +/** What `TasksExtension.create` asks a store to durably create. */ +export interface CreateTaskParams { + /** Retention from creation, ms; `null` = unlimited. */ + ttlMs: number | null; + /** Suggested client polling interval, ms; omitted = no hint on the task. */ + pollIntervalMs?: number; + /** + * Auth binding: the principal the creating request was authenticated as, + * when the transport knows one. Stores MUST refuse `tasks/*` access from + * a different principal (fail closed, no existence leak) and MAY ignore + * it when absent. + */ + principal?: string; + /** + * Free-form, store-defined. The originating tool name and arguments, a + * workflow id — whatever the server's execution needs to find or start + * the work. Opaque to the SDK. + */ + context?: Record; +} + +/** The caller identity presented on a `tasks/*` request. */ +export interface TaskAccess { + principal?: string; +} + +/** + * A task store. Engine selection is configuration: the extension and the + * tool handlers that create tasks are byte-identical across stores. + * + * - `create` MUST NOT resolve before a subsequent `get(taskId)` would succeed + * (durable creation, a Tasks extension rule). + * - `get`, `update`, `cancel` resolve `undefined` / `false` for an unknown, + * expired, or foreign-principal task; the extension answers `-32602`. + * - `cancel` is cooperative: it resolves on acknowledgement, the task may + * still settle `completed` or `failed`. Idempotent on terminal tasks. + */ +export interface TaskStore { + create(params: CreateTaskParams): Promise; + get(taskId: string, access?: TaskAccess): Promise; + update(taskId: string, inputResponses: InputResponses, access?: TaskAccess): Promise; + cancel(taskId: string, access?: TaskAccess): Promise; +} diff --git a/packages/server/src/ext/tasks/tasksExtension.ts b/packages/server/src/ext/tasks/tasksExtension.ts new file mode 100644 index 0000000000..dd197864fd --- /dev/null +++ b/packages/server/src/ext/tasks/tasksExtension.ts @@ -0,0 +1,172 @@ +/** + * `TasksExtension` — the server side of the MCP Tasks extension + * (`io.modelcontextprotocol/tasks`) as a {@linkcode ServerExtension}. It + * owns the wire: the capability, the per-request client-capability check, + * the `tasks/get`, `tasks/update` and `tasks/cancel` methods, and the + * `tools/call` result shape. Everything about the task itself — where its + * state lives, how its work runs — is behind the {@link TaskStore} the + * server passes in. + * + * ```ts + * const store = new InMemoryTaskStore(); + * const tasks = new TasksExtension(store); + * const server = new McpServer(info, { extensions: [tasks] }); + * + * server.registerTool('send_report', { inputSchema }, async (input, ctx) => { + * const task = await tasks.create(ctx, { ttlMs: 3_600_000 }); + * void runReport(store.handle(task.taskId), input); // the server's own execution + * return task; + * }); + * ``` + */ + +import type { CallToolResult, InputResponses, JSONRPCRequest, Result, ServerContext } from '@modelcontextprotocol/core-internal'; +import { + CLIENT_CAPABILITIES_META_KEY, + MissingRequiredClientCapabilityError, + ProtocolError, + ProtocolErrorCode +} from '@modelcontextprotocol/core-internal'; +import type { CancelTaskResult, CreateTaskResult, GetTaskResult, UpdateTaskResult } from '@modelcontextprotocol/core-internal/ext/tasks'; +import { + cancelTaskParamsSchema, + getTaskParamsSchema, + inputResponsesSchema, + TASKS_EXTENSION_ID +} from '@modelcontextprotocol/core-internal/ext/tasks'; + +import type { ServerExtension, ServerExtensionContext } from '../../server/extension'; +import type { CreateTaskParams, TaskStore } from './store'; + +/** Options for {@link TasksExtension}. */ +export interface TasksExtensionOptions { + /** Default retention for `create` when the caller gives none, ms; `null` = unlimited. Default 86_400_000 (24h). */ + defaultTtlMs?: number | null; + /** Default polling hint for `create` when the caller gives none, ms. Default 5_000. */ + defaultPollIntervalMs?: number; +} + +/** What a tool handler passes to {@link TasksExtension.create}; every field optional. */ +export type CreateTaskOptions = Partial>; + +/** + * The task handle a tool handler returns in place of a `CallToolResult`. + * The 2026-07-28 result encoder forwards `resultType: "task"` for `tools/call` + * verbatim, so the flat handle is the wire result. + */ +export type TaskToolResult = CallToolResult & CreateTaskResult; + +const isRecord = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value); + +/** Whether this request's `_meta` envelope declared the tasks extension. */ +export const declaresTasksExtension = (ctx: ServerContext): boolean => { + const envelope = ctx.mcpReq.envelope as Record | undefined; + const capabilities = envelope?.[CLIENT_CAPABILITIES_META_KEY]; + if (!isRecord(capabilities)) return false; + const extensions = capabilities['extensions']; + return isRecord(extensions) && TASKS_EXTENSION_ID in extensions; +}; + +const requireTasksExtension = (ctx: ServerContext, what: string): void => { + if (declaresTasksExtension(ctx)) return; + throw new MissingRequiredClientCapabilityError( + { requiredCapabilities: { extensions: { [TASKS_EXTENSION_ID]: {} } } }, + `${what} requires the request to declare the "${TASKS_EXTENSION_ID}" extension capability` + ); +}; + +const principalOf = (ctx: ServerContext): string | undefined => ctx.http?.authInfo?.clientId; + +const notFound = (): never => { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Task not found'); +}; + +/** + * `tasks/update` params as the HANDLER sees them. `inputResponses` is a + * reserved multi-round-trip name on the 2026-07-28 revision: the protocol + * layer lifts it out of every client request's params before dispatch and + * surfaces it at `ctx.mcpReq.inputResponses`. The wire schema + * (`updateTaskParamsSchema`) keeps the field required; here it is optional + * and read back from the context. + */ +const updateTaskHandlerParamsSchema = getTaskParamsSchema.extend({ inputResponses: inputResponsesSchema.optional() }); + +export class TasksExtension implements ServerExtension { + readonly id = TASKS_EXTENSION_ID; + readonly store: TaskStore; + readonly #defaultTtlMs: number | null; + readonly #defaultPollIntervalMs: number; + + constructor(store: TaskStore, options?: TasksExtensionOptions) { + this.store = store; + this.#defaultTtlMs = options?.defaultTtlMs === undefined ? 86_400_000 : options.defaultTtlMs; + this.#defaultPollIntervalMs = options?.defaultPollIntervalMs ?? 5000; + } + + /** + * Creates a task for the current `tools/call` and returns the handle the + * tool handler answers with. Refuses (`-32021`) when the request did not + * declare the extension; binds the task to the request's principal when + * the transport knows one. + */ + async create(ctx: ServerContext, options?: CreateTaskOptions): Promise { + requireTasksExtension(ctx, `Tool "${ctx.mcpReq.method}" executes as a task and`); + const principal = principalOf(ctx); + const task = await this.store.create({ + ttlMs: options?.ttlMs === undefined ? this.#defaultTtlMs : options.ttlMs, + pollIntervalMs: options?.pollIntervalMs ?? this.#defaultPollIntervalMs, + ...(principal !== undefined && { principal }), + ...(options?.context !== undefined && { context: options.context }) + }); + const result: CreateTaskResult = { resultType: 'task', ...task }; + return result as TaskToolResult; + } + + /** + * Registers the extension's methods and its `tools/call` middleware. The + * server calls this for each entry in `extensions`. + * + * Experimental: the extension shape may change in a minor release. + * + * @experimental + */ + setup(ctx: ServerExtensionContext): void { + ctx.setRequestHandler('tasks/get', { params: getTaskParamsSchema }, async (params, requestCtx): Promise => { + requireTasksExtension(requestCtx, 'tasks/get'); + const task = await this.store.get(params.taskId, { principal: principalOf(requestCtx) }); + if (task === undefined) return notFound(); + return { resultType: 'complete', ...task }; + }); + + ctx.setRequestHandler( + 'tasks/update', + { params: updateTaskHandlerParamsSchema }, + async (params, requestCtx): Promise => { + requireTasksExtension(requestCtx, 'tasks/update'); + const inputResponses = (requestCtx.mcpReq.inputResponses ?? params.inputResponses ?? {}) as InputResponses; + const found = await this.store.update(params.taskId, inputResponses, { principal: principalOf(requestCtx) }); + if (!found) return notFound(); + return { resultType: 'complete' }; + } + ); + + ctx.setRequestHandler('tasks/cancel', { params: cancelTaskParamsSchema }, async (params, requestCtx): Promise => { + requireTasksExtension(requestCtx, 'tasks/cancel'); + const found = await this.store.cancel(params.taskId, { principal: principalOf(requestCtx) }); + if (!found) return notFound(); + return { resultType: 'complete' }; + }); + + // A task handle may only be answered to a client that can consume it. + // `create(ctx, …)` checks up front; this catches handles minted some + // other way (an external engine's own create) — the extension owns + // the wire regardless of where the task came from. + ctx.use('tools/call', async (request: JSONRPCRequest, requestCtx, next): Promise => { + const result = await next(request, requestCtx); + if ((result as { resultType?: unknown }).resultType === 'task') { + requireTasksExtension(requestCtx, `Tool "${String((request.params as { name?: unknown })?.name)}" executes as a task and`); + } + return result; + }); + } +} diff --git a/packages/server/test/ext/tasks/inMemoryStore.test.ts b/packages/server/test/ext/tasks/inMemoryStore.test.ts new file mode 100644 index 0000000000..59473ada47 --- /dev/null +++ b/packages/server/test/ext/tasks/inMemoryStore.test.ts @@ -0,0 +1,82 @@ +/** + * `InMemoryTaskStore`: the store semantics a persistent implementation must + * reproduce — durable create, partial input answers, first answer wins, + * cancel aborts a pending input wait, terminal writes are ignored, TTL purge, + * principal fail-closed. + */ +import { describe, expect, it } from 'vitest'; + +import { InMemoryTaskStore } from '../../../src/ext/tasks/index'; + +const tick = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms)); + +describe('InMemoryTaskStore', () => { + it('creates durably and records context for the execution to pick up', async () => { + const store = new InMemoryTaskStore({ createTaskId: () => 'fixed' }); + const task = await store.create({ ttlMs: null, pollIntervalMs: 50, context: { tool: 'greet' } }); + expect(task).toMatchObject({ taskId: 'fixed', status: 'working', ttlMs: null, pollIntervalMs: 50 }); + expect(await store.get('fixed')).toMatchObject({ status: 'working' }); + expect(store.context('fixed')).toEqual({ tool: 'greet' }); + store.close(); + }); + + it('accumulates partial input answers; the first answer to a key wins; unknown keys are ignored', async () => { + const store = new InMemoryTaskStore(); + const { taskId } = await store.create({ ttlMs: null }); + const handle = store.handle(taskId); + const answers = handle.requireInput({ + a: { + method: 'elicitation/create', + params: { message: 'q', mode: 'form', requestedSchema: { type: 'object', properties: {} } } + }, + b: { method: 'elicitation/create', params: { message: 'q', mode: 'form', requestedSchema: { type: 'object', properties: {} } } } + }); + await tick(); + expect(await store.get(taskId)).toMatchObject({ status: 'input_required', inputRequests: { a: {}, b: {} } }); + expect(await store.update(taskId, { a: { action: 'accept' }, zzz: { action: 'accept' } })).toBe(true); + expect(await store.get(taskId)).toMatchObject({ status: 'input_required', inputRequests: { b: {} } }); + await store.update(taskId, { a: { action: 'decline' }, b: { action: 'cancel' } }); + expect(await answers).toEqual({ a: { action: 'accept' }, b: { action: 'cancel' } }); + expect((await store.get(taskId))?.status).toBe('working'); + store.close(); + }); + + it('cancel rejects a pending input wait, aborts the signal, and later writes are ignored', async () => { + const store = new InMemoryTaskStore(); + const { taskId } = await store.create({ ttlMs: null }); + const handle = store.handle(taskId); + const waiting = handle.requireInput({ + a: { method: 'elicitation/create', params: { message: 'q', mode: 'form', requestedSchema: { type: 'object', properties: {} } } } + }); + expect(await store.cancel(taskId)).toBe(true); + await expect(waiting).rejects.toThrow('cancelled'); + expect(handle.signal.aborted).toBe(true); + await handle.complete({ content: [] }); + await handle.status('late'); + expect(await store.get(taskId)).toMatchObject({ status: 'cancelled' }); + expect((await store.get(taskId))?.statusMessage).toBeUndefined(); + expect(await store.cancel(taskId)).toBe(true); + expect(await store.cancel('missing')).toBe(false); + store.close(); + }); + + it('purges at the TTL deadline and fails closed on a foreign principal', async () => { + const store = new InMemoryTaskStore(); + const { taskId } = await store.create({ ttlMs: 10, principal: 'alice' }); + expect(await store.get(taskId)).toBeUndefined(); + expect(await store.get(taskId, { principal: 'bob' })).toBeUndefined(); + expect((await store.get(taskId, { principal: 'alice' }))?.taskId).toBe(taskId); + expect(await store.update(taskId, {}, { principal: 'bob' })).toBe(false); + await tick(30); + expect(await store.get(taskId, { principal: 'alice' })).toBeUndefined(); + expect(() => store.handle(taskId)).toThrow('not found'); + store.close(); + }); + + it('rejects invalid ttlMs and pollIntervalMs', async () => { + const store = new InMemoryTaskStore(); + await expect(store.create({ ttlMs: -1 })).rejects.toBeInstanceOf(RangeError); + await expect(store.create({ ttlMs: null, pollIntervalMs: 1.5 })).rejects.toBeInstanceOf(RangeError); + store.close(); + }); +}); diff --git a/packages/server/test/ext/tasks/tasks.e2e.test.ts b/packages/server/test/ext/tasks/tasks.e2e.test.ts new file mode 100644 index 0000000000..7e19463ecf --- /dev/null +++ b/packages/server/test/ext/tasks/tasks.e2e.test.ts @@ -0,0 +1,196 @@ +/** + * End to end through a real `Client` against the stateless `createMcpHandler` + * (a fresh `McpServer` per request): nothing about a running task lives on + * the server instance that answered `tools/call`; the store is the only + * shared state, and the work runs wherever the server chooses — here, a + * plain async function driving the store's writer handle. + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { TasksClientExtension } from '@modelcontextprotocol/client/ext/tasks'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import type { DetailedTask, TaskHandle } from '../../../src/ext/tasks/index'; +import { InMemoryTaskStore, TASKS_EXTENSION_ID, TasksExtension } from '../../../src/ext/tasks/index'; + +const TASKS_CAPABILITY = { extensions: { [TASKS_EXTENSION_ID]: {} } }; +import { CLIENT_CAPABILITIES_META_KEY, createMcpHandler, McpServer, PROTOCOL_VERSION_META_KEY } from '../../../src/index'; + +type Work = (handle: TaskHandle, input: { name: string }) => Promise; + +function createHarness(work: Work, options?: { declareExtension?: boolean; plainTool?: boolean }) { + const store = new InMemoryTaskStore(); + const tasks = new TasksExtension(store, { defaultPollIntervalMs: 10 }); + const createServer = () => { + const server = new McpServer({ name: 'tasks-test', version: '1.0.0' }, { extensions: [tasks] }); + server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, async (input, ctx) => { + const task = await tasks.create(ctx); + void work(store.handle(task.taskId), input); + return task; + }); + if (options?.plainTool) { + // A handle minted outside `tasks.create`: the tools/call override still gates it. + server.registerTool('sneaky', {}, async () => { + const task = await store.create({ ttlMs: null }); + return { resultType: 'task', ...task } as never; + }); + } + return server; + }; + const mcpHandler = createMcpHandler(createServer); + const declare = options?.declareExtension !== false; + /** A raw `tools/call` POST, for the cases where the client deliberately does not declare the extension. */ + const callTool = async (name: string, args: Record) => { + const body = { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name, + arguments: args, + _meta: { [PROTOCOL_VERSION_META_KEY]: '2026-07-28', [CLIENT_CAPABILITIES_META_KEY]: declare ? TASKS_CAPABILITY : {} } + } + }; + const response = await mcpHandler.fetch( + new Request('http://test.local/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': '2026-07-28', + 'Mcp-Method': 'tools/call', + 'Mcp-Name': name + }, + body: JSON.stringify(body) + }) + ); + const text = await response.text(); + const payload = response.headers.get('content-type')?.includes('text/event-stream') + ? text + .split('\n') + .filter(line => line.startsWith('data:')) + .map(line => line.slice(5).trim()) + .at(-1) + : text; + return JSON.parse(payload ?? '{}') as { result?: Record; error?: { code: number; message: string } }; + }; + const startTask = async (name: string) => { + const outcome = await clientTasks.callTool({ name: 'greet', arguments: { name } }); + if (outcome.kind !== 'task') throw new Error('expected a task handle'); + return outcome.task; + }; + const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), { + fetch: (url, init) => mcpHandler.fetch(new Request(url, init)) + }); + const clientTasks = new TasksClientExtension(); + const client = new Client( + { name: 'harness', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' }, extensions: declare ? [clientTasks] : [] } + ); + return { store, client, transport, clientTasks, startTask, callTool }; +} + +async function pollUntil(tasks: TasksClientExtension, taskId: string, predicate: (task: DetailedTask) => boolean) { + for (let i = 0; i < 200; i++) { + const task = await tasks.get(taskId); + if (predicate(task)) return task; + await new Promise(resolve => setTimeout(resolve, 5)); + } + throw new Error(`task ${taskId} never reached the expected state`); +} + +describe('TasksExtension end to end (stateless handler, in-memory store)', () => { + let cleanup: (() => void) | undefined; + afterEach(() => cleanup?.()); + + it('advertises the extension, answers a task handle, and completes with status', async () => { + const h = createHarness(async (handle, { name }) => { + await handle.status(`greeting ${name}`); + await new Promise(resolve => setTimeout(resolve, 10)); + await handle.complete({ content: [{ type: 'text', text: `hello ${name}` }] }); + }); + cleanup = () => h.store.close(); + await h.client.connect(h.transport); + expect(h.client.getServerCapabilities()?.extensions).toEqual({ [TASKS_EXTENSION_ID]: {} }); + + const created = await h.startTask('ada'); + expect(created).toMatchObject({ resultType: 'task', status: 'working', ttlMs: 86_400_000, pollIntervalMs: 10 }); + + const seen: string[] = []; + const done = await h.clientTasks.waitFor(created.taskId, { onUpdate: task => seen.push(task.status) }); + expect(done.status === 'completed' && done.result).toEqual({ content: [{ type: 'text', text: 'hello ada' }] }); + expect(done.statusMessage).toBe('greeting ada'); + expect(seen.at(-1)).toBe('completed'); + }); + + it('surfaces input_required, resumes on tasks/update, and inlines the answer', async () => { + const h = createHarness(async (handle, { name }) => { + const answers = await handle.requireInput({ + confirm: { + method: 'elicitation/create', + params: { message: `greet ${name}?`, mode: 'form', requestedSchema: { type: 'object', properties: {} } } + } + }); + await handle.complete({ content: [{ type: 'text', text: JSON.stringify(answers) }] }); + }); + cleanup = () => h.store.close(); + await h.client.connect(h.transport); + const created = await h.startTask('bob'); + const waiting = await pollUntil(h.clientTasks, created.taskId, task => task.status === 'input_required'); + expect(waiting.status === 'input_required' && Object.keys(waiting.inputRequests)).toEqual(['confirm']); + + await h.clientTasks.update(created.taskId, { confirm: { action: 'accept', content: { ok: true } } }); + const done = await pollUntil(h.clientTasks, created.taskId, task => task.status === 'completed'); + expect(done.status === 'completed' && done.result).toEqual({ + content: [{ type: 'text', text: JSON.stringify({ confirm: { action: 'accept', content: { ok: true } } }) }] + }); + }); + + it('fails a task with the reported error', async () => { + const h = createHarness(async handle => { + await handle.fail({ code: -32_000, message: 'upstream down' }); + }); + cleanup = () => h.store.close(); + await h.client.connect(h.transport); + const created = await h.startTask('x'); + const done = await pollUntil(h.clientTasks, created.taskId, task => task.status === 'failed'); + expect(done.status === 'failed' && done.error).toEqual({ code: -32_000, message: 'upstream down' }); + }); + + it('cancels cooperatively: the handle signal aborts and later writes are ignored', async () => { + let aborted = false; + const h = createHarness(async handle => { + await new Promise(resolve => handle.signal.addEventListener('abort', () => resolve(), { once: true })); + aborted = true; + await handle.complete({ content: [] }); + }); + cleanup = () => h.store.close(); + await h.client.connect(h.transport); + const created = await h.startTask('x'); + await h.clientTasks.cancel(created.taskId); + const done = await pollUntil(h.clientTasks, created.taskId, task => task.status === 'cancelled'); + expect(done.status).toBe('cancelled'); + await new Promise(resolve => setTimeout(resolve, 5)); + expect(aborted).toBe(true); + expect((await h.clientTasks.get(created.taskId)).status).toBe('cancelled'); + await expect(h.clientTasks.cancel(created.taskId)).resolves.toBeUndefined(); // idempotent + }); + + it('answers -32602 for an unknown task and -32021 without the extension capability, as JSON-RPC errors', async () => { + const h = createHarness(async () => {}); + cleanup = () => h.store.close(); + await h.client.connect(h.transport); + await expect(h.clientTasks.get('nope')).rejects.toMatchObject({ code: -32_602 }); + + const plain = createHarness(async () => {}, { declareExtension: false, plainTool: true }); + await plain.client.connect(plain.transport); + await expect(plain.client.request({ method: 'tasks/get', params: { taskId: 'nope' } }, z.looseObject({}))).rejects.toMatchObject({ + code: -32_021 + }); + const refused = await plain.callTool('greet', { name: 'x' }); + expect(refused.error).toMatchObject({ code: -32_021 }); + const sneaky = await plain.callTool('sneaky', {}); + expect(sneaky.error).toMatchObject({ code: -32_021 }); + plain.store.close(); + }); +}); diff --git a/packages/server/test/ext/tasks/withSkills.e2e.test.ts b/packages/server/test/ext/tasks/withSkills.e2e.test.ts new file mode 100644 index 0000000000..37e48d9849 --- /dev/null +++ b/packages/server/test/ext/tasks/withSkills.e2e.test.ts @@ -0,0 +1,66 @@ +/** + * Two extensions on one server, end to end: the Skills and Tasks extensions, + * passed together in `extensions` and served by the stateless + * `createMcpHandler` to a real `Client`. + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { SkillsClient } from '@modelcontextprotocol/client/ext/skills'; +import { TasksClientExtension } from '@modelcontextprotocol/client/ext/tasks'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { memorySkillFiles, SkillsExtension } from '../../../src/ext/skills/index'; +import { InMemoryTaskStore, TASKS_EXTENSION_ID, TasksExtension } from '../../../src/ext/tasks/index'; +import { createMcpHandler, McpServer } from '../../../src/index'; + +const SKILL_MD = '---\nname: release-notes\ndescription: Write release notes.\n---\n# Release notes\n'; + +describe('Skills and Tasks extensions on one server', () => { + let store: InMemoryTaskStore | undefined; + afterEach(() => store?.close()); + + it('serves skills and answers a tool with a task, through one client', async () => { + store = new InMemoryTaskStore(); + const taskStore = store; + const tasks = new TasksExtension(taskStore, { defaultPollIntervalMs: 10 }); + const skills = await SkillsExtension.load(memorySkillFiles({ 'release-notes/SKILL.md': SKILL_MD })); + + const mcpHandler = createMcpHandler(() => { + const server = new McpServer({ name: 'skills-and-tasks', version: '1.0.0' }, { extensions: [skills, tasks] }); + server.registerTool('draft_notes', { inputSchema: z.object({ version: z.string() }) }, async ({ version }, ctx) => { + const task = await tasks.create(ctx); + void taskStore.handle(task.taskId).complete({ content: [{ type: 'text', text: `notes for ${version}` }] }); + return task; + }); + return server; + }); + + const clientTasks = new TasksClientExtension(); + const client = new Client({ name: 'host', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' }, extensions: [clientTasks] }); + await client.connect( + new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), { + fetch: (url, init) => mcpHandler.fetch(new Request(url, init)) + }) + ); + + expect(client.getServerCapabilities()?.extensions).toEqual({ + 'io.modelcontextprotocol/skills': { directoryRead: true }, + [TASKS_EXTENSION_ID]: {} + }); + + const skillsClient = new SkillsClient(client); + const { skills: entries } = await skillsClient.list(); + expect(entries.map(entry => entry.uri)).toEqual(['skill://release-notes/SKILL.md']); + const [entry] = entries; + if (entry === undefined) throw new Error('expected one skill'); + const manifest = await skillsClient.read(entry); + expect(manifest).toMatchObject({ verified: true, text: SKILL_MD }); + + const outcome = await clientTasks.callTool({ name: 'draft_notes', arguments: { version: '2.4.0' } }); + if (outcome.kind !== 'task') throw new Error('expected a task handle'); + const done = await clientTasks.waitFor(outcome.task.taskId); + expect(done.status === 'completed' && done.result).toEqual({ content: [{ type: 'text', text: 'notes for 2.4.0' }] }); + + await client.close(); + }); +}); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 54f3389a5f..494526ddc8 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -21,7 +21,12 @@ "./node_modules/@modelcontextprotocol/core-internal/src/validators/cfWorkerProvider.ts" ], "@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"], - "@modelcontextprotocol/server/_shims": ["./src/shimsNode.ts"] + "@modelcontextprotocol/server/_shims": ["./src/shimsNode.ts"], + "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], + "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], + "@modelcontextprotocol/core-internal/ext/tasks": ["./node_modules/@modelcontextprotocol/core-internal/src/ext/tasks/index.ts"], + "@modelcontextprotocol/client/ext/tasks": ["./node_modules/@modelcontextprotocol/client/src/ext/tasks/index.ts"], + "@modelcontextprotocol/client/ext/skills": ["./node_modules/@modelcontextprotocol/client/src/ext/skills/index.ts"] } } } diff --git a/packages/server/tsdown.config.ts b/packages/server/tsdown.config.ts index 06137a4560..efacd7d4bd 100644 --- a/packages/server/tsdown.config.ts +++ b/packages/server/tsdown.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ entry: [ 'src/index.ts', 'src/stdio.ts', + 'src/ext/tasks/index.ts', 'src/shimsNode.ts', 'src/shimsWorkerd.ts', 'src/shimsBrowser.ts', @@ -32,6 +33,7 @@ export default defineConfig({ '@modelcontextprotocol/core-internal': ['../core-internal/src/index.ts'], '@modelcontextprotocol/core-internal/public': ['../core-internal/src/exports/public/index.ts'], '@modelcontextprotocol/core-internal/ext/skills': ['../core-internal/src/ext/skills/index.ts'], + '@modelcontextprotocol/core-internal/ext/tasks': ['../core-internal/src/ext/tasks/index.ts'], '@modelcontextprotocol/core-internal/validators/ajv': ['../core-internal/src/validators/ajvProvider.ts'], '@modelcontextprotocol/core-internal/validators/cfWorker': ['../core-internal/src/validators/cfWorkerProvider.ts'] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 060f3c4b42..fdf96a650e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -594,6 +594,31 @@ importers: specifier: catalog:devTools version: 4.21.0 + examples/ext-tasks: + dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/hono': + specifier: workspace:* + version: link:../../packages/middleware/hono + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + examples/extension-capabilities: dependencies: '@hono/node-server': @@ -1822,6 +1847,9 @@ importers: '@eslint/js': specifier: catalog:devTools version: 9.39.4 + '@modelcontextprotocol/client': + specifier: workspace:^ + version: link:../client '@modelcontextprotocol/core-internal': specifier: workspace:^ version: link:../core-internal From e10e9299e7f0531deccb5baf7df47fc8575a8550 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 12:33:45 +0000 Subject: [PATCH 02/12] fix(server): keep in-memory tasks whose TTL exceeds the setTimeout limit InMemoryTaskStore passed ttlMs straight to setTimeout, which fires almost at once for any delay above 2^31-1 ms (about 24.8 days), so such a task was purged right after creation and its signal aborted. Wait the TTL out in steps of at most that delay. --- .../server/src/ext/tasks/inMemoryStore.ts | 17 ++++++++++++---- .../test/ext/tasks/inMemoryStore.test.ts | 20 ++++++++++++++++++- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/server/src/ext/tasks/inMemoryStore.ts b/packages/server/src/ext/tasks/inMemoryStore.ts index 23d04d4c70..76635c6b4f 100644 --- a/packages/server/src/ext/tasks/inMemoryStore.ts +++ b/packages/server/src/ext/tasks/inMemoryStore.ts @@ -36,6 +36,8 @@ interface TaskRecord { const TERMINAL: ReadonlySet = new Set(['completed', 'failed', 'cancelled']); +const MAX_TIMEOUT_MS = 2_147_483_647; + /** * The writer side of a task, for the code that does the work. Obtained from * {@link InMemoryTaskStore.handle}; a persistent store exposes its own @@ -103,10 +105,7 @@ export class InMemoryTaskStore implements TaskStore { ...(params.pollIntervalMs !== undefined && { pollIntervalMs: params.pollIntervalMs }), abort: new AbortController() }; - if (record.ttlMs !== null) { - record.ttlTimer = setTimeout(() => this.#purge(record), record.ttlMs); - record.ttlTimer.unref?.(); - } + if (record.ttlMs !== null) this.#schedulePurge(record, record.ttlMs); this.#tasks.set(record.taskId, record); return this.#snapshot(record); } @@ -209,6 +208,16 @@ export class InMemoryTaskStore implements TaskStore { record.lastUpdatedAt = this.#now(); } + // setTimeout fires at once for a delay above MAX_TIMEOUT_MS, so a longer TTL is waited out in steps. + #schedulePurge(record: TaskRecord, remainingMs: number): void { + const delay = Math.min(remainingMs, MAX_TIMEOUT_MS); + record.ttlTimer = setTimeout( + () => (remainingMs > delay ? this.#schedulePurge(record, remainingMs - delay) : this.#purge(record)), + delay + ); + record.ttlTimer.unref?.(); + } + #purge(record: TaskRecord): void { record.abort.abort(); this.#tasks.delete(record.taskId); diff --git a/packages/server/test/ext/tasks/inMemoryStore.test.ts b/packages/server/test/ext/tasks/inMemoryStore.test.ts index 59473ada47..9fe69ce0b8 100644 --- a/packages/server/test/ext/tasks/inMemoryStore.test.ts +++ b/packages/server/test/ext/tasks/inMemoryStore.test.ts @@ -4,7 +4,7 @@ * cancel aborts a pending input wait, terminal writes are ignored, TTL purge, * principal fail-closed. */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { InMemoryTaskStore } from '../../../src/ext/tasks/index'; @@ -73,6 +73,24 @@ describe('InMemoryTaskStore', () => { store.close(); }); + it('keeps a task whose TTL exceeds the largest setTimeout delay until its deadline', async () => { + vi.useFakeTimers(); + try { + const store = new InMemoryTaskStore(); + const ttlMs = 30 * 24 * 60 * 60 * 1000; + const { taskId } = await store.create({ ttlMs }); + await vi.advanceTimersByTimeAsync(1000); + expect(await store.get(taskId)).toBeDefined(); + await vi.advanceTimersByTimeAsync(ttlMs - 1001); + expect(await store.get(taskId)).toBeDefined(); + await vi.advanceTimersByTimeAsync(1); + expect(await store.get(taskId)).toBeUndefined(); + store.close(); + } finally { + vi.useRealTimers(); + } + }); + it('rejects invalid ttlMs and pollIntervalMs', async () => { const store = new InMemoryTaskStore(); await expect(store.create({ ttlMs: -1 })).rejects.toBeInstanceOf(RangeError); From 4a59d5e07e920e02ead63dcde5d75d7fcf349a6c Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 12:34:08 +0000 Subject: [PATCH 03/12] fix(server): settle every requireInput wait in the in-memory task store handle.complete and handle.fail left an outstanding requireInput promise pending until the TTL purge, or forever with ttlMs: null, and a second requireInput replaced the first wait so the first never settled. Reject the pending wait when the task settles, refuse a second concurrent wait, and drop the abort listener once a wait resolves. --- .../server/src/ext/tasks/inMemoryStore.ts | 34 ++++++++++++++++--- .../test/ext/tasks/inMemoryStore.test.ts | 17 ++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/server/src/ext/tasks/inMemoryStore.ts b/packages/server/src/ext/tasks/inMemoryStore.ts index 76635c6b4f..66f6fe3681 100644 --- a/packages/server/src/ext/tasks/inMemoryStore.ts +++ b/packages/server/src/ext/tasks/inMemoryStore.ts @@ -15,6 +15,7 @@ interface PendingInput { requests: InputRequests; responses: InputResponses; resolve: (responses: InputResponses) => void; + reject: (error: Error) => void; } interface TaskRecord { @@ -52,7 +53,8 @@ export interface TaskHandle { /** * Moves the task to `input_required` with `requests` outstanding and * resolves once `tasks/update` has answered every key (partial updates - * accumulate). Rejects if the task is cancelled while waiting. + * accumulate). Rejects if the task is cancelled, purged or settled while + * waiting, and when another `requireInput` is already outstanding. */ requireInput(requests: InputRequests): Promise; complete(result: CallToolResult): Promise; @@ -169,13 +171,27 @@ export class InMemoryTaskStore implements TaskStore { requireInput: requests => { const current = live(); if (current === undefined) return Promise.reject(new Error(`Task "${taskId}" is no longer running`)); + if (current.pendingInput !== undefined) { + return Promise.reject(new Error(`Task "${taskId}" is already waiting for input`)); + } return new Promise((resolve, reject) => { - current.pendingInput = { requests, responses: {}, resolve }; + const signal = current.abort.signal; + const onAbort = () => reject(new Error(`Task "${taskId}" was cancelled`)); + signal.addEventListener('abort', onAbort, { once: true }); + current.pendingInput = { + requests, + responses: {}, + resolve: responses => { + signal.removeEventListener('abort', onAbort); + resolve(responses); + }, + reject: error => { + signal.removeEventListener('abort', onAbort); + reject(error); + } + }; current.status = 'input_required'; this.#touch(current); - current.abort.signal.addEventListener('abort', () => reject(new Error(`Task "${taskId}" was cancelled`)), { - once: true - }); }); }, complete: async result => { @@ -183,6 +199,7 @@ export class InMemoryTaskStore implements TaskStore { if (current === undefined) return; current.status = 'completed'; current.result = result; + this.#settlePendingInput(current); this.#touch(current); }, fail: async error => { @@ -190,6 +207,7 @@ export class InMemoryTaskStore implements TaskStore { if (current === undefined) return; current.status = 'failed'; current.error = error; + this.#settlePendingInput(current); this.#touch(current); } }; @@ -204,6 +222,12 @@ export class InMemoryTaskStore implements TaskStore { return record; } + #settlePendingInput(record: TaskRecord): void { + const pending = record.pendingInput; + record.pendingInput = undefined; + pending?.reject(new Error(`Task "${record.taskId}" settled while waiting for input`)); + } + #touch(record: TaskRecord): void { record.lastUpdatedAt = this.#now(); } diff --git a/packages/server/test/ext/tasks/inMemoryStore.test.ts b/packages/server/test/ext/tasks/inMemoryStore.test.ts index 9fe69ce0b8..c8ad8d143d 100644 --- a/packages/server/test/ext/tasks/inMemoryStore.test.ts +++ b/packages/server/test/ext/tasks/inMemoryStore.test.ts @@ -60,6 +60,23 @@ describe('InMemoryTaskStore', () => { store.close(); }); + it('settles a pending input wait when the task completes or fails, and refuses a second wait', async () => { + const store = new InMemoryTaskStore(); + const ask = { + a: { method: 'elicitation/create', params: { message: 'q', mode: 'form', requestedSchema: { type: 'object', properties: {} } } } + } as const; + for (const settle of ['complete', 'fail'] as const) { + const { taskId } = await store.create({ ttlMs: null }); + const handle = store.handle(taskId); + const waiting = handle.requireInput(ask); + await expect(handle.requireInput(ask)).rejects.toThrow('already waiting for input'); + await (settle === 'complete' ? handle.complete({ content: [] }) : handle.fail({ code: -32_603, message: 'boom' })); + await expect(waiting).rejects.toThrow('settled while waiting for input'); + expect(handle.signal.aborted).toBe(false); + } + store.close(); + }); + it('purges at the TTL deadline and fails closed on a foreign principal', async () => { const store = new InMemoryTaskStore(); const { taskId } = await store.create({ ttlMs: 10, principal: 'alice' }); From e668104a950998e2bbcdb5e4bc49679a10fe46b0 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 12:35:08 +0000 Subject: [PATCH 04/12] docs(tasks): correct the Tasks guides, changeset and comments - TasksExtension goes in McpServerOptions.extensions; ServerOptions has no extensions option. - The requester half ships in this PR as TasksClientExtension from @modelcontextprotocol/client/ext/tasks, not @modelcontextprotocol/ext-tasks. - The server example fired the work with void and no catch; requireInput rejects on cancel or purge, so that crashed Node with an unhandled rejection. Settle failures on the task instead, here and in the TSDoc. - The schema header pointed at a vendored test fixture that does not exist. --- .changeset/tasks-extension.md | 2 +- docs/servers/tasks.md | 11 +++++++---- packages/core-internal/src/ext/tasks/schemas.ts | 11 +++++------ packages/server/src/ext/tasks/tasksExtension.ts | 3 ++- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.changeset/tasks-extension.md b/.changeset/tasks-extension.md index b6b80b45ff..c47be564a9 100644 --- a/.changeset/tasks-extension.md +++ b/.changeset/tasks-extension.md @@ -6,7 +6,7 @@ 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/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`), 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. diff --git a/docs/servers/tasks.md b/docs/servers/tasks.md index 1d6eea2954..bb2c17c826 100644 --- a/docs/servers/tasks.md +++ b/docs/servers/tasks.md @@ -27,11 +27,14 @@ The server advertises `io.modelcontextprotocol/tasks` under `capabilities.extens 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 }); - void sendReport(store.handle(task.taskId), to); + 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; }); @@ -47,7 +50,7 @@ async function sendReport(handle: TaskHandle, to: string) { } ``` -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. +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. ## Bring your own store @@ -67,12 +70,12 @@ How the work behind a task runs — a queue, a workflow engine, a durable-execut - 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`. +- 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)). - `notifications/tasks` over `subscriptions/listen` is not implemented (typescript-sdk#2569); polling only. ## Recap -- `new TasksExtension(store)` in `ServerOptions.extensions` serves the extension. +- `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. diff --git a/packages/core-internal/src/ext/tasks/schemas.ts b/packages/core-internal/src/ext/tasks/schemas.ts index 2542a5fb47..c54c570efb 100644 --- a/packages/core-internal/src/ext/tasks/schemas.ts +++ b/packages/core-internal/src/ext/tasks/schemas.ts @@ -1,19 +1,18 @@ /* * Hand-written zod v4 schemas for the MCP Tasks extension wire types * (./types). Upstream commits only generated JSON Schema - * (`schema/draft/schema.json`, vendored at `test/fixtures/ext-tasks.schema.json` - * as the conformance fixture); these runtime schemas are authored against + * (`schema/draft/schema.json`); these runtime schemas are authored against * modelcontextprotocol/ext-tasks pinned at commit dcc8d2b (SEP-2663 Final). * https://github.com/modelcontextprotocol/ext-tasks * - * Deliberate deviations from the generated fixture, which reflects the + * Deliberate deviations from the generated JSON Schema, which reflects the * pre-envelope TS source rather than the wire: - * - Objects are loose (unknown keys pass through) where the fixture says + * - Objects are loose (unknown keys pass through) where the JSON Schema says * `additionalProperties: false`: modern responses carry `resultType` and * `_meta`, and modern request params carry the `_meta` envelope. * - `resultType` literals are REQUIRED on result schemas (spec MUST; the - * fixture omits the field entirely). - * - `InputRequest`/`InputResponse` get minimal structural checks (the fixture + * JSON Schema omits the field entirely). + * - `InputRequest`/`InputResponse` get minimal structural checks (the JSON Schema * degenerates them to `anyOf [{}, {}, {}]`). * * Copyright (c) Model Context Protocol contributors diff --git a/packages/server/src/ext/tasks/tasksExtension.ts b/packages/server/src/ext/tasks/tasksExtension.ts index dd197864fd..cd53ab8663 100644 --- a/packages/server/src/ext/tasks/tasksExtension.ts +++ b/packages/server/src/ext/tasks/tasksExtension.ts @@ -14,7 +14,8 @@ * * server.registerTool('send_report', { inputSchema }, async (input, ctx) => { * const task = await tasks.create(ctx, { ttlMs: 3_600_000 }); - * void runReport(store.handle(task.taskId), input); // the server's own execution + * const handle = store.handle(task.taskId); + * void runReport(handle, input).catch(error => handle.fail({ code: -32_603, message: String(error) })); * return task; * }); * ``` From d3db0c3e71186ad4077ce903e5564bb1889ecc1f Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 12:36:02 +0000 Subject: [PATCH 05/12] fix(client): await onUpdate in TasksClientExtension.waitFor The client guide answers input_required inside an async onUpdate, but waitFor called it without awaiting. Polling went on at the task's interval while the user was being asked, so the same unanswered input request came back and was asked again on every poll, and a rejection from the callback was unhandled. Await the callback before the next poll and let its rejection reject waitFor. --- .../src/ext/tasks/tasksClientExtension.ts | 11 ++++--- .../ext/tasks/tasksClientExtension.test.ts | 32 +++++++++++++++++++ .../server/test/ext/tasks/tasks.e2e.test.ts | 2 +- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/client/src/ext/tasks/tasksClientExtension.ts b/packages/client/src/ext/tasks/tasksClientExtension.ts index 79896f327f..41856f625d 100644 --- a/packages/client/src/ext/tasks/tasksClientExtension.ts +++ b/packages/client/src/ext/tasks/tasksClientExtension.ts @@ -41,8 +41,8 @@ export interface WaitForOptions { pollIntervalMs?: number; /** Fallback when the task carries no `pollIntervalMs`, ms. Default 1_000. */ defaultPollIntervalMs?: number; - /** Called with every snapshot, terminal one included. */ - onUpdate?: (task: DetailedTask) => void; + /** Called with every snapshot, terminal one included; polling waits for a returned promise. */ + onUpdate?: (task: DetailedTask) => void | Promise; } /** A task in a terminal status. */ @@ -111,14 +111,15 @@ export class TasksClientExtension implements ClientExtension { /** * Polls `tasks/get` at the task's suggested interval until the task is - * terminal. `input_required` snapshots are reported through `onUpdate` - * and polling continues; answer them with {@link update}. + * terminal. `input_required` snapshots are reported through `onUpdate`, + * and polling resumes once it settles; answer them with {@link update} + * there. A rejection from `onUpdate` rejects `waitFor`. */ async waitFor(taskId: string, options?: WaitForOptions): Promise { for (;;) { options?.signal?.throwIfAborted(); const task = await this.get(taskId, { signal: options?.signal }); - options?.onUpdate?.(task); + await options?.onUpdate?.(task); if (TERMINAL.has(task.status)) return task as TerminalTask; const delay = options?.pollIntervalMs ?? task.pollIntervalMs ?? options?.defaultPollIntervalMs ?? 1000; await sleep(delay, options?.signal); diff --git a/packages/client/test/ext/tasks/tasksClientExtension.test.ts b/packages/client/test/ext/tasks/tasksClientExtension.test.ts index 750144bb8c..0fea77402b 100644 --- a/packages/client/test/ext/tasks/tasksClientExtension.test.ts +++ b/packages/client/test/ext/tasks/tasksClientExtension.test.ts @@ -133,6 +133,38 @@ describe('TasksClientExtension', () => { await client.close(); }); + it('waitFor awaits onUpdate before polling again, so an input request is answered once', async () => { + const ask = { + q: { method: 'elicitation/create', params: { message: '?', mode: 'form', requestedSchema: { type: 'object', properties: {} } } } + }; + const { client, tasks, written } = await scriptedServer([ + { status: 'input_required', inputRequests: ask }, + { status: 'completed', result: { content: [] } } + ]); + let prompts = 0; + await tasks.waitFor('t-1', { + onUpdate: async task => { + if (task.status !== 'input_required') return; + prompts += 1; + await new Promise(resolve => setTimeout(resolve, 30)); + await tasks.update('t-1', { q: { action: 'accept' } }); + } + }); + expect(prompts).toBe(1); + expect(methodsWritten(written).filter(method => method === 'tasks/get')).toHaveLength(2); + + const failing = await scriptedServer([{ status: 'working' }]); + await expect( + failing.tasks.waitFor('t-1', { + onUpdate: async () => { + throw new Error('ui closed'); + } + }) + ).rejects.toThrow('ui closed'); + await failing.client.close(); + await client.close(); + }); + it('waitFor stops on abort without touching the task; cancel is an explicit call', async () => { const { client, tasks, written } = await scriptedServer([{ status: 'working' }]); const controller = new AbortController(); diff --git a/packages/server/test/ext/tasks/tasks.e2e.test.ts b/packages/server/test/ext/tasks/tasks.e2e.test.ts index 7e19463ecf..319381cf22 100644 --- a/packages/server/test/ext/tasks/tasks.e2e.test.ts +++ b/packages/server/test/ext/tasks/tasks.e2e.test.ts @@ -117,7 +117,7 @@ describe('TasksExtension end to end (stateless handler, in-memory store)', () => expect(created).toMatchObject({ resultType: 'task', status: 'working', ttlMs: 86_400_000, pollIntervalMs: 10 }); const seen: string[] = []; - const done = await h.clientTasks.waitFor(created.taskId, { onUpdate: task => seen.push(task.status) }); + const done = await h.clientTasks.waitFor(created.taskId, { onUpdate: task => void seen.push(task.status) }); expect(done.status === 'completed' && done.result).toEqual({ content: [{ type: 'text', text: 'hello ada' }] }); expect(done.statusMessage).toBe('greeting ada'); expect(seen.at(-1)).toBe('completed'); From c1f47514770b59e972d673d1c0bf0c8337e88a4c Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 13:00:21 +0000 Subject: [PATCH 06/12] docs(tasks): name only published entry points in the changeset @modelcontextprotocol/core-internal is private, so users cannot import the wire types from it; they come from the server and client ext/tasks subpaths. --- .changeset/tasks-extension.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tasks-extension.md b/.changeset/tasks-extension.md index c47be564a9..0c33267c27 100644 --- a/.changeset/tasks-extension.md +++ b/.changeset/tasks-extension.md @@ -10,4 +10,4 @@ The MCP Tasks extension (`io.modelcontextprotocol/tasks`) as a pair of extension `@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. +Both subpaths also export the wire types and zod schemas for the extension's 2026-07-28 schema. From b493638f8260c4b6d5b7d0f5f6d7cbf2662ce340 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 13:43:58 +0000 Subject: [PATCH 07/12] fix(server): bind tasks to a user-level principal; serve Tasks on 2026-07-28 only The task principal was the OAuth client id, so every user of a shared or public client could read, answer and cancel the others' tasks. The default is now the client id plus the token subject at authInfo.extra.sub when the verifier sets one, and TasksExtensionOptions.principal picks any identity. On 2025-11-25 the capability was advertised, then every tasks call failed with -32021 asking for an envelope that era cannot carry, and the core legacy tasks/get and tasks/cancel got the extension's handlers. The extension's methods now answer -32601 there, and tasks.create refuses with a -32021 that names the era. --- .changeset/tasks-extension.md | 2 +- docs/servers/tasks.md | 14 ++- packages/server/src/ext/tasks/store.ts | 5 +- .../server/src/ext/tasks/tasksExtension.ts | 44 ++++++- .../test/ext/tasks/tasksExtension.test.ts | 109 ++++++++++++++++++ 5 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 packages/server/test/ext/tasks/tasksExtension.test.ts diff --git a/.changeset/tasks-extension.md b/.changeset/tasks-extension.md index 0c33267c27..2f327e81d9 100644 --- a/.changeset/tasks-extension.md +++ b/.changeset/tasks-extension.md @@ -6,7 +6,7 @@ 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`), 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/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. diff --git a/docs/servers/tasks.md b/docs/servers/tasks.md index bb2c17c826..ff5aaa2a09 100644 --- a/docs/servers/tasks.md +++ b/docs/servers/tasks.md @@ -52,6 +52,18 @@ async function sendReport(handle: TaskHandle, to: string) { 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: @@ -67,7 +79,7 @@ How the work behind a task runs — a queue, a workflow engine, a durable-execut ## 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. +- 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)). diff --git a/packages/server/src/ext/tasks/store.ts b/packages/server/src/ext/tasks/store.ts index dc655f13c9..1b988a471f 100644 --- a/packages/server/src/ext/tasks/store.ts +++ b/packages/server/src/ext/tasks/store.ts @@ -16,8 +16,9 @@ export interface CreateTaskParams { /** Suggested client polling interval, ms; omitted = no hint on the task. */ pollIntervalMs?: number; /** - * Auth binding: the principal the creating request was authenticated as, - * when the transport knows one. Stores MUST refuse `tasks/*` access from + * Auth binding: the principal of the creating request, from + * `TasksExtensionOptions.principal` (by default the OAuth client id plus + * the token subject), when there is one. Stores MUST refuse `tasks/*` access from * a different principal (fail closed, no existence leak) and MAY ignore * it when absent. */ diff --git a/packages/server/src/ext/tasks/tasksExtension.ts b/packages/server/src/ext/tasks/tasksExtension.ts index cd53ab8663..2de365e613 100644 --- a/packages/server/src/ext/tasks/tasksExtension.ts +++ b/packages/server/src/ext/tasks/tasksExtension.ts @@ -45,6 +45,17 @@ export interface TasksExtensionOptions { defaultTtlMs?: number | null; /** Default polling hint for `create` when the caller gives none, ms. Default 5_000. */ defaultPollIntervalMs?: number; + /** + * The identity a task is bound to, read from the request that creates it + * and checked on every `tasks/*` request: a request with a different + * principal gets "Task not found". `undefined` leaves the task unbound, so + * its id is the only credential. The default is the OAuth client id plus + * the token subject at `authInfo.extra.sub` when the token verifier sets + * one; with a client shared between users and no subject there, set this + * to the user's identity, or every user of that client can reach the + * others' tasks. + */ + principal?: (ctx: ServerContext) => string | undefined; } /** What a tool handler passes to {@link TasksExtension.create}; every field optional. */ @@ -70,13 +81,31 @@ export const declaresTasksExtension = (ctx: ServerContext): boolean => { const requireTasksExtension = (ctx: ServerContext, what: string): void => { if (declaresTasksExtension(ctx)) return; + if (ctx.mcpReq.envelope === undefined) { + // Every 2026-07-28 request carries the envelope, so this is a 2025-11-25 request. + throw new MissingRequiredClientCapabilityError( + { requiredCapabilities: { extensions: { [TASKS_EXTENSION_ID]: {} } } }, + `${what} requires a 2026-07-28 request that declares the "${TASKS_EXTENSION_ID}" extension; it is not served on 2025-11-25` + ); + } throw new MissingRequiredClientCapabilityError( { requiredCapabilities: { extensions: { [TASKS_EXTENSION_ID]: {} } } }, `${what} requires the request to declare the "${TASKS_EXTENSION_ID}" extension capability` ); }; -const principalOf = (ctx: ServerContext): string | undefined => ctx.http?.authInfo?.clientId; +/** The default principal: the OAuth client id, with the token subject when the verifier exposes one. */ +const defaultTaskPrincipal = (ctx: ServerContext): string | undefined => { + const authInfo = ctx.http?.authInfo; + if (authInfo === undefined) return undefined; + const subject = authInfo.extra?.['sub']; + return typeof subject === 'string' ? JSON.stringify([authInfo.clientId, subject]) : authInfo.clientId; +}; + +// The extension's methods are not served on 2025-11-25, where tasks/get and tasks/cancel are other, core methods. +const requireModernRequest = (ctx: ServerContext): void => { + if (ctx.mcpReq.envelope === undefined) throw new ProtocolError(ProtocolErrorCode.MethodNotFound, 'Method not found'); +}; const notFound = (): never => { throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Task not found'); @@ -97,9 +126,11 @@ export class TasksExtension implements ServerExtension { readonly store: TaskStore; readonly #defaultTtlMs: number | null; readonly #defaultPollIntervalMs: number; + readonly #principal: (ctx: ServerContext) => string | undefined; constructor(store: TaskStore, options?: TasksExtensionOptions) { this.store = store; + this.#principal = options?.principal ?? defaultTaskPrincipal; this.#defaultTtlMs = options?.defaultTtlMs === undefined ? 86_400_000 : options.defaultTtlMs; this.#defaultPollIntervalMs = options?.defaultPollIntervalMs ?? 5000; } @@ -112,7 +143,7 @@ export class TasksExtension implements ServerExtension { */ async create(ctx: ServerContext, options?: CreateTaskOptions): Promise { requireTasksExtension(ctx, `Tool "${ctx.mcpReq.method}" executes as a task and`); - const principal = principalOf(ctx); + const principal = this.#principal(ctx); const task = await this.store.create({ ttlMs: options?.ttlMs === undefined ? this.#defaultTtlMs : options.ttlMs, pollIntervalMs: options?.pollIntervalMs ?? this.#defaultPollIntervalMs, @@ -133,8 +164,9 @@ export class TasksExtension implements ServerExtension { */ setup(ctx: ServerExtensionContext): void { ctx.setRequestHandler('tasks/get', { params: getTaskParamsSchema }, async (params, requestCtx): Promise => { + requireModernRequest(requestCtx); requireTasksExtension(requestCtx, 'tasks/get'); - const task = await this.store.get(params.taskId, { principal: principalOf(requestCtx) }); + const task = await this.store.get(params.taskId, { principal: this.#principal(requestCtx) }); if (task === undefined) return notFound(); return { resultType: 'complete', ...task }; }); @@ -143,17 +175,19 @@ export class TasksExtension implements ServerExtension { 'tasks/update', { params: updateTaskHandlerParamsSchema }, async (params, requestCtx): Promise => { + requireModernRequest(requestCtx); requireTasksExtension(requestCtx, 'tasks/update'); const inputResponses = (requestCtx.mcpReq.inputResponses ?? params.inputResponses ?? {}) as InputResponses; - const found = await this.store.update(params.taskId, inputResponses, { principal: principalOf(requestCtx) }); + const found = await this.store.update(params.taskId, inputResponses, { principal: this.#principal(requestCtx) }); if (!found) return notFound(); return { resultType: 'complete' }; } ); ctx.setRequestHandler('tasks/cancel', { params: cancelTaskParamsSchema }, async (params, requestCtx): Promise => { + requireModernRequest(requestCtx); requireTasksExtension(requestCtx, 'tasks/cancel'); - const found = await this.store.cancel(params.taskId, { principal: principalOf(requestCtx) }); + const found = await this.store.cancel(params.taskId, { principal: this.#principal(requestCtx) }); if (!found) return notFound(); return { resultType: 'complete' }; }); diff --git a/packages/server/test/ext/tasks/tasksExtension.test.ts b/packages/server/test/ext/tasks/tasksExtension.test.ts new file mode 100644 index 0000000000..35d4708b90 --- /dev/null +++ b/packages/server/test/ext/tasks/tasksExtension.test.ts @@ -0,0 +1,109 @@ +/** + * `TasksExtension` binding and era rules: which principal a task is bound to, + * and that the extension's methods and task handles are 2026-07-28 only. + */ +import { Client } from '@modelcontextprotocol/client'; +import type { AuthInfo, JSONRPCRequest, MessageClassification } from '@modelcontextprotocol/core-internal'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + InMemoryTransport, + PROTOCOL_VERSION_META_KEY, + setNegotiatedProtocolVersion +} from '@modelcontextprotocol/core-internal'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { InMemoryTaskStore, TASKS_EXTENSION_ID, TasksExtension } from '../../../src/ext/tasks/index'; +import { invoke } from '../../../src/server/invoke'; +import { McpServer } from '../../../src/server/mcp'; + +const MODERN = '2026-07-28'; +const CLASSIFICATION: MessageClassification = { era: 'modern', revision: MODERN }; +const info = { name: 's', version: '1' }; + +const request = (method: string, params: Record): JSONRPCRequest => ({ + jsonrpc: '2.0', + id: 1, + method, + params: { + ...params, + _meta: { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'c', version: '1' }, + [CLIENT_CAPABILITIES_META_KEY]: { extensions: { [TASKS_EXTENSION_ID]: {} } } + } + } +}); + +const auth = (clientId: string, sub?: string): AuthInfo => ({ + token: 't', + clientId, + scopes: [], + ...(sub !== undefined && { extra: { sub } }) +}); + +function serve(tasks: TasksExtension) { + const mcp = new McpServer(info, { extensions: [tasks] }); + mcp.registerTool('job', { inputSchema: z.object({}) }, async (_args, ctx) => tasks.create(ctx)); + return mcp; +} + +async function call(tasks: TasksExtension, method: string, params: Record, authInfo?: AuthInfo) { + const mcp = serve(tasks); + setNegotiatedProtocolVersion(mcp.server, MODERN); + const response = await invoke(mcp.server, request(method, params), { + classification: CLASSIFICATION, + ...(authInfo !== undefined && { authInfo }) + }); + return (await response.json()) as { result?: Record; error?: { code: number } }; +} + +describe('TasksExtension principal', () => { + let store: InMemoryTaskStore; + afterEach(() => store.close()); + + it('binds a task to the client id and token subject by default, so users of one client stay apart', async () => { + store = new InMemoryTaskStore(); + const tasks = new TasksExtension(store); + const created = await call(tasks, 'tools/call', { name: 'job', arguments: {} }, auth('shared-app', 'alice')); + const taskId = created.result?.['taskId'] as string; + expect((await call(tasks, 'tasks/get', { taskId }, auth('shared-app', 'alice'))).result).toMatchObject({ taskId }); + expect((await call(tasks, 'tasks/get', { taskId }, auth('shared-app', 'bob'))).error).toMatchObject({ code: -32_602 }); + expect((await call(tasks, 'tasks/get', { taskId }, auth('other-app', 'alice'))).error).toMatchObject({ code: -32_602 }); + }); + + it('uses the principal option when given', async () => { + store = new InMemoryTaskStore(); + const tasks = new TasksExtension(store, { principal: ctx => ctx.http?.authInfo?.extra?.['tenant'] as string | undefined }); + const tenant = (name: string): AuthInfo => ({ token: 't', clientId: 'app', scopes: [], extra: { tenant: name } }); + const created = await call(tasks, 'tools/call', { name: 'job', arguments: {} }, tenant('acme')); + const taskId = created.result?.['taskId'] as string; + expect((await call(tasks, 'tasks/get', { taskId }, tenant('acme'))).result).toMatchObject({ taskId }); + expect((await call(tasks, 'tasks/get', { taskId }, tenant('globex'))).error).toMatchObject({ code: -32_602 }); + }); +}); + +describe('TasksExtension on 2025-11-25', () => { + it('answers the tasks methods with -32601 and refuses task handles with -32021', async () => { + const store = new InMemoryTaskStore(); + const mcp = serve(new TasksExtension(store)); + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + await mcp.connect(serverTx); + const client = new Client({ name: 'c', version: '1' }, { capabilities: { extensions: { [TASKS_EXTENSION_ID]: {} } } }); + await client.connect(clientTx); + + await expect(client.request({ method: 'tasks/get', params: { taskId: 'x' } }, z.looseObject({}))).rejects.toMatchObject({ + code: -32_601 + }); + await expect( + client.request({ method: 'tasks/update', params: { taskId: 'x', inputResponses: {} } }, z.looseObject({})) + ).rejects.toMatchObject({ + code: -32_601 + }); + await expect(client.callTool({ name: 'job', arguments: {} })).rejects.toMatchObject({ code: -32_021 }); + + await client.close(); + store.close(); + }); +}); From 2a4c1894bb4bc935a976a19429c7aa123179cf30 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 13:46:29 +0000 Subject: [PATCH 08/12] test(conformance): serve the Tasks extension and its fixtures everythingServer now installs TasksExtension over one in-memory store shared by the per-request servers, and registers the tools the tasks-* scenarios call by name: greet, slow_compute, failing_job, protocol_error_job, confirm_delete, multi_input and test_tool_with_task. All nine tasks-* server scenarios pass, so they leave the expected-failures baseline. --- test/conformance/expected-failures.yaml | 18 +-- test/conformance/src/everythingServer.ts | 5 +- test/conformance/src/tasks.ts | 145 +++++++++++++++++++++++ test/conformance/tsconfig.json | 2 + 4 files changed, 152 insertions(+), 18 deletions(-) create mode 100644 test/conformance/src/tasks.ts diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index bedba50604..827173ca21 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -55,20 +55,4 @@ client: # (SEP-1932 DPoP burned by the auth/dpop + auth/dpop-nonce client implementation.) - auth/wif-jwt-bearer -server: - # --- SEP-2663 (io.modelcontextprotocol/tasks) — server SDK does not implement the tasks extension --- - # Extension-tagged scenarios; selected only by `--suite all` (the alpha.10 referee - # has no server-side `--suite extensions`). The active/draft/2026 legs never select - # them, so they cannot flag these entries as stale. `tasks-status-notifications` is - # intentionally absent: the referee SKIPs it unconditionally (harness rewrite pending - # against the SEP-2575 subscriptions/listen channel), so a baseline entry would be - # flagged stale. - - tasks-lifecycle - - tasks-capability-negotiation - - tasks-wire-fields - - tasks-request-state-removal - - tasks-mrtr-input - - tasks-request-headers - - tasks-dispatch-and-envelope - - tasks-required-task-error - - tasks-mrtr-composition +server: [] diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index a42ed62ef8..5f1bcb9f32 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -44,6 +44,7 @@ import express from 'express'; import * as z from 'zod/v4'; import { conformanceSkills } from './skills'; +import { conformanceTasks, registerTaskFixtures } from './tasks'; // Server state const resourceSubscriptions = new Set(); @@ -167,9 +168,11 @@ function createMcpServer() { // data.reason 'invalid_request_state'. requestState: { verify: requestStateCodec.verify }, // SEP-2640: skills/list, skills/get, resources/directory/read and the skill:// resource template. - extensions: [conformanceSkills] + // SEP-2663: tasks/get, tasks/update, tasks/cancel and task handles on tools/call. + extensions: [conformanceSkills, conformanceTasks] } ); + registerTaskFixtures(mcpServer); // Helper to send log messages using the underlying server function sendLog( diff --git a/test/conformance/src/tasks.ts b/test/conformance/src/tasks.ts new file mode 100644 index 0000000000..e17cce63ae --- /dev/null +++ b/test/conformance/src/tasks.ts @@ -0,0 +1,145 @@ +/** + * Tasks (SEP-2663) served by the conformance server, with the fixtures the + * `tasks-*` server scenarios call by name. One store backs every per-request + * server, and each task's work is a plain async function driving the store's + * writer handle. + */ +import type { CallToolResult, InputRequiredResult, McpServer, ServerContext } from '@modelcontextprotocol/server'; +import { acceptedContent, inputRequired } from '@modelcontextprotocol/server'; +import type { TaskHandle } from '@modelcontextprotocol/server/ext/tasks'; +import { declaresTasksExtension, InMemoryTaskStore, TasksExtension } from '@modelcontextprotocol/server/ext/tasks'; +import * as z from 'zod/v4'; + +const store = new InMemoryTaskStore(); + +export const conformanceTasks = new TasksExtension(store, { defaultTtlMs: 600_000, defaultPollIntervalMs: 100 }); + +const text = (value: string): CallToolResult => ({ content: [{ type: 'text', text: value }] }); + +/** Resolves after `ms`, or early when `signal` aborts. */ +const sleep = (ms: number, signal?: AbortSignal): Promise => + new Promise(resolve => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true } + ); + }); + +const confirmRequest = (message: string) => + inputRequired.elicit({ + message, + requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + }); + +/** Creates a task for this call and starts `work` on it; a failure in `work` fails the task. */ +async function runAsTask(ctx: ServerContext, work: (handle: TaskHandle) => Promise) { + const task = await conformanceTasks.create(ctx); + const handle = store.handle(task.taskId); + void work(handle).catch(error => handle.fail({ code: -32_603, message: String(error) })); + return task; +} + +export function registerTaskFixtures(mcpServer: McpServer): void { + mcpServer.registerTool( + 'greet', + { description: 'SEP-2663 fixture: sync-only greeting', inputSchema: z.object({ name: z.string() }) }, + async ({ name }) => text(`Hello, ${name}!`) + ); + + // Task-supporting; without the extension declared on the request it runs synchronously. + mcpServer.registerTool( + 'slow_compute', + { + description: 'SEP-2663 fixture: sleeps `seconds`, then returns; a task when the client declares the extension', + inputSchema: z.object({ seconds: z.number().min(0), label: z.string().optional() }) + }, + async ({ seconds, label }, ctx) => { + const done = `computed ${label ?? 'result'} after ${seconds}s`; + if (!declaresTasksExtension(ctx)) { + await sleep(Math.min(seconds, 5) * 1000); + return text(done); + } + return runAsTask(ctx, async handle => { + await handle.status(`computing ${label ?? ''}`.trim()); + await sleep(seconds * 1000, handle.signal); + await handle.complete(text(done)); + }); + } + ); + + // Task-required: refuses with -32021 when the client did not declare the extension. + mcpServer.registerTool( + 'failing_job', + { description: 'SEP-2663 fixture: a task that completes with a tool execution error', inputSchema: z.object({}) }, + async (_args, ctx) => + runAsTask(ctx, async handle => { + await sleep(1000, handle.signal); + await handle.complete({ isError: true, content: [{ type: 'text', text: 'the job failed' }] }); + }) + ); + + mcpServer.registerTool( + 'protocol_error_job', + { description: 'SEP-2663 fixture: a task that fails with a protocol-level error', inputSchema: z.object({}) }, + async (_args, ctx) => + runAsTask(ctx, async handle => { + await sleep(200, handle.signal); + await handle.fail({ code: -32_603, message: 'internal failure while running the job' }); + }) + ); + + mcpServer.registerTool( + 'confirm_delete', + { + description: 'SEP-2663 fixture: a task that asks for confirmation through tasks/update', + inputSchema: z.object({ filename: z.string() }) + }, + async ({ filename }, ctx) => + runAsTask(ctx, async handle => { + const answers = await handle.requireInput({ confirm: confirmRequest(`Delete ${filename}?`) }); + const confirmed = acceptedContent<{ confirm: boolean }>(answers, 'confirm')?.confirm === true; + await handle.complete(text(confirmed ? `deleted ${filename}` : `kept ${filename}`)); + }) + ); + + mcpServer.registerTool( + 'multi_input', + { description: 'SEP-2663 fixture: a task that asks two questions at once', inputSchema: z.object({}) }, + async (_args, ctx) => + runAsTask(ctx, async handle => { + const answers = await handle.requireInput({ + first: confirmRequest('First question?'), + second: confirmRequest('Second question?') + }); + await handle.complete(text(`answered ${Object.keys(answers).toSorted().join(', ')}`)); + }) + ); + + // MRTR first (SEP-2322), then a task on the final round (SEP-2663). + mcpServer.registerTool( + 'test_tool_with_task', + { description: 'SEP-2663 fixture: gathers a name in band, then answers with a task', inputSchema: z.object({}) }, + async (_args, ctx): Promise => { + const name = acceptedContent<{ name: string }>(ctx.mcpReq.inputResponses, 'user_name')?.name; + if (typeof name !== 'string') { + return inputRequired({ + inputRequests: { + user_name: inputRequired.elicit({ + message: 'What is your name?', + requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } + }) + } + }); + } + return runAsTask(ctx, async handle => { + await sleep(100, handle.signal); + await handle.complete(text(`Hello, ${name}!`)); + }); + } + ); +} diff --git a/test/conformance/tsconfig.json b/test/conformance/tsconfig.json index 01cfe3ef37..f8a3c53109 100644 --- a/test/conformance/tsconfig.json +++ b/test/conformance/tsconfig.json @@ -18,12 +18,14 @@ "@modelcontextprotocol/core-internal/ext/skills": [ "./node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" ], + "@modelcontextprotocol/core-internal/ext/tasks": ["./node_modules/@modelcontextprotocol/core-internal/src/ext/tasks/index.ts"], "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], "@modelcontextprotocol/client/ext/skills": ["./node_modules/@modelcontextprotocol/client/src/ext/skills/index.ts"], "@modelcontextprotocol/server/ext/skills": ["./node_modules/@modelcontextprotocol/server/src/ext/skills/index.ts"], + "@modelcontextprotocol/server/ext/tasks": ["./node_modules/@modelcontextprotocol/server/src/ext/tasks/index.ts"], "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], From 6f69be83f603b321c1c95378df3e1cb4fe59c334 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 14:00:33 +0000 Subject: [PATCH 09/12] fix(server): refuse malformed tasks/update and cancel refused task handles - tasks/update acked a request whose inputResponses the protocol dropped as not bare results, or that had none, leaving the task waiting. It now answers -32602 naming the refused keys, or the missing field; an empty object is still a valid partial update. - The tools/call middleware refused a task handle minted outside create after the task was already running, so the task leaked until its TTL, or forever with ttlMs: null. It now cancels the task before refusing. --- .../server/src/ext/tasks/tasksExtension.ts | 25 ++++++++++++++----- .../server/test/ext/tasks/tasks.e2e.test.ts | 7 +++++- .../test/ext/tasks/tasksExtension.test.ts | 13 ++++++++++ 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/server/src/ext/tasks/tasksExtension.ts b/packages/server/src/ext/tasks/tasksExtension.ts index 2de365e613..fe0e0e1a65 100644 --- a/packages/server/src/ext/tasks/tasksExtension.ts +++ b/packages/server/src/ext/tasks/tasksExtension.ts @@ -177,7 +177,16 @@ export class TasksExtension implements ServerExtension { async (params, requestCtx): Promise => { requireModernRequest(requestCtx); requireTasksExtension(requestCtx, 'tasks/update'); - const inputResponses = (requestCtx.mcpReq.inputResponses ?? params.inputResponses ?? {}) as InputResponses; + const dropped = requestCtx.mcpReq.droppedInputResponseKeys ?? []; + if (dropped.length > 0) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `inputResponses entries must be bare results; refused: ${dropped.join(', ')}` + ); + } + const inputResponses = (requestCtx.mcpReq.inputResponses ?? params.inputResponses) as InputResponses | undefined; + if (inputResponses === undefined) + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'tasks/update requires inputResponses'); const found = await this.store.update(params.taskId, inputResponses, { principal: this.#principal(requestCtx) }); if (!found) return notFound(); return { resultType: 'complete' }; @@ -192,14 +201,18 @@ export class TasksExtension implements ServerExtension { return { resultType: 'complete' }; }); - // A task handle may only be answered to a client that can consume it. - // `create(ctx, …)` checks up front; this catches handles minted some - // other way (an external engine's own create) — the extension owns - // the wire regardless of where the task came from. + // Refuses task handles minted outside `create` to a client that cannot consume them, cancelling the task first. ctx.use('tools/call', async (request: JSONRPCRequest, requestCtx, next): Promise => { const result = await next(request, requestCtx); - if ((result as { resultType?: unknown }).resultType === 'task') { + const handle = result as { resultType?: unknown; taskId?: unknown }; + if (handle.resultType !== 'task') return result; + try { requireTasksExtension(requestCtx, `Tool "${String((request.params as { name?: unknown })?.name)}" executes as a task and`); + } catch (error) { + if (typeof handle.taskId === 'string') { + await this.store.cancel(handle.taskId, { principal: this.#principal(requestCtx) }).catch(() => false); + } + throw error; } return result; }); diff --git a/packages/server/test/ext/tasks/tasks.e2e.test.ts b/packages/server/test/ext/tasks/tasks.e2e.test.ts index 319381cf22..ad1b11f0ef 100644 --- a/packages/server/test/ext/tasks/tasks.e2e.test.ts +++ b/packages/server/test/ext/tasks/tasks.e2e.test.ts @@ -7,7 +7,7 @@ */ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { TasksClientExtension } from '@modelcontextprotocol/client/ext/tasks'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import * as z from 'zod/v4'; import type { DetailedTask, TaskHandle } from '../../../src/ext/tasks/index'; @@ -189,8 +189,13 @@ describe('TasksExtension end to end (stateless handler, in-memory store)', () => }); const refused = await plain.callTool('greet', { name: 'x' }); expect(refused.error).toMatchObject({ code: -32_021 }); + const cancel = vi.spyOn(plain.store, 'cancel'); const sneaky = await plain.callTool('sneaky', {}); expect(sneaky.error).toMatchObject({ code: -32_021 }); + // The refused handle's task is cancelled, not left running. + expect(cancel).toHaveBeenCalledTimes(1); + const [taskId] = cancel.mock.calls[0]!; + expect((await plain.store.get(taskId))?.status).toBe('cancelled'); plain.store.close(); }); }); diff --git a/packages/server/test/ext/tasks/tasksExtension.test.ts b/packages/server/test/ext/tasks/tasksExtension.test.ts index 35d4708b90..29825e21bc 100644 --- a/packages/server/test/ext/tasks/tasksExtension.test.ts +++ b/packages/server/test/ext/tasks/tasksExtension.test.ts @@ -84,6 +84,19 @@ describe('TasksExtension principal', () => { }); }); +describe('tasks/update', () => { + it('answers -32602 without inputResponses, and for entries the protocol could not take as bare results', async () => { + const store = new InMemoryTaskStore(); + const tasks = new TasksExtension(store); + const { taskId } = await store.create({ ttlMs: null }); + expect((await call(tasks, 'tasks/update', { taskId })).error).toMatchObject({ code: -32_602 }); + const wrapped = { q: { method: 'elicitation/create', result: { action: 'accept' } } }; + expect((await call(tasks, 'tasks/update', { taskId, inputResponses: wrapped })).error).toMatchObject({ code: -32_602 }); + expect((await call(tasks, 'tasks/update', { taskId, inputResponses: {} })).result).toMatchObject({ resultType: 'complete' }); + store.close(); + }); +}); + describe('TasksExtension on 2025-11-25', () => { it('answers the tasks methods with -32601 and refuses task handles with -32021', async () => { const store = new InMemoryTaskStore(); From c299e82cb67960074885ba81e1806c131dad190a Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 14:01:11 +0000 Subject: [PATCH 10/12] fix(server): give in-memory task aborts a reason that says why TTL expiry and close() aborted the same signal as tasks/cancel with no reason, and a pending requireInput rejected with "was cancelled" in every case. The signal now carries an Error naming cancellation, expiry or store close, and the wait rejects with it. --- .../server/src/ext/tasks/inMemoryStore.ts | 10 +++---- .../test/ext/tasks/inMemoryStore.test.ts | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/server/src/ext/tasks/inMemoryStore.ts b/packages/server/src/ext/tasks/inMemoryStore.ts index 66f6fe3681..3b04d411f9 100644 --- a/packages/server/src/ext/tasks/inMemoryStore.ts +++ b/packages/server/src/ext/tasks/inMemoryStore.ts @@ -46,7 +46,7 @@ const MAX_TIMEOUT_MS = 2_147_483_647; */ export interface TaskHandle { readonly taskId: string; - /** Aborted when `tasks/cancel` is acknowledged. */ + /** Aborted when `tasks/cancel` is acknowledged, the TTL expires, or the store closes; `signal.reason` says which. */ readonly signal: AbortSignal; /** Writes `statusMessage` for pollers. */ status(message: string): Promise; @@ -83,7 +83,7 @@ export class InMemoryTaskStore implements TaskStore { close(): void { for (const record of this.#tasks.values()) { if (record.ttlTimer !== undefined) clearTimeout(record.ttlTimer); - record.abort.abort(); + record.abort.abort(new Error(`Task "${record.taskId}" was dropped: the store closed`)); } this.#tasks.clear(); } @@ -142,7 +142,7 @@ export class InMemoryTaskStore implements TaskStore { record.status = 'cancelled'; record.pendingInput = undefined; this.#touch(record); - record.abort.abort(); + record.abort.abort(new Error(`Task "${taskId}" was cancelled`)); return true; } @@ -176,7 +176,7 @@ export class InMemoryTaskStore implements TaskStore { } return new Promise((resolve, reject) => { const signal = current.abort.signal; - const onAbort = () => reject(new Error(`Task "${taskId}" was cancelled`)); + const onAbort = () => reject(signal.reason as Error); signal.addEventListener('abort', onAbort, { once: true }); current.pendingInput = { requests, @@ -243,7 +243,7 @@ export class InMemoryTaskStore implements TaskStore { } #purge(record: TaskRecord): void { - record.abort.abort(); + record.abort.abort(new Error(`Task "${record.taskId}" expired`)); this.#tasks.delete(record.taskId); } diff --git a/packages/server/test/ext/tasks/inMemoryStore.test.ts b/packages/server/test/ext/tasks/inMemoryStore.test.ts index c8ad8d143d..7201bbfb10 100644 --- a/packages/server/test/ext/tasks/inMemoryStore.test.ts +++ b/packages/server/test/ext/tasks/inMemoryStore.test.ts @@ -108,6 +108,33 @@ describe('InMemoryTaskStore', () => { } }); + it('tells expiry and store close apart from cancellation, on the signal and the input wait', async () => { + vi.useFakeTimers(); + try { + const store = new InMemoryTaskStore(); + const ask = { + q: { + method: 'elicitation/create', + params: { message: 'q', mode: 'form', requestedSchema: { type: 'object', properties: {} } } + } + } as const; + const expiring = store.handle((await store.create({ ttlMs: 100 })).taskId); + const expiringWait = expiring.requireInput(ask); + const closing = store.handle((await store.create({ ttlMs: null })).taskId); + const closingWait = closing.requireInput(ask); + const expired = expect(expiringWait).rejects.toThrow('expired'); + const closed = expect(closingWait).rejects.toThrow('the store closed'); + await vi.advanceTimersByTimeAsync(100); + await expired; + expect(String(expiring.signal.reason)).toContain('expired'); + store.close(); + await closed; + expect(String(closing.signal.reason)).toContain('the store closed'); + } finally { + vi.useRealTimers(); + } + }); + it('rejects invalid ttlMs and pollIntervalMs', async () => { const store = new InMemoryTaskStore(); await expect(store.create({ ttlMs: -1 })).rejects.toBeInstanceOf(RangeError); From 5356ed63faefc960c060dfa14ef88bae42568306 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 14:01:30 +0000 Subject: [PATCH 11/12] docs(tasks): the client example connects on 2026-07-28; trim the wire-type headers The TasksClientExtension example built a client on the default legacy handshake, where tasks.callTool is refused with -32021. It now negotiates, and the header says the extension needs a 2026-07-28 connection. The wire-type and schema headers keep provenance only. --- .../src/ext/tasks/tasksClientExtension.ts | 7 ++++--- packages/core-internal/src/ext/tasks/schemas.ts | 17 +++-------------- packages/core-internal/src/ext/tasks/types.ts | 17 ++--------------- 3 files changed, 9 insertions(+), 32 deletions(-) diff --git a/packages/client/src/ext/tasks/tasksClientExtension.ts b/packages/client/src/ext/tasks/tasksClientExtension.ts index 41856f625d..632924e3a8 100644 --- a/packages/client/src/ext/tasks/tasksClientExtension.ts +++ b/packages/client/src/ext/tasks/tasksClientExtension.ts @@ -1,14 +1,15 @@ /** * `TasksClientExtension` — the client side of the MCP Tasks extension - * (`io.modelcontextprotocol/tasks`) as a {@linkcode ClientExtension}. It - * advertises the capability on every request, lets `tools/call` answer with a + * (`io.modelcontextprotocol/tasks`) as a {@linkcode ClientExtension}. On a + * 2026-07-28 connection, which the extension needs, it advertises the + * capability on every request, lets `tools/call` answer with a * task handle, and wraps the extension's methods: `get`, `update`, `cancel`, * and a `waitFor` that polls at the server's suggested interval until the * task is terminal. * * ```ts * const tasks = new TasksClientExtension(); - * const client = new Client(info, { extensions: [tasks] }); + * const client = new Client(info, { versionNegotiation: { mode: 'auto' }, extensions: [tasks] }); * await client.connect(transport); * * const outcome = await tasks.callTool({ name: 'send_report', arguments: { to: 'ops' } }); diff --git a/packages/core-internal/src/ext/tasks/schemas.ts b/packages/core-internal/src/ext/tasks/schemas.ts index c54c570efb..e10edaf0d0 100644 --- a/packages/core-internal/src/ext/tasks/schemas.ts +++ b/packages/core-internal/src/ext/tasks/schemas.ts @@ -1,20 +1,9 @@ /* - * Hand-written zod v4 schemas for the MCP Tasks extension wire types - * (./types). Upstream commits only generated JSON Schema - * (`schema/draft/schema.json`); these runtime schemas are authored against - * modelcontextprotocol/ext-tasks pinned at commit dcc8d2b (SEP-2663 Final). + * Zod schemas for the MCP Tasks extension wire types (./types), written against + * modelcontextprotocol/ext-tasks at dcc8d2b (SEP-2663 Final). Objects are loose + * and `resultType` literals are required on results. * https://github.com/modelcontextprotocol/ext-tasks * - * Deliberate deviations from the generated JSON Schema, which reflects the - * pre-envelope TS source rather than the wire: - * - Objects are loose (unknown keys pass through) where the JSON Schema says - * `additionalProperties: false`: modern responses carry `resultType` and - * `_meta`, and modern request params carry the `_meta` envelope. - * - `resultType` literals are REQUIRED on result schemas (spec MUST; the - * JSON Schema omits the field entirely). - * - `InputRequest`/`InputResponse` get minimal structural checks (the JSON Schema - * degenerates them to `anyOf [{}, {}, {}]`). - * * Copyright (c) Model Context Protocol contributors */ diff --git a/packages/core-internal/src/ext/tasks/types.ts b/packages/core-internal/src/ext/tasks/types.ts index 494b1d5a29..e1ccc9a9c7 100644 --- a/packages/core-internal/src/ext/tasks/types.ts +++ b/packages/core-internal/src/ext/tasks/types.ts @@ -1,19 +1,6 @@ /* - * MCP Tasks extension wire types (extension id: io.modelcontextprotocol/tasks). - * - * Adapted from modelcontextprotocol/ext-tasks, pinned at commit dcc8d2b - * (SEP-2663 Final): `schema/draft/schema.ts`, re-based on - * `@modelcontextprotocol/server` v2 types — the modern SDK ships the MRTR - * `InputRequest`/`InputResponse` unions the upstream file's TODOs point at, so - * those are re-exported rather than re-declared. The JSON-RPC request shapes - * are declared standalone (not extending the SDK's `JSONRPCRequest`) so the - * wire contract stays pinned to this file. `notifications/tasks` and the - * subscription additions are omitted: v1 is polling-only (the engine contract)). - * - * This module (with ./schemas) is the ONLY import source for task wire types - * in this repo — the SDK's deprecated 2025-11-25 task exports (`Task`, - * `CreateTaskResult`, `TaskStatus`, `GetTaskRequest`, ...) carry the removed - * legacy wire shape and must not be used (the engine contract)). + * MCP Tasks extension wire types (io.modelcontextprotocol/tasks), adapted from + * modelcontextprotocol/ext-tasks at dcc8d2b (SEP-2663 Final). * https://github.com/modelcontextprotocol/ext-tasks * * Copyright (c) Model Context Protocol contributors From 8980fb8e87f0abddf3acebe507918f04fb0f6fb6 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 16:39:02 +0000 Subject: [PATCH 12/12] feat(core): publish the Tasks wire definitions at @modelcontextprotocol/core/ext/tasks The Tasks schemas, types and constants lived in the private core-internal, so code implementing either side without the SDK's server or client had no published import, and the changeset had pointed at a private package. They now live in core/ext/tasks, like core/ext/skills: the zod schemas and constants move unchanged, and the types are inferred from the schemas, so there is one definition. core-internal/ext/tasks is gone. The core types describe inputRequests structurally ({ method, params }), as core cannot import the SDK's MRTR unions. The server and client subpaths re-export core's definitions, plus the precise InputRequest(s) and InputResponse(s) types that requireInput and update take. --- .changeset/tasks-extension.md | 4 +- docs/servers/tasks.md | 1 + examples/client-quickstart/tsconfig.json | 3 + examples/server-quickstart/tsconfig.json | 3 + examples/shared/tsconfig.json | 3 + examples/tsconfig.json | 6 +- packages/client/src/ext/tasks/index.ts | 16 +- .../src/ext/tasks/tasksClientExtension.ts | 4 +- packages/client/tsconfig.json | 4 +- packages/client/tsdown.config.ts | 9 +- packages/core-internal/package.json | 4 - packages/core-internal/src/ext/tasks/index.ts | 7 - packages/core-internal/src/ext/tasks/types.ts | 207 ------------------ .../test/packageTopologyPins.test.ts | 5 +- packages/core-internal/tsconfig.json | 3 +- packages/core/package.json | 13 ++ packages/core/src/ext/tasks/constants.ts | 11 + packages/core/src/ext/tasks/index.ts | 10 + .../src/ext/tasks/schemas.ts | 4 +- packages/core/src/ext/tasks/types.ts | 82 +++++++ packages/core/tsdown.config.ts | 2 +- packages/middleware/express/tsconfig.json | 3 + packages/middleware/fastify/tsconfig.json | 3 + packages/middleware/hono/tsconfig.json | 3 + packages/middleware/node/tsconfig.json | 3 + packages/server-legacy/tsconfig.json | 1 + .../server/src/ext/tasks/inMemoryStore.ts | 2 +- packages/server/src/ext/tasks/index.ts | 16 +- packages/server/src/ext/tasks/store.ts | 2 +- .../server/src/ext/tasks/tasksExtension.ts | 4 +- packages/server/tsconfig.json | 2 +- packages/server/tsdown.config.ts | 9 +- test/conformance/tsconfig.json | 4 +- test/e2e/tsconfig.json | 3 + test/helpers/tsconfig.json | 3 + test/integration/tsconfig.json | 3 + 36 files changed, 204 insertions(+), 258 deletions(-) delete mode 100644 packages/core-internal/src/ext/tasks/index.ts delete mode 100644 packages/core-internal/src/ext/tasks/types.ts create mode 100644 packages/core/src/ext/tasks/constants.ts create mode 100644 packages/core/src/ext/tasks/index.ts rename packages/{core-internal => core}/src/ext/tasks/schemas.ts (97%) create mode 100644 packages/core/src/ext/tasks/types.ts diff --git a/.changeset/tasks-extension.md b/.changeset/tasks-extension.md index 2f327e81d9..93bd051056 100644 --- a/.changeset/tasks-extension.md +++ b/.changeset/tasks-extension.md @@ -1,5 +1,5 @@ --- -'@modelcontextprotocol/core-internal': minor +'@modelcontextprotocol/core': minor '@modelcontextprotocol/client': minor '@modelcontextprotocol/server': minor --- @@ -10,4 +10,4 @@ The MCP Tasks extension (`io.modelcontextprotocol/tasks`) as a pair of extension `@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. -Both subpaths also export the wire types and zod schemas for the extension's 2026-07-28 schema. +`@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. diff --git a/docs/servers/tasks.md b/docs/servers/tasks.md index ff5aaa2a09..eb04c2ccc5 100644 --- a/docs/servers/tasks.md +++ b/docs/servers/tasks.md @@ -83,6 +83,7 @@ How the work behind a task runs — a queue, a workflow engine, a durable-execut - 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 diff --git a/examples/client-quickstart/tsconfig.json b/examples/client-quickstart/tsconfig.json index e227905f23..e63e20ccbe 100644 --- a/examples/client-quickstart/tsconfig.json +++ b/examples/client-quickstart/tsconfig.json @@ -19,6 +19,9 @@ "@modelcontextprotocol/core/ext/skills": [ "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" ], + "@modelcontextprotocol/core/ext/tasks": [ + "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core/src/ext/tasks/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], diff --git a/examples/server-quickstart/tsconfig.json b/examples/server-quickstart/tsconfig.json index ad7cceae55..e5b4f388a9 100644 --- a/examples/server-quickstart/tsconfig.json +++ b/examples/server-quickstart/tsconfig.json @@ -19,6 +19,9 @@ "@modelcontextprotocol/core/ext/skills": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" ], + "@modelcontextprotocol/core/ext/tasks": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/ext/tasks/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], diff --git a/examples/shared/tsconfig.json b/examples/shared/tsconfig.json index 75cedeee6c..b7250a6fb3 100644 --- a/examples/shared/tsconfig.json +++ b/examples/shared/tsconfig.json @@ -19,6 +19,9 @@ "@modelcontextprotocol/core/ext/skills": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" ], + "@modelcontextprotocol/core/ext/tasks": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/ext/tasks/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], diff --git a/examples/tsconfig.json b/examples/tsconfig.json index 69092a7b0a..230b130639 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -28,6 +28,7 @@ ], "@modelcontextprotocol/core/internal": ["./node_modules/@modelcontextprotocol/core/src/internal.ts"], "@modelcontextprotocol/core/ext/skills": ["./node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts"], + "@modelcontextprotocol/core/ext/tasks": ["./node_modules/@modelcontextprotocol/core/src/ext/tasks/index.ts"], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], @@ -36,10 +37,7 @@ ], "@mcp-examples/shared": ["./node_modules/@mcp-examples/shared/src/index.ts"], "@modelcontextprotocol/server/ext/tasks": ["./node_modules/@modelcontextprotocol/server/src/ext/tasks/index.ts"], - "@modelcontextprotocol/client/ext/tasks": ["./node_modules/@modelcontextprotocol/client/src/ext/tasks/index.ts"], - "@modelcontextprotocol/core-internal/ext/tasks": [ - "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/ext/tasks/index.ts" - ] + "@modelcontextprotocol/client/ext/tasks": ["./node_modules/@modelcontextprotocol/client/src/ext/tasks/index.ts"] } } } diff --git a/packages/client/src/ext/tasks/index.ts b/packages/client/src/ext/tasks/index.ts index 6a58b1ccac..68ae1ee380 100644 --- a/packages/client/src/ext/tasks/index.ts +++ b/packages/client/src/ext/tasks/index.ts @@ -21,19 +21,19 @@ export type { GetTaskParams, GetTaskRequest, GetTaskResult, - InputRequest, - InputRequests, InputRequiredTask, - InputResponse, - InputResponses, Task, + TaskInputRequest, + TaskInputRequests, + TaskInputResponse, + TaskInputResponses, TasksExtensionCapability, TaskStatus, UpdateTaskParams, UpdateTaskRequest, UpdateTaskResult, WorkingTask -} from '@modelcontextprotocol/core-internal/ext/tasks'; +} from '@modelcontextprotocol/core/ext/tasks'; export { cancelledTaskSchema, cancelTaskParamsSchema, @@ -58,5 +58,7 @@ export { updateTaskRequestSchema, updateTaskResultSchema, workingTaskSchema -} from '@modelcontextprotocol/core-internal/ext/tasks'; -export { TASK_STATUSES, TASKS_EXTENSION_ID } from '@modelcontextprotocol/core-internal/ext/tasks'; +} from '@modelcontextprotocol/core/ext/tasks'; +export { TASK_STATUSES, TASKS_EXTENSION_ID } from '@modelcontextprotocol/core/ext/tasks'; +// The SDK's precise input request and response types, which requireInput and update take. +export type { InputRequest, InputRequests, InputResponse, InputResponses } from '@modelcontextprotocol/core-internal'; diff --git a/packages/client/src/ext/tasks/tasksClientExtension.ts b/packages/client/src/ext/tasks/tasksClientExtension.ts index 632924e3a8..8c1e2a6a00 100644 --- a/packages/client/src/ext/tasks/tasksClientExtension.ts +++ b/packages/client/src/ext/tasks/tasksClientExtension.ts @@ -24,8 +24,8 @@ import type { CallToolRequestParams, CallToolResult, InputResponses, RequestOptions } from '@modelcontextprotocol/core-internal'; import { CompatibilityCallToolResultSchema, SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal'; -import type { CreateTaskResult, DetailedTask } from '@modelcontextprotocol/core-internal/ext/tasks'; -import { createTaskResultSchema, detailedTaskSchema, TASKS_EXTENSION_ID } from '@modelcontextprotocol/core-internal/ext/tasks'; +import type { CreateTaskResult, DetailedTask } from '@modelcontextprotocol/core/ext/tasks'; +import { createTaskResultSchema, detailedTaskSchema, TASKS_EXTENSION_ID } from '@modelcontextprotocol/core/ext/tasks'; import * as z from 'zod/v4'; import type { Client } from '../../client/client'; diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 97218ce9e9..5dfddbeeaf 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -8,6 +8,7 @@ "@modelcontextprotocol/core-internal": ["./node_modules/@modelcontextprotocol/core-internal/src/index.ts"], "@modelcontextprotocol/core/internal": ["./node_modules/@modelcontextprotocol/core/src/internal.ts"], "@modelcontextprotocol/core/ext/skills": ["./node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts"], + "@modelcontextprotocol/core/ext/tasks": ["./node_modules/@modelcontextprotocol/core/src/ext/tasks/index.ts"], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], @@ -21,8 +22,7 @@ "./node_modules/@modelcontextprotocol/core-internal/src/validators/cfWorkerProvider.ts" ], "@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"], - "@modelcontextprotocol/client/_shims": ["./src/shimsNode.ts"], - "@modelcontextprotocol/core-internal/ext/tasks": ["./node_modules/@modelcontextprotocol/core-internal/src/ext/tasks/index.ts"] + "@modelcontextprotocol/client/_shims": ["./src/shimsNode.ts"] } } } diff --git a/packages/client/tsdown.config.ts b/packages/client/tsdown.config.ts index 368ff2af10..a2a9598566 100644 --- a/packages/client/tsdown.config.ts +++ b/packages/client/tsdown.config.ts @@ -29,10 +29,10 @@ export default defineConfig({ paths: { 'fast-uri': ['../core-internal/src/validators/fastUriShim.d.ts'], '@modelcontextprotocol/core/ext/skills': ['../core/src/ext/skills/index.ts'], + '@modelcontextprotocol/core/ext/tasks': ['../core/src/ext/tasks/index.ts'], '@modelcontextprotocol/core-internal': ['../core-internal/src/index.ts'], '@modelcontextprotocol/core-internal/public': ['../core-internal/src/exports/public/index.ts'], '@modelcontextprotocol/core-internal/ext/skills': ['../core-internal/src/ext/skills/index.ts'], - '@modelcontextprotocol/core-internal/ext/tasks': ['../core-internal/src/ext/tasks/index.ts'], '@modelcontextprotocol/core-internal/validators/ajv': ['../core-internal/src/validators/ajvProvider.ts'], '@modelcontextprotocol/core-internal/validators/cfWorker': ['../core-internal/src/validators/cfWorkerProvider.ts'] } @@ -42,5 +42,10 @@ export default defineConfig({ // The schema modules live in @modelcontextprotocol/core (a real runtime dependency); the // bundled core-internal shims import them via the './internal' subpath, which must stay an // external import (explicit entry — the tsconfig paths alias would otherwise inline it). - external: ['@modelcontextprotocol/client/_shims', '@modelcontextprotocol/core/internal', '@modelcontextprotocol/core/ext/skills'] + external: [ + '@modelcontextprotocol/client/_shims', + '@modelcontextprotocol/core/internal', + '@modelcontextprotocol/core/ext/skills', + '@modelcontextprotocol/core/ext/tasks' + ] }); diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json index 8ecddb18c0..7c8ebaf2b8 100644 --- a/packages/core-internal/package.json +++ b/packages/core-internal/package.json @@ -37,10 +37,6 @@ "types": "./src/ext/skills/index.ts", "import": "./src/ext/skills/index.ts" }, - "./ext/tasks": { - "types": "./src/ext/tasks/index.ts", - "import": "./src/ext/tasks/index.ts" - }, "./validators/ajv": { "types": "./src/validators/ajvProvider.ts", "import": "./src/validators/ajvProvider.ts" diff --git a/packages/core-internal/src/ext/tasks/index.ts b/packages/core-internal/src/ext/tasks/index.ts deleted file mode 100644 index f55a8bda64..0000000000 --- a/packages/core-internal/src/ext/tasks/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Wire types and zod schemas of the MCP Tasks extension - * (`io.modelcontextprotocol/tasks`, schema 2026-07-28), shared by the server - * and client halves of the extension. - */ -export * from './schemas'; -export * from './types'; diff --git a/packages/core-internal/src/ext/tasks/types.ts b/packages/core-internal/src/ext/tasks/types.ts deleted file mode 100644 index e1ccc9a9c7..0000000000 --- a/packages/core-internal/src/ext/tasks/types.ts +++ /dev/null @@ -1,207 +0,0 @@ -/* - * MCP Tasks extension wire types (io.modelcontextprotocol/tasks), adapted from - * modelcontextprotocol/ext-tasks at dcc8d2b (SEP-2663 Final). - * https://github.com/modelcontextprotocol/ext-tasks - * - * Copyright (c) Model Context Protocol contributors - */ - -import type { InputRequests, InputResponses, Result } from '../../types/index'; - -/** - * A single input request / response embedded in a task, re-based on the SDK - * v2 MRTR unions (sampling, roots, or elicitation). Keys in the containing - * maps MUST be unique over the lifetime of a single task. - */ -export type { InputRequest, InputRequests, InputResponse, InputResponses } from '../../types/index'; - -/** The MCP Tasks extension identifier. An empty-object capability declares support. */ -export const TASKS_EXTENSION_ID = 'io.modelcontextprotocol/tasks'; - -/** All task statuses, in lifecycle order (terminal states last). */ -export const TASK_STATUSES = ['working', 'input_required', 'completed', 'failed', 'cancelled'] as const; - -/** The status of a task. */ -export type TaskStatus = (typeof TASK_STATUSES)[number]; - -/** Data associated with a task. */ -export interface Task { - /** The task identifier. */ - taskId: string; - - /** Current task status. */ - status: TaskStatus; - - /** - * Optional human-readable message describing the current task state: - * progress descriptions for "working", blocked work for "input_required", - * reasons for "cancelled", summaries for "completed", diagnostics for - * "failed". - */ - statusMessage?: string; - - /** ISO 8601 timestamp when the task was created. */ - createdAt: string; - - /** ISO 8601 timestamp when the task was last updated. */ - lastUpdatedAt: string; - - /** - * Time-to-live duration from creation in integer milliseconds, null for - * unlimited. The server may discard the task after the TTL elapses. This - * value MAY change over the lifetime of a task. - */ - ttlMs: number | null; - - /** - * Suggested polling interval in integer milliseconds. Clients SHOULD honor - * this value to avoid overwhelming the server. This value MAY change over - * the lifetime of a task. - */ - pollIntervalMs?: number; -} - -/** A task that is in a normal working state. */ -export interface WorkingTask extends Task { - status: 'working'; -} - -/** A task that is waiting for input from the client. */ -export interface InputRequiredTask extends Task { - status: 'input_required'; - - /** - * Server-to-client requests that need to be fulfilled during task - * execution. Keys are arbitrary identifiers for matching requests to - * responses. - */ - inputRequests: InputRequests; -} - -/** A task that has completed successfully. */ -export interface CompletedTask extends Task { - status: 'completed'; - - /** - * The final result of the task. The structure matches the result type of - * the original request — for a `tools/call` task, the `CallToolResult` - * structure. - */ - result: { [key: string]: unknown }; -} - -/** A task that has failed due to a JSON-RPC error during execution. */ -export interface FailedTask extends Task { - status: 'failed'; - - /** The JSON-RPC error that caused the task to fail. */ - error: { [key: string]: unknown }; -} - -/** A task that has been cancelled. */ -export interface CancelledTask extends Task { - status: 'cancelled'; -} - -/** - * A task with status-specific fields inlined, as returned by `tasks/get`: - * terminal results or pending input requests ride on the snapshot itself. - */ -export type DetailedTask = WorkingTask | InputRequiredTask | CompletedTask | FailedTask | CancelledTask; - -/** - * The result returned by a server in lieu of a standard result shape when it - * elects to process a request asynchronously — flat `Result & Task`. The - * `resultType` field MUST be `"task"` on the wire; it is declared explicitly - * here (the upstream type leaves it to the old SDK `Result`'s index - * signature, which the v2 `Result` no longer has). - */ -export type CreateTaskResult = Result & Task & { resultType: 'task' }; - -/** Parameters of a `tasks/get` request. */ -export interface GetTaskParams { - /** The task identifier to query. */ - taskId: string; - - /** - * The modern (2026-07-28) per-request envelope: carries - * `io.modelcontextprotocol/clientCapabilities` among other keys. Not part - * of the upstream extension schema's params (which lists only `taskId`) — - * declared here because every modern request threads it. - */ - _meta?: Record; -} - -/** A request to retrieve the state of a task. */ -export interface GetTaskRequest { - jsonrpc: '2.0'; - id: string | number; - method: 'tasks/get'; - params: GetTaskParams; -} - -/** - * The response to `tasks/get`: the appropriate {@link DetailedTask} variant - * for the task's current status, with `resultType: "complete"` (MUST). - */ -export type GetTaskResult = Result & DetailedTask & { resultType: 'complete' }; - -/** Parameters of a `tasks/update` request. */ -export interface UpdateTaskParams { - /** The task identifier to update. */ - taskId: string; - - /** - * Responses to outstanding inputRequests previously surfaced by the - * server. Each key MUST correspond to a currently-outstanding inputRequest - * key (unknown keys are ignored; partial responses are accepted). - */ - inputResponses: InputResponses; - - /** The modern per-request envelope (see {@link GetTaskParams._meta}). */ - _meta?: Record; -} - -/** A request to provide input responses to a task in the input_required state. */ -export interface UpdateTaskRequest { - jsonrpc: '2.0'; - id: string | number; - method: 'tasks/update'; - params: UpdateTaskParams; -} - -/** - * The response to `tasks/update`: an empty acknowledgement (eventually - * consistent), with `resultType: "complete"` (MUST). - */ -export type UpdateTaskResult = Result & { resultType: 'complete' }; - -/** Parameters of a `tasks/cancel` request. */ -export interface CancelTaskParams { - /** The task identifier to cancel. */ - taskId: string; - - /** The modern per-request envelope (see {@link GetTaskParams._meta}). */ - _meta?: Record; -} - -/** A request to cancel a task. Cancellation is cooperative and eventually consistent. */ -export interface CancelTaskRequest { - jsonrpc: '2.0'; - id: string | number; - method: 'tasks/cancel'; - params: CancelTaskParams; -} - -/** - * The response to `tasks/cancel`: an empty acknowledgement (ack does not mean - * stopped), with `resultType: "complete"` (MUST). - */ -export type CancelTaskResult = Result & { resultType: 'complete' }; - -/** - * The extension capability declaration for the tasks extension. An empty - * object indicates support; no extension-specific settings are currently - * defined. - */ -export type TasksExtensionCapability = Record; diff --git a/packages/core-internal/test/packageTopologyPins.test.ts b/packages/core-internal/test/packageTopologyPins.test.ts index 6716f30cfd..14cb336ed9 100644 --- a/packages/core-internal/test/packageTopologyPins.test.ts +++ b/packages/core-internal/test/packageTopologyPins.test.ts @@ -71,8 +71,9 @@ const PUBLIC_PACKAGES: Record; + +/** + * A request embedded in a task for the client to fulfil: an elicitation, + * sampling, or roots request (`{ method, params }`). + */ +export type TaskInputRequest = z.infer; +/** Input requests keyed by identifiers unique over the task's lifetime. */ +export type TaskInputRequests = z.infer; +/** The bare result object answering one input request. */ +export type TaskInputResponse = z.infer; +/** Input responses keyed like the input requests they answer. */ +export type TaskInputResponses = z.infer; + +/** Data associated with a task. */ +export type Task = z.infer; +/** A task that is running. */ +export type WorkingTask = z.infer; +/** A task waiting for the client to answer `inputRequests` through `tasks/update`. */ +export type InputRequiredTask = z.infer; +/** A finished task, with the original request's result inlined (`isError` tool results included). */ +export type CompletedTask = z.infer; +/** A task that ended in a JSON-RPC error, inlined as `error`. */ +export type FailedTask = z.infer; +/** A cancelled task. */ +export type CancelledTask = z.infer; +/** A task snapshot, discriminated on `status`. */ +export type DetailedTask = z.infer; + +/** The task handle a request answers with: flat `Result & Task`, `resultType: "task"`. */ +export type CreateTaskResult = z.infer; + +/** `tasks/get` params. */ +export type GetTaskParams = z.infer; +/** `tasks/get` request. */ +export type GetTaskRequest = z.infer; +/** `tasks/get` result: a `DetailedTask` with `resultType: "complete"`. */ +export type GetTaskResult = z.infer; +/** `tasks/update` params. */ +export type UpdateTaskParams = z.infer; +/** `tasks/update` request. */ +export type UpdateTaskRequest = z.infer; +/** `tasks/update` result: an empty ack. */ +export type UpdateTaskResult = z.infer; +/** `tasks/cancel` params. */ +export type CancelTaskParams = z.infer; +/** `tasks/cancel` request. */ +export type CancelTaskRequest = z.infer; +/** `tasks/cancel` result: an empty ack. */ +export type CancelTaskResult = z.infer; + +/** The capability a peer declares under `capabilities.extensions["io.modelcontextprotocol/tasks"]`. */ +export type TasksExtensionCapability = z.infer; diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index dec96674ef..ef7cdab44a 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown'; // makes a node-only dependency leaking in fail the build here instead of silently shipping. export default defineConfig({ failOnWarn: 'ci-only', - entry: ['src/index.ts', 'src/internal.ts', 'src/ext/skills/index.ts'], + entry: ['src/index.ts', 'src/internal.ts', 'src/ext/skills/index.ts', 'src/ext/tasks/index.ts'], format: ['esm', 'cjs'], fixedExtension: true, outDir: 'dist', diff --git a/packages/middleware/express/tsconfig.json b/packages/middleware/express/tsconfig.json index aa149835df..9d4e23da41 100644 --- a/packages/middleware/express/tsconfig.json +++ b/packages/middleware/express/tsconfig.json @@ -16,6 +16,9 @@ "@modelcontextprotocol/core/ext/skills": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" ], + "@modelcontextprotocol/core/ext/tasks": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/ext/tasks/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], diff --git a/packages/middleware/fastify/tsconfig.json b/packages/middleware/fastify/tsconfig.json index de34390e99..014cccfbd9 100644 --- a/packages/middleware/fastify/tsconfig.json +++ b/packages/middleware/fastify/tsconfig.json @@ -15,6 +15,9 @@ ], "@modelcontextprotocol/core/ext/skills": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" + ], + "@modelcontextprotocol/core/ext/tasks": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/ext/tasks/index.ts" ] } } diff --git a/packages/middleware/hono/tsconfig.json b/packages/middleware/hono/tsconfig.json index aa149835df..9d4e23da41 100644 --- a/packages/middleware/hono/tsconfig.json +++ b/packages/middleware/hono/tsconfig.json @@ -16,6 +16,9 @@ "@modelcontextprotocol/core/ext/skills": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" ], + "@modelcontextprotocol/core/ext/tasks": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/ext/tasks/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], diff --git a/packages/middleware/node/tsconfig.json b/packages/middleware/node/tsconfig.json index 6c35df5027..91664ae1bc 100644 --- a/packages/middleware/node/tsconfig.json +++ b/packages/middleware/node/tsconfig.json @@ -14,6 +14,9 @@ "@modelcontextprotocol/core/ext/skills": [ "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" ], + "@modelcontextprotocol/core/ext/tasks": [ + "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/ext/tasks/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], diff --git a/packages/server-legacy/tsconfig.json b/packages/server-legacy/tsconfig.json index 832ad0ea9e..4a9e4aa387 100644 --- a/packages/server-legacy/tsconfig.json +++ b/packages/server-legacy/tsconfig.json @@ -8,6 +8,7 @@ "@modelcontextprotocol/core-internal": ["./node_modules/@modelcontextprotocol/core-internal/src/index.ts"], "@modelcontextprotocol/core/internal": ["./node_modules/@modelcontextprotocol/core/src/internal.ts"], "@modelcontextprotocol/core/ext/skills": ["./node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts"], + "@modelcontextprotocol/core/ext/tasks": ["./node_modules/@modelcontextprotocol/core/src/ext/tasks/index.ts"], "@modelcontextprotocol/core-internal/public": ["./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts"] } } diff --git a/packages/server/src/ext/tasks/inMemoryStore.ts b/packages/server/src/ext/tasks/inMemoryStore.ts index 3b04d411f9..e28d44ced9 100644 --- a/packages/server/src/ext/tasks/inMemoryStore.ts +++ b/packages/server/src/ext/tasks/inMemoryStore.ts @@ -7,7 +7,7 @@ */ import type { CallToolResult, InputRequests, InputResponses } from '@modelcontextprotocol/core-internal'; -import type { DetailedTask, Task, TaskStatus } from '@modelcontextprotocol/core-internal/ext/tasks'; +import type { DetailedTask, Task, TaskStatus } from '@modelcontextprotocol/core/ext/tasks'; import type { CreateTaskParams, TaskAccess, TaskStore } from './store'; diff --git a/packages/server/src/ext/tasks/index.ts b/packages/server/src/ext/tasks/index.ts index 2cdcf9684a..e3cfc43cc7 100644 --- a/packages/server/src/ext/tasks/index.ts +++ b/packages/server/src/ext/tasks/index.ts @@ -27,19 +27,19 @@ export type { GetTaskParams, GetTaskRequest, GetTaskResult, - InputRequest, - InputRequests, InputRequiredTask, - InputResponse, - InputResponses, Task, + TaskInputRequest, + TaskInputRequests, + TaskInputResponse, + TaskInputResponses, TasksExtensionCapability, TaskStatus, UpdateTaskParams, UpdateTaskRequest, UpdateTaskResult, WorkingTask -} from '@modelcontextprotocol/core-internal/ext/tasks'; +} from '@modelcontextprotocol/core/ext/tasks'; export { cancelledTaskSchema, cancelTaskParamsSchema, @@ -64,5 +64,7 @@ export { updateTaskRequestSchema, updateTaskResultSchema, workingTaskSchema -} from '@modelcontextprotocol/core-internal/ext/tasks'; -export { TASK_STATUSES, TASKS_EXTENSION_ID } from '@modelcontextprotocol/core-internal/ext/tasks'; +} from '@modelcontextprotocol/core/ext/tasks'; +export { TASK_STATUSES, TASKS_EXTENSION_ID } from '@modelcontextprotocol/core/ext/tasks'; +// The SDK's precise input request and response types, which requireInput and update take. +export type { InputRequest, InputRequests, InputResponse, InputResponses } from '@modelcontextprotocol/core-internal'; diff --git a/packages/server/src/ext/tasks/store.ts b/packages/server/src/ext/tasks/store.ts index 1b988a471f..6fece995cc 100644 --- a/packages/server/src/ext/tasks/store.ts +++ b/packages/server/src/ext/tasks/store.ts @@ -7,7 +7,7 @@ */ import type { InputResponses } from '@modelcontextprotocol/core-internal'; -import type { DetailedTask, Task } from '@modelcontextprotocol/core-internal/ext/tasks'; +import type { DetailedTask, Task } from '@modelcontextprotocol/core/ext/tasks'; /** What `TasksExtension.create` asks a store to durably create. */ export interface CreateTaskParams { diff --git a/packages/server/src/ext/tasks/tasksExtension.ts b/packages/server/src/ext/tasks/tasksExtension.ts index fe0e0e1a65..fb12d075d8 100644 --- a/packages/server/src/ext/tasks/tasksExtension.ts +++ b/packages/server/src/ext/tasks/tasksExtension.ts @@ -28,13 +28,13 @@ import { ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/core-internal'; -import type { CancelTaskResult, CreateTaskResult, GetTaskResult, UpdateTaskResult } from '@modelcontextprotocol/core-internal/ext/tasks'; +import type { CancelTaskResult, CreateTaskResult, GetTaskResult, UpdateTaskResult } from '@modelcontextprotocol/core/ext/tasks'; import { cancelTaskParamsSchema, getTaskParamsSchema, inputResponsesSchema, TASKS_EXTENSION_ID -} from '@modelcontextprotocol/core-internal/ext/tasks'; +} from '@modelcontextprotocol/core/ext/tasks'; import type { ServerExtension, ServerExtensionContext } from '../../server/extension'; import type { CreateTaskParams, TaskStore } from './store'; diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 494526ddc8..f2624b3725 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -8,6 +8,7 @@ "@modelcontextprotocol/core-internal": ["./node_modules/@modelcontextprotocol/core-internal/src/index.ts"], "@modelcontextprotocol/core/internal": ["./node_modules/@modelcontextprotocol/core/src/internal.ts"], "@modelcontextprotocol/core/ext/skills": ["./node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts"], + "@modelcontextprotocol/core/ext/tasks": ["./node_modules/@modelcontextprotocol/core/src/ext/tasks/index.ts"], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], @@ -24,7 +25,6 @@ "@modelcontextprotocol/server/_shims": ["./src/shimsNode.ts"], "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], - "@modelcontextprotocol/core-internal/ext/tasks": ["./node_modules/@modelcontextprotocol/core-internal/src/ext/tasks/index.ts"], "@modelcontextprotocol/client/ext/tasks": ["./node_modules/@modelcontextprotocol/client/src/ext/tasks/index.ts"], "@modelcontextprotocol/client/ext/skills": ["./node_modules/@modelcontextprotocol/client/src/ext/skills/index.ts"] } diff --git a/packages/server/tsdown.config.ts b/packages/server/tsdown.config.ts index efacd7d4bd..e15adb790b 100644 --- a/packages/server/tsdown.config.ts +++ b/packages/server/tsdown.config.ts @@ -30,10 +30,10 @@ export default defineConfig({ paths: { 'fast-uri': ['../core-internal/src/validators/fastUriShim.d.ts'], '@modelcontextprotocol/core/ext/skills': ['../core/src/ext/skills/index.ts'], + '@modelcontextprotocol/core/ext/tasks': ['../core/src/ext/tasks/index.ts'], '@modelcontextprotocol/core-internal': ['../core-internal/src/index.ts'], '@modelcontextprotocol/core-internal/public': ['../core-internal/src/exports/public/index.ts'], '@modelcontextprotocol/core-internal/ext/skills': ['../core-internal/src/ext/skills/index.ts'], - '@modelcontextprotocol/core-internal/ext/tasks': ['../core-internal/src/ext/tasks/index.ts'], '@modelcontextprotocol/core-internal/validators/ajv': ['../core-internal/src/validators/ajvProvider.ts'], '@modelcontextprotocol/core-internal/validators/cfWorker': ['../core-internal/src/validators/cfWorkerProvider.ts'] } @@ -43,5 +43,10 @@ export default defineConfig({ // The schema modules live in @modelcontextprotocol/core (a real runtime dependency); the // bundled core-internal shims import them via the './internal' subpath, which must stay an // external import (explicit entry — the tsconfig paths alias would otherwise inline it). - external: ['@modelcontextprotocol/server/_shims', '@modelcontextprotocol/core/internal', '@modelcontextprotocol/core/ext/skills'] + external: [ + '@modelcontextprotocol/server/_shims', + '@modelcontextprotocol/core/internal', + '@modelcontextprotocol/core/ext/skills', + '@modelcontextprotocol/core/ext/tasks' + ] }); diff --git a/test/conformance/tsconfig.json b/test/conformance/tsconfig.json index f8a3c53109..7ab9e0bd52 100644 --- a/test/conformance/tsconfig.json +++ b/test/conformance/tsconfig.json @@ -12,13 +12,15 @@ "@modelcontextprotocol/core/ext/skills": [ "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" ], + "@modelcontextprotocol/core/ext/tasks": [ + "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/ext/tasks/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], "@modelcontextprotocol/core-internal/ext/skills": [ "./node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" ], - "@modelcontextprotocol/core-internal/ext/tasks": ["./node_modules/@modelcontextprotocol/core-internal/src/ext/tasks/index.ts"], "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], diff --git a/test/e2e/tsconfig.json b/test/e2e/tsconfig.json index 13094c9b35..6aaca74dcf 100644 --- a/test/e2e/tsconfig.json +++ b/test/e2e/tsconfig.json @@ -12,6 +12,9 @@ "@modelcontextprotocol/core/ext/skills": [ "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" ], + "@modelcontextprotocol/core/ext/tasks": [ + "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/ext/tasks/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], diff --git a/test/helpers/tsconfig.json b/test/helpers/tsconfig.json index e6108eaf1f..7281dde741 100644 --- a/test/helpers/tsconfig.json +++ b/test/helpers/tsconfig.json @@ -12,6 +12,9 @@ "@modelcontextprotocol/core/ext/skills": [ "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" ], + "@modelcontextprotocol/core/ext/tasks": [ + "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/ext/tasks/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], diff --git a/test/integration/tsconfig.json b/test/integration/tsconfig.json index a15fa93ad5..8e25969a41 100644 --- a/test/integration/tsconfig.json +++ b/test/integration/tsconfig.json @@ -12,6 +12,9 @@ "@modelcontextprotocol/core/ext/skills": [ "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" ], + "@modelcontextprotocol/core/ext/tasks": [ + "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/ext/tasks/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ],