diff --git a/.changeset/tasks-extension.md b/.changeset/tasks-extension.md new file mode 100644 index 0000000000..93bd051056 --- /dev/null +++ b/.changeset/tasks-extension.md @@ -0,0 +1,13 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +The MCP Tasks extension (`io.modelcontextprotocol/tasks`) as a pair of extensions. + +`@modelcontextprotocol/server/ext/tasks`: `new TasksExtension(store)` in `McpServerOptions.extensions` advertises the capability, serves `tasks/get`, `tasks/update` and `tasks/cancel`, gates task handles on the client capability (`-32021`), binds each task to the creating request's principal (`principal` option; by default the OAuth client id plus the token subject), serves its methods on 2026-07-28 only (`-32601` on 2025-11-25), and offers `tasks.create(ctx)` for a tool handler to answer with a task handle. `TaskStore` (create / get / update / cancel over JSON) is the interface a server implements over its own state and execution; `InMemoryTaskStore` is the in-process reference with a writer `handle` for reporting status, requesting input, and settling. + +`@modelcontextprotocol/client/ext/tasks`: `new TasksClientExtension()` in `ClientOptions.extensions` declares the capability on every request, accepts task handles on `tools/call`, and wraps the extension's methods: `callTool` (a task handle or the plain result), `get`, `update`, `cancel`, and `waitFor`, which polls at the server's suggested interval to a terminal snapshot. + +`@modelcontextprotocol/core/ext/tasks`: the extension's wire constants (`TASKS_EXTENSION_ID`, `TASK_STATUSES`), zod schemas and the types inferred from them, runtime-neutral. The server and client subpaths re-export them, plus the SDK's precise `InputRequest(s)` / `InputResponse(s)` types that `requireInput` and `update` take. 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..eb04c2ccc5 --- /dev/null +++ b/docs/servers/tasks.md @@ -0,0 +1,94 @@ +--- +shape: how-to +--- + +# Tasks (extension) + +The [MCP Tasks extension](https://github.com/modelcontextprotocol/ext-tasks) (`io.modelcontextprotocol/tasks`) lets a tool answer with a **task handle** instead of blocking on work that takes minutes: the client polls `tasks/get`, answers `tasks/update`, and stops with `tasks/cancel`. `@modelcontextprotocol/server/ext/tasks` is the server side of that wire, as a [server extension](../advanced/extensions.md). It owns the protocol; you own the task's state and its execution, behind a `TaskStore`. + +## Install the extension + +`TasksExtension` takes the store. `InMemoryTaskStore` is the in-process reference; a persistent store implements the same four methods. + +```ts +import { McpServer } from '@modelcontextprotocol/server'; +import { InMemoryTaskStore, TasksExtension } from '@modelcontextprotocol/server/ext/tasks'; + +const store = new InMemoryTaskStore(); +const tasks = new TasksExtension(store); + +const server = new McpServer({ name: 'report-server', version: '1.0.0' }, { extensions: [tasks] }); +``` + +The server advertises `io.modelcontextprotocol/tasks` under `capabilities.extensions` and serves `tasks/get`, `tasks/update` and `tasks/cancel`. + +## Answer a tool call with a task + +Inside a tool handler, `tasks.create(ctx)` creates the task in the store, bound to the request's principal, and returns the handle the handler answers with. The work itself is yours to start however your server runs things — here, an async function driving the in-memory store's writer handle. + +```ts +import type { TaskHandle } from '@modelcontextprotocol/server/ext/tasks'; +import * as z from 'zod/v4'; + +server.registerTool('send_report', { inputSchema: z.object({ to: z.string() }) }, async ({ to }, ctx) => { + const task = await tasks.create(ctx, { ttlMs: 3_600_000 }); + const handle = store.handle(task.taskId); + // The work outlives the request: settle its failures on the task, not as an unhandled rejection. + void sendReport(handle, to).catch(error => handle.fail({ code: -32_603, message: String(error) })); + return task; +}); + +async function sendReport(handle: TaskHandle, to: string) { + await handle.status('compiling'); + const report = await compile(to); + const answers = await handle.requireInput({ + approve: { method: 'elicitation/create', params: { message: `send ${report.pages} pages to ${to}?`, mode: 'form', requestedSchema: { type: 'object', properties: {} } } } + }); + if (answers.approve?.action !== 'accept' || handle.signal.aborted) return handle.complete({ content: [{ type: 'text', text: 'not sent' }] }); + await deliver(report, to); + await handle.complete({ content: [{ type: 'text', text: `report sent to ${to}` }] }); +} +``` + +On the wire the tool answers a flat `CreateTaskResult` (`resultType: "task"`). `requireInput` moves the task to `input_required` and resolves once `tasks/update` has answered every key; it rejects if the task is cancelled or purged meanwhile, which is why the work's promise needs a `catch`. `tasks/cancel` aborts `handle.signal`; a later `complete` or `fail` is ignored. + +## Bind tasks to the caller + +Every `tasks/*` request is checked against the principal the task was created for, and a request from a different principal gets "Task not found". By default the principal is the OAuth client id, plus the token subject when your token verifier puts it at `authInfo.extra.sub`. If users share one client and your verifier sets no subject, say which identity to bind to: + +```ts +const tasks = new TasksExtension(store, { + principal: ctx => ctx.http?.authInfo?.extra?.['userId'] as string | undefined +}); +``` + +Without authentication the principal is `undefined` and the task is unbound: anyone holding its id can reach it. + +## Bring your own store + +`TaskStore` is four methods over JSON: + +| Method | Contract | +| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `create(params)` | Durably create; MUST NOT resolve before a following `get` would succeed. `params.context` is yours (tool name, workflow id…). | +| `get(taskId, access?)` | The `DetailedTask` snapshot, or `undefined` for unknown, expired, or foreign-principal tasks. | +| `update(taskId, inputResponses, access?)` | Deliver answers; `false` when the task is not found. Unknown keys are ignored, partial answers accepted. | +| `cancel(taskId, access?)` | Cooperative; resolves on acknowledgement. Idempotent on terminal tasks. | + +How the work behind a task runs — a queue, a workflow engine, a durable-execution runtime — is invisible to the SDK. The store is also where a writer API lives if your execution needs one; `InMemoryTaskStore.handle` is the reference shape. + +## Wire notes + +- The extension is served on the 2026-07-28 revision. On a 2025-11-25 request, `tasks/get`, `tasks/update` and `tasks/cancel` answer `-32601`, and `tasks.create` refuses with `-32021`. Task tools are ordinary tools without `outputSchema`; the result encoder forwards `resultType: "task"` for `tools/call` verbatim. +- A request that does not declare `io.modelcontextprotocol/tasks` in its client capabilities is refused with `-32021` — from `tasks.create`, from the `tasks/*` methods, and by the extension's `tools/call` middleware for any handle minted some other way. +- `tasks/update`'s `inputResponses` shares its name with the multi-round-trip retry field: the protocol layer lifts it out of the params and the extension reads it back from `ctx.mcpReq.inputResponses`. +- A plain `client.callTool` rejects `resultType: "task"`; the requester half of the extension is `TasksClientExtension` from `@modelcontextprotocol/client/ext/tasks` ([Tasks on the client](../clients/tasks.md)). +- The wire schemas, types and constants live in `@modelcontextprotocol/core/ext/tasks`, for code that implements either side without the SDK's server or client; both subpaths re-export them. +- `notifications/tasks` over `subscriptions/listen` is not implemented (typescript-sdk#2569); polling only. + +## Recap + +- `new TasksExtension(store)` in `McpServerOptions.extensions` serves the extension. +- `tasks.create(ctx, options?)` in a tool handler returns the task handle to answer with. +- `TaskStore` is create / get / update / cancel; `InMemoryTaskStore` is the reference and adds a writer `handle`. +- Execution is the server's; the SDK only defines the API shape. 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/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/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/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 28288e5c6d..230b130639 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -28,13 +28,16 @@ ], "@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" ], "@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"] } } } 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..68ae1ee380 --- /dev/null +++ b/packages/client/src/ext/tasks/index.ts @@ -0,0 +1,64 @@ +/** + * `@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, + InputRequiredTask, + Task, + TaskInputRequest, + TaskInputRequests, + TaskInputResponse, + TaskInputResponses, + TasksExtensionCapability, + TaskStatus, + UpdateTaskParams, + UpdateTaskRequest, + UpdateTaskResult, + WorkingTask +} from '@modelcontextprotocol/core/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/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 new file mode 100644 index 0000000000..8c1e2a6a00 --- /dev/null +++ b/packages/client/src/ext/tasks/tasksClientExtension.ts @@ -0,0 +1,144 @@ +/** + * `TasksClientExtension` — the client side of the MCP Tasks extension + * (`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, { versionNegotiation: { mode: 'auto' }, 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/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'; +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; polling waits for a returned promise. */ + onUpdate?: (task: DetailedTask) => void | Promise; +} + +/** 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 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 }); + 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); + } + } +} + +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..0fea77402b --- /dev/null +++ b/packages/client/test/ext/tasks/tasksClientExtension.test.ts @@ -0,0 +1,180 @@ +/** + * `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 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(); + 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..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" ], diff --git a/packages/client/tsdown.config.ts b/packages/client/tsdown.config.ts index d4a8de998a..a2a9598566 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', @@ -28,6 +29,7 @@ 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'], @@ -40,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/test/packageTopologyPins.test.ts b/packages/core-internal/test/packageTopologyPins.test.ts index 5bc882e8e3..14cb336ed9 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; + +/** + * 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/package.json b/packages/server/package.json index e147d8b516..e55b7d42a7 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -60,6 +60,16 @@ "default": "./dist/ext/skills/node.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", @@ -141,6 +151,9 @@ ], "stdio": [ "dist/stdio.d.mts" + ], + "ext/tasks": [ + "dist/ext/tasks/index.d.mts" ] } }, @@ -164,9 +177,8 @@ }, "devDependencies": { "@cfworker/json-schema": "catalog:runtimeShared", - "ajv": "catalog:runtimeShared", - "ajv-formats": "catalog:runtimeShared", "@eslint/js": "catalog:devTools", + "@modelcontextprotocol/client": "workspace:^", "@modelcontextprotocol/core-internal": "workspace:^", "@modelcontextprotocol/eslint-config": "workspace:^", "@modelcontextprotocol/test-helpers": "workspace:^", @@ -174,6 +186,8 @@ "@modelcontextprotocol/vitest-config": "workspace:^", "@types/eventsource": "catalog:devTools", "@typescript/native-preview": "catalog:devTools", + "ajv": "catalog:runtimeShared", + "ajv-formats": "catalog:runtimeShared", "eslint": "catalog:devTools", "eslint-config-prettier": "catalog:devTools", "eslint-plugin-n": "catalog:devTools", diff --git a/packages/server/src/ext/tasks/inMemoryStore.ts b/packages/server/src/ext/tasks/inMemoryStore.ts new file mode 100644 index 0000000000..e28d44ced9 --- /dev/null +++ b/packages/server/src/ext/tasks/inMemoryStore.ts @@ -0,0 +1,287 @@ +/** + * `InMemoryTaskStore` — the reference {@link TaskStore}: task records in a + * `Map`, TTL purge on a timer, and a writer {@link TaskHandle} the server's + * own execution uses to report progress, request input, and settle the task. + * State does not survive the process; the shape of every method is the row + * write a persistent store would make instead. + */ + +import type { CallToolResult, InputRequests, InputResponses } from '@modelcontextprotocol/core-internal'; +import type { DetailedTask, Task, TaskStatus } from '@modelcontextprotocol/core/ext/tasks'; + +import type { CreateTaskParams, TaskAccess, TaskStore } from './store'; + +interface PendingInput { + requests: InputRequests; + responses: InputResponses; + resolve: (responses: InputResponses) => void; + reject: (error: Error) => 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']); + +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 + * equivalent (or none, when the work reports through the store directly). + */ +export interface TaskHandle { + readonly taskId: string; + /** 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; + /** + * 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, purged or settled while + * waiting, and when another `requireInput` is already outstanding. + */ + 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(new Error(`Task "${record.taskId}" was dropped: the store closed`)); + } + 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) this.#schedulePurge(record, record.ttlMs); + 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(new Error(`Task "${taskId}" was cancelled`)); + 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`)); + if (current.pendingInput !== undefined) { + return Promise.reject(new Error(`Task "${taskId}" is already waiting for input`)); + } + return new Promise((resolve, reject) => { + const signal = current.abort.signal; + const onAbort = () => reject(signal.reason as Error); + 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); + }); + }, + complete: async result => { + const current = live(); + if (current === undefined) return; + current.status = 'completed'; + current.result = result; + this.#settlePendingInput(current); + this.#touch(current); + }, + fail: async error => { + const current = live(); + if (current === undefined) return; + current.status = 'failed'; + current.error = error; + this.#settlePendingInput(current); + 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; + } + + #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(); + } + + // 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(new Error(`Task "${record.taskId}" expired`)); + 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..e3cfc43cc7 --- /dev/null +++ b/packages/server/src/ext/tasks/index.ts @@ -0,0 +1,70 @@ +/** + * `@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, + InputRequiredTask, + Task, + TaskInputRequest, + TaskInputRequests, + TaskInputResponse, + TaskInputResponses, + TasksExtensionCapability, + TaskStatus, + UpdateTaskParams, + UpdateTaskRequest, + UpdateTaskResult, + WorkingTask +} from '@modelcontextprotocol/core/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/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 new file mode 100644 index 0000000000..6fece995cc --- /dev/null +++ b/packages/server/src/ext/tasks/store.ts @@ -0,0 +1,55 @@ +/** + * 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/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 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. + */ + 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..fb12d075d8 --- /dev/null +++ b/packages/server/src/ext/tasks/tasksExtension.ts @@ -0,0 +1,220 @@ +/** + * `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 }); + * const handle = store.handle(task.taskId); + * void runReport(handle, input).catch(error => handle.fail({ code: -32_603, message: String(error) })); + * 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/ext/tasks'; +import { + cancelTaskParamsSchema, + getTaskParamsSchema, + inputResponsesSchema, + TASKS_EXTENSION_ID +} from '@modelcontextprotocol/core/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; + /** + * 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. */ +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; + 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` + ); +}; + +/** 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'); +}; + +/** + * `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; + 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; + } + + /** + * 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 = this.#principal(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 => { + requireModernRequest(requestCtx); + requireTasksExtension(requestCtx, 'tasks/get'); + const task = await this.store.get(params.taskId, { principal: this.#principal(requestCtx) }); + if (task === undefined) return notFound(); + return { resultType: 'complete', ...task }; + }); + + ctx.setRequestHandler( + 'tasks/update', + { params: updateTaskHandlerParamsSchema }, + async (params, requestCtx): Promise => { + requireModernRequest(requestCtx); + requireTasksExtension(requestCtx, 'tasks/update'); + 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' }; + } + ); + + 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: this.#principal(requestCtx) }); + if (!found) return notFound(); + return { resultType: 'complete' }; + }); + + // 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); + 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/inMemoryStore.test.ts b/packages/server/test/ext/tasks/inMemoryStore.test.ts new file mode 100644 index 0000000000..7201bbfb10 --- /dev/null +++ b/packages/server/test/ext/tasks/inMemoryStore.test.ts @@ -0,0 +1,144 @@ +/** + * `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, vi } 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('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' }); + 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('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('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); + 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..ad1b11f0ef --- /dev/null +++ b/packages/server/test/ext/tasks/tasks.e2e.test.ts @@ -0,0 +1,201 @@ +/** + * 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, vi } 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 => 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'); + }); + + 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 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 new file mode 100644 index 0000000000..29825e21bc --- /dev/null +++ b/packages/server/test/ext/tasks/tasksExtension.test.ts @@ -0,0 +1,122 @@ +/** + * `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('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(); + 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(); + }); +}); 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..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" ], @@ -21,7 +22,11 @@ "./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/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..e15adb790b 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', @@ -29,6 +30,7 @@ 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'], @@ -41,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/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 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..7ab9e0bd52 100644 --- a/test/conformance/tsconfig.json +++ b/test/conformance/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" ], @@ -24,6 +27,7 @@ "@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"], 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" ],