From ab89e5a783df579b4d2194c2a297083830794dd5 Mon Sep 17 00:00:00 2001 From: freya0926 <299410795+freya0926@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:30:59 +0900 Subject: [PATCH 1/6] fix(core-internal): let explicit-schema handlers/calls escape the era-universe gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inbound and outbound era gates rejected any method name that ever appeared in a past protocol revision's registry but is absent from the current era's registry, even when the consumer explicitly registered a handler (or supplied a schema on send) for it. This made extension methods that reuse a historical core method name unreachable: the Tasks extension (SEP-2663) defines `tasks/get` and `tasks/cancel`, both of which the 2025-11-25 revision used for now-removed core methods, so a 2026-era server could never serve them and a 2026-era client could never send them — every attempt answered -32601 or threw MethodNotSupportedByProtocolVersion before the handler or the transport were ever consulted. Both gates now only apply to TYPED dispatch (setRequestHandler(method, handler) inbound, request(method, options) outbound) — exactly the path the SDK's own built-ins (initialize, ping, logging/setLevel) use, which correctly stays era-gated. A method registered or sent with an EXPLICIT schema (setRequestHandler(method, schemas, handler) / request(request, resultSchema, options)) is the extension-authoring path: the consumer supplied their own validation, so a historical registry collision no longer blocks it. Fixes #2598 --- .../era-gate-explicit-schema-handlers.md | 8 +++ docs/migration/support-2026-07-28.md | 23 ++++--- packages/core-internal/src/shared/protocol.ts | 60 ++++++++++++++++--- packages/core-internal/src/wire/codec.ts | 29 ++++++--- .../core-internal/test/wire/eraGates.test.ts | 57 +++++++++++++++--- 5 files changed, 146 insertions(+), 31 deletions(-) create mode 100644 .changeset/era-gate-explicit-schema-handlers.md diff --git a/.changeset/era-gate-explicit-schema-handlers.md b/.changeset/era-gate-explicit-schema-handlers.md new file mode 100644 index 0000000000..006026d419 --- /dev/null +++ b/.changeset/era-gate-explicit-schema-handlers.md @@ -0,0 +1,8 @@ +--- +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +Fixed the inbound era gate rejecting explicit-schema request handlers (`setRequestHandler(method, schemas, handler)`) for method names that a past protocol revision used for an unrelated core method, even though the current era's registry no longer defines that name at all. This made extension methods reusing a historical core name — like the Tasks extension's (SEP-2663) `tasks/get` and `tasks/cancel`, which collide with the 2025-11-25 core methods of the same name — permanently unreachable on the 2026-07-28 era: every inbound request answered `-32601 Method not found` before the registered handler was ever consulted, regardless of what the handler or its schema accepted. + +The era gate now only blocks methods registered through the typed `setRequestHandler(method, handler)` overload (the SDK's own built-ins, like `initialize` or `ping`, correctly keep answering by absence once an era moves past them). A method registered with an explicit schema is the extension-authoring path, and the consumer's own schema now takes precedence over the historical registry collision. diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index 01ae6f0542..4e9d343726 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -349,10 +349,17 @@ mismatch is rejected as an entry/routing error (`-32022 Unsupported protocol ver for requests; drop + `onerror` for notifications). Methods deleted by a protocol revision are **physically absent** from that era's -registry: an inbound `tasks/get` on a 2026-era connection gets `-32601` even if a -handler is registered, and sending an era-mismatched spec method (e.g. `server/discover` -toward a 2025-era peer, or any `tasks/*` method toward a 2026-era peer) throws -`SdkError(MethodNotSupportedByProtocolVersion)` before anything reaches the transport. +registry for TYPED dispatch: an inbound `tasks/get` handler registered via +`setRequestHandler('tasks/get', handler)` on a 2026-era connection still gets `-32601`, +and sending an era-mismatched spec method via the typed `request(method, options)` form +(e.g. `server/discover` toward a 2025-era peer, or any `tasks/*` method toward a 2026-era +peer) throws `SdkError(MethodNotSupportedByProtocolVersion)` before anything reaches the +transport. An EXPLICIT SCHEMA is the extension-authoring escape hatch and is exempt from +this gate in both directions — `setRequestHandler('tasks/get', { params, result }, +handler)` is reachable, and `request({ method: 'tasks/get', params }, +GetTaskResultSchema)` is sendable, on every era, so a historical core name an extension +reuses (e.g. the Tasks extension, SEP-2663) is never permanently blocked by a past +revision's registry entry. If you were on a v2 alpha and consumed wire schemas directly: @@ -700,9 +707,11 @@ methods at compile time. `ResultTypeMap['tools/call']` is plain `CallToolResult` maps still carry the `tasks/*` entries and the `CreateTaskResult` unions; narrow with the `isCallToolResult` guard if you are pinned to one of those alphas. `2.0.0-alpha.4` and later include the exclusion.) Where -task interop is genuinely required, use the explicit-schema custom-method form -(`request({ method: 'tasks/get', params }, GetTaskResultSchema)`). Inbound `tasks/*` -requests → `-32601`. +task interop is genuinely required, use the explicit-schema custom-method form on both +sides: `request({ method: 'tasks/get', params }, GetTaskResultSchema)` to send, and +`setRequestHandler('tasks/get', { params, result }, handler)` to serve — both reach the +wire/handler on every era, unlike the typed 2-arg overloads, which stay `-32601`/typed-error +gated for `tasks/*` on the 2026 era exactly like any other era-deleted spec method. The experimental tasks **interception** layer is removed entirely — see [upgrade-to-v2.md › Experimental tasks interception removed](./upgrade-to-v2.md#experimental-tasks-interception-removed). diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 59d7e7f95d..086a56931d 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -574,6 +574,16 @@ export abstract class Protocol { private _requestMiddleware: Array<{ method: string; middleware: RequestMiddleware }> = []; /** Extension result kinds accepted per method (see `acceptResultType`). */ private _acceptedResultTypes: Map> = new Map(); + /** + * Methods registered through the explicit-schema `setRequestHandler(method, schemas, + * handler)` overload — the consumer supplied their own params/result schema rather than + * relying on a spec-registry entry. A name in this set is exempt from the spec-universe + * era gate in `_onrequest` (see the comment there): the consumer explicitly declared how + * to validate the method, so a historical core name reused by an extension (e.g. + * `tasks/get`) is served by the registered handler even on an era whose registry no + * longer defines it as a core method. + */ + private _customSchemaRequestMethods = new Set(); private _requestHandlerAbortControllers: Map = new Map(); private _notificationHandlers: Map Promise> = new Map(); private _responseHandlers: Map void> = new Map(); @@ -1011,11 +1021,27 @@ export abstract class Protocol { // Era gate — deletions are physical: a spec method that is not in // this era's registry is −32601 BY ABSENCE, before any handler - // lookup, even when a handler is registered (a custom handler cannot - // shadow a deleted spec method across eras). Methods outside the - // spec universe are consumer-owned extension methods and stay - // era-blind. - if (isSpecRequestMethod(request.method) && !codec.hasRequestMethod(request.method)) { + // lookup, even when a TYPED spec handler is registered for it (a + // `setRequestHandler(method, handler)` registration cannot shadow a + // deleted spec method across eras — this is how `initialize` stays + // unreachable once a 2026-era connection has negotiated past it). + // Methods outside the spec universe are consumer-owned extension + // methods and stay era-blind. + // + // A name that IS in the spec universe but was registered through the + // EXPLICIT-SCHEMA overload (`setRequestHandler(method, schemas, + // handler)`, tracked in `_customSchemaRequestMethods`) is exempt: the + // consumer supplied their own schema rather than relying on the + // era-registry entry, which is exactly the extension-method + // authoring path — some extensions (e.g. the Tasks extension, + // SEP-2663) reuse a name that a past core revision also used for an + // unrelated core method, and that historical collision should not + // make the extension's own handler unreachable. + if ( + isSpecRequestMethod(request.method) && + !codec.hasRequestMethod(request.method) && + !this._customSchemaRequestMethods.has(request.method) + ) { sendErrorResponse(ProtocolErrorCode.MethodNotFound, 'Method not found'); return; } @@ -1285,10 +1311,16 @@ export abstract class Protocol { ): Promise>; request(request: Request, schemaOrOptions?: StandardSchemaV1 | RequestOptions, maybeOptions?: RequestOptions): Promise { const codec = this._resolveOutboundCodec(request.method); - this._assertOutboundRequestInEra(codec, request.method); if (isStandardSchema(schemaOrOptions)) { + // Explicit schema: the extension-authoring path, symmetric with the + // inbound `setRequestHandler(method, schemas, handler)` exemption + // (#2598) — a name a past era's registry used for an unrelated core + // method (e.g. the Tasks extension's `tasks/get`) must not block a + // consumer's own schema-driven call just because the CURRENT era's + // registry doesn't define it as a core method either. return this._requestWithSchemaViaCodec(codec, request, schemaOrOptions, maybeOptions); } + this._assertOutboundRequestInEra(codec, request.method); const validate = codecResultValidator(codec, request.method); if (validate === undefined) { throw new TypeError(`'${request.method}' is not a spec method; pass a result schema as the second argument to request().`); @@ -1337,7 +1369,11 @@ export abstract class Protocol { * directions: sending a spec method that the resolved era does not define * dies locally with a typed error before anything reaches the transport. * Methods outside the spec universe are consumer-owned extension methods - * and stay era-blind. + * and stay era-blind. Only applies to the TYPED dispatch path + * (`request(method, options)`, resolved via `codecResultValidator`) — the + * public `request()` overload skips this gate entirely when the caller + * passes an explicit result schema (mirrors the inbound exemption for + * `setRequestHandler(method, schemas, handler)`; see the comment there). */ private _assertOutboundRequestInEra(codec: WireCodec, method: string): void { if (isSpecRequestMethod(method) && !codec.hasRequestMethod(method)) { @@ -1781,6 +1817,15 @@ export abstract class Protocol { throw new TypeError('setRequestHandler: handler is required'); } + // Track explicit-schema registrations (see `_customSchemaRequestMethods`'s + // declaration) so the era gate can exempt them; a re-registration through the + // typed 2-arg overload reverts a method back to ordinary era gating. + if (typeof schemasOrHandler === 'function') { + this._customSchemaRequestMethods.delete(method); + } else { + this._customSchemaRequestMethods.add(method); + } + this._requestHandlers.set(method, this._wrapHandler(method, stored)); } @@ -1905,6 +1950,7 @@ export abstract class Protocol { */ removeRequestHandler(method: RequestMethod | string): void { this._requestHandlers.delete(method); + this._customSchemaRequestMethods.delete(method); } /** diff --git a/packages/core-internal/src/wire/codec.ts b/packages/core-internal/src/wire/codec.ts index 7672a8d64a..0510341eca 100644 --- a/packages/core-internal/src/wire/codec.ts +++ b/packages/core-internal/src/wire/codec.ts @@ -24,18 +24,29 @@ * * Deletions are physical: registry membership is the deletion story. The * 2026-era registry has no `tasks/*`, `initialize`, `ping`, `logging/setLevel`, - * `resources/(un)subscribe` or server→client wire-request entries, so an - * inbound era-mismatched method falls to −32601 by absence — even when a - * handler is registered — and an outbound one dies locally with a typed - * `SdkError` before anything reaches the transport. The 2025-era registry has - * no `server/discover`/`subscriptions/listen`/MRTR entries, symmetrically. + * `resources/(un)subscribe` or server→client wire-request entries, so a + * TYPED-dispatch era-mismatched method falls to −32601 by absence inbound + * (`setRequestHandler(method, handler)`) or dies locally with a typed + * `SdkError` outbound (`request(method, options)`), before anything reaches + * the transport either way. The 2025-era registry has no + * `server/discover`/`subscriptions/listen`/MRTR entries, symmetrically. * * Custom-handler shadowing policy (both directions): a method that belongs to * the SPEC-METHOD UNIVERSE — the union of every codec's registry, derived, - * not hand-curated — is ALWAYS era-gated, so a custom handler registered for - * a deleted spec method (e.g. `tasks/get`) serves it only on the era that - * defines it. Methods outside the universe are consumer-owned extension - * methods: they are era-blind and require explicit schemas, exactly as today. + * not hand-curated — is era-gated UNLESS the consumer supplied their own + * schema for it: inbound via the explicit-schema overload + * (`setRequestHandler(method, schemas, handler)`, tracked by + * `Protocol#_customSchemaRequestMethods`), outbound via `request(request, + * resultSchema, options)`. A TYPED spec handler/call for a deleted spec + * method (e.g. legacy `tasks/get`) still serves/sends only on the era that + * defines it — that's how `initialize` stays unreachable once a 2026-era + * connection has negotiated past it. But an explicit schema is the + * extension-authoring path, and some extensions (e.g. the Tasks extension, + * SEP-2663) intentionally reuse a name a past core revision used for an + * unrelated core method — the historical collision must not make the + * extension's own handler/call unreachable in either direction. Methods + * outside the spec universe were always consumer-owned extension methods: + * they are era-blind and require explicit schemas, exactly as today. * * Everything in `wire/` is internal to the bundled, `private: true` core — * nothing per-revision is public surface, and nothing here may ever be diff --git a/packages/core-internal/test/wire/eraGates.test.ts b/packages/core-internal/test/wire/eraGates.test.ts index fb2eb26d4e..6e3f04f072 100644 --- a/packages/core-internal/test/wire/eraGates.test.ts +++ b/packages/core-internal/test/wire/eraGates.test.ts @@ -11,9 +11,15 @@ * Registry membership is the deletion story, and these tests prove it at the * protocol funnels, in both directions: * - * - inbound: `tasks/get` on a modern-era instance gets −32601 BY ABSENCE — - * even with a handler registered (a custom handler cannot shadow a - * deleted spec method across eras); era-deleted spec notifications are + * - inbound: a TYPED spec handler (`setRequestHandler(method, handler)`) + * cannot shadow a deleted spec method across eras — `ping` on a + * modern-era instance still answers −32601 BY ABSENCE. An + * EXPLICIT-SCHEMA handler (`setRequestHandler(method, schemas, handler)`) + * for the same kind of name IS reachable regardless of era-registry + * absence — `tasks/get` served on a modern-era instance, because the + * consumer's own schema is the extension-authoring path (issue #2598: + * the Tasks extension, SEP-2663, reuses a name a past core revision also + * used for an unrelated core method). Era-deleted spec notifications are * silently dropped even with a handler registered. * - outbound: an era-mismatched spec method dies locally with * `SdkErrorCode.MethodNotSupportedByProtocolVersion` before anything @@ -109,31 +115,51 @@ const resultOf = (msg: JSONRPCMessage | undefined) => (msg as { result?: Record< describe('inbound era gates — deletions are physical, era is instance state', () => { const registerTasksGetHandler = (onRun: () => void) => (receiver: TestProtocol) => { - // A custom (3-arg) handler deliberately shadowing the deleted - // spec method: it may serve the 2025 era only. + // An explicit-schema (3-arg) handler: the consumer supplies their own + // schema for a name the era registry doesn't define as a core + // method, which is the extension-authoring path (#2598) — it is + // reachable on every era, not shadowed by the deletion gate. receiver.setRequestHandler('tasks/get', { params: z.looseObject({ taskId: z.string() }) }, () => { onRun(); return {} as Result; }); }; - test('a modern-era instance answers tasks/get with −32601 BY ABSENCE even with a handler registered', async () => { + test('a modern-era instance still serves tasks/get through an explicit-schema handler (#2598)', async () => { let handlerRan = false; const h = await harness({ era: '2026-07-28', setup: registerTasksGetHandler(() => (handlerRan = true)) }); // A matching modern classification rides along untouched — the - // handoff check accepts it; the era gate still answers by absence. + // handoff check accepts it; the era gate no longer answers by + // absence when an explicit-schema handler is registered. + h.deliver( + { jsonrpc: '2.0', id: 1, method: 'tasks/get', params: { taskId: 't-1', _meta: { ...ENVELOPE } } } as JSONRPCMessage, + MODERN + ); + await h.flush(); + + expect(handlerRan).toBe(true); + expect(resultOf(h.sent[0])).toBeDefined(); + }); + + test('a modern-era instance still answers −32601 BY ABSENCE for a deleted spec method with no handler registered', async () => { + const h = await harness({ era: '2026-07-28' }); + h.deliver( { jsonrpc: '2.0', id: 1, method: 'tasks/get', params: { taskId: 't-1', _meta: { ...ENVELOPE } } } as JSONRPCMessage, MODERN ); await h.flush(); - expect(handlerRan).toBe(false); expect(h.sent).toHaveLength(1); expect(errorOf(h.sent[0])).toMatchObject({ code: -32601, message: 'Method not found' }); }); + // The built-in `ping` handler (registered via the typed 2-arg overload + // in the `Protocol` constructor) demonstrates the typed path stays fully + // era-gated — see 'ping on a modern-era instance is −32601 by absence' + // below: only explicit-schema registrations are exempt. + test('a legacy-era instance (the default) serves tasks/get with that handler — era is fixed per instance', async () => { let handlerRan = false; const h = await harness({ setup: registerTasksGetHandler(() => (handlerRan = true)) }); @@ -534,6 +560,21 @@ describe('outbound era gates — typed local error before the transport', () => expect(h.sent).toHaveLength(0); }); + test('the public request() overload sends tasks/get with an explicit schema even on a 2026-era instance (#2598)', async () => { + const h = await harness({ era: '2026-07-28' }); + + const pending = h.receiver.request({ method: 'tasks/get', params: { taskId: 't-1' } }, z.looseObject({})); + await h.flush(); + + // Reached the transport (unlike the typed-dispatch case above, which + // never gets past the local era gate) — no peer is listening in this + // harness, so the request itself is left pending; asserting on + // `h.sent` is enough to prove it was NOT rejected locally. + expect(h.sent).toHaveLength(1); + expect(h.sent[0]).toMatchObject({ method: 'tasks/get', params: { taskId: 't-1' } }); + pending.catch(() => {}); + }); + test('pre-negotiation bootstrap pins still route initialize to the 2025 era', async () => { // An instance with NO negotiated version may always send the legacy // handshake; setting a modern version afterwards closes it (the pin From 1e182a55d4ff15b015eb0a26798749b39ec40a84 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 17 Sep 2026 09:23:00 +0200 Subject: [PATCH 2/6] feat(server): Tasks extension at @modelcontextprotocol/server/ext/tasks TasksExtension is the server side of io.modelcontextprotocol/tasks as a ServerExtension: it advertises the capability, serves tasks/get, tasks/update and tasks/cancel, gates task handles on the client capability (-32021, including through a tools/call override for handles minted outside the extension), and offers tasks.create(ctx) for a tool handler to answer with a task handle. TaskStore (create / get / update / cancel over JSON) is the seam a server implements over its own state and execution; how the work runs is the server's own. InMemoryTaskStore is the in-process reference with a writer handle (status, requireInput, complete, fail, cancel signal). Wire types and zod schemas for the extension's 2026-07-28 schema are exported. Stacked on the server extensions seam and on #2599 (explicit-schema handlers escape the era gate), which tasks/get and tasks/cancel need. --- .changeset/tasks-extension.md | 5 + docs/.vitepress/nav.ts | 1 + docs/servers/tasks.md | 78 ++++++ packages/server/package.json | 18 +- .../server/src/ext/tasks/inMemoryStore.ts | 254 ++++++++++++++++++ packages/server/src/ext/tasks/index.ts | 68 +++++ packages/server/src/ext/tasks/store.ts | 55 ++++ .../server/src/ext/tasks/tasksExtension.ts | 162 +++++++++++ packages/server/src/ext/tasks/wire/schemas.ts | 177 ++++++++++++ packages/server/src/ext/tasks/wire/types.ts | 220 +++++++++++++++ .../test/ext/tasks/inMemoryStore.test.ts | 82 ++++++ .../server/test/ext/tasks/tasks.e2e.test.ts | 208 ++++++++++++++ packages/server/tsconfig.json | 4 +- packages/server/tsdown.config.ts | 1 + 14 files changed, 1330 insertions(+), 3 deletions(-) create mode 100644 .changeset/tasks-extension.md create mode 100644 docs/servers/tasks.md create mode 100644 packages/server/src/ext/tasks/inMemoryStore.ts create mode 100644 packages/server/src/ext/tasks/index.ts create mode 100644 packages/server/src/ext/tasks/store.ts create mode 100644 packages/server/src/ext/tasks/tasksExtension.ts create mode 100644 packages/server/src/ext/tasks/wire/schemas.ts create mode 100644 packages/server/src/ext/tasks/wire/types.ts create mode 100644 packages/server/test/ext/tasks/inMemoryStore.test.ts create mode 100644 packages/server/test/ext/tasks/tasks.e2e.test.ts diff --git a/.changeset/tasks-extension.md b/.changeset/tasks-extension.md new file mode 100644 index 0000000000..c1233281c2 --- /dev/null +++ b/.changeset/tasks-extension.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': minor +--- + +New subpath `@modelcontextprotocol/server/ext/tasks`: the server side of the MCP Tasks extension (`io.modelcontextprotocol/tasks`) as a server extension. `new TasksExtension(store)` in `ServerOptions.extensions` advertises the capability, serves `tasks/get`, `tasks/update` and `tasks/cancel`, gates task handles on the client capability (`-32021`), and offers `tasks.create(ctx)` for a tool handler to answer with a task handle. `TaskStore` (create / get / update / cancel over JSON) is the seam 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. Wire types and zod schemas for the extension's 2026-07-28 schema are exported. diff --git a/docs/.vitepress/nav.ts b/docs/.vitepress/nav.ts index f1d5f96cdf..a288cb9ed4 100644 --- a/docs/.vitepress/nav.ts +++ b/docs/.vitepress/nav.ts @@ -27,6 +27,7 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [ { text: 'Elicitation', link: '/servers/elicitation' }, { text: 'Sampling (sunset)', link: '/servers/sampling' }, { text: 'Input required', link: '/servers/input-required' }, + { text: 'Tasks (extension)', link: '/servers/tasks' }, { text: 'Notifications', link: '/servers/notifications' }, { text: 'Errors', link: '/servers/errors' } ] diff --git a/docs/servers/tasks.md b/docs/servers/tasks.md new file mode 100644 index 0000000000..c58b197fd3 --- /dev/null +++ b/docs/servers/tasks.md @@ -0,0 +1,78 @@ +--- +shape: how-to +--- + +# Tasks (extension) + +The [MCP Tasks extension](https://github.com/modelcontextprotocol/ext-tasks) (`io.modelcontextprotocol/tasks`) lets a tool answer with a **task handle** instead of blocking on work that takes minutes: the client polls `tasks/get`, answers `tasks/update`, and stops with `tasks/cancel`. `@modelcontextprotocol/server/ext/tasks` is the server side of that wire, as a [server extension](../advanced/extensions.md). It owns the protocol; you own the task's state and its execution, behind a `TaskStore`. + +## Install the extension + +`TasksExtension` takes the store. `InMemoryTaskStore` is the in-process reference; a persistent store implements the same four methods. + +```ts +import { McpServer } from '@modelcontextprotocol/server'; +import { InMemoryTaskStore, TasksExtension } from '@modelcontextprotocol/server/ext/tasks'; + +const store = new InMemoryTaskStore(); +const tasks = new TasksExtension(store); + +const server = new McpServer({ name: 'report-server', version: '1.0.0' }, { extensions: [tasks] }); +``` + +The server advertises `io.modelcontextprotocol/tasks` under `capabilities.extensions` and serves `tasks/get`, `tasks/update` and `tasks/cancel`. + +## Answer a tool call with a task + +Inside a tool handler, `tasks.create(ctx)` creates the task in the store, bound to the request's principal, and returns the handle the handler answers with. The work itself is yours to start however your server runs things — here, an async function driving the in-memory store's writer handle. + +```ts +import * as z from 'zod/v4'; + +server.registerTool('send_report', { inputSchema: z.object({ to: z.string() }) }, async ({ to }, ctx) => { + const task = await tasks.create(ctx, { ttlMs: 3_600_000 }); + void sendReport(store.handle(task.taskId), to); + return task; +}); + +async function sendReport(handle: TaskHandle, to: string) { + await handle.status('compiling'); + const report = await compile(to); + const answers = await handle.requireInput({ + approve: { method: 'elicitation/create', params: { message: `send ${report.pages} pages to ${to}?`, mode: 'form', requestedSchema: { type: 'object', properties: {} } } } + }); + if (answers.approve?.action !== 'accept' || handle.signal.aborted) return handle.complete({ content: [{ type: 'text', text: 'not sent' }] }); + await deliver(report, to); + await handle.complete({ content: [{ type: 'text', text: `report sent to ${to}` }] }); +} +``` + +On the wire the tool answers a flat `CreateTaskResult` (`resultType: "task"`). `requireInput` moves the task to `input_required` and resolves once `tasks/update` has answered every key. `tasks/cancel` aborts `handle.signal`; a later `complete` or `fail` is ignored. + +## Bring your own store + +`TaskStore` is four methods over JSON: + +| Method | Contract | +| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `create(params)` | Durably create; MUST NOT resolve before a following `get` would succeed. `params.context` is yours (tool name, workflow id…). | +| `get(taskId, access?)` | The `DetailedTask` snapshot, or `undefined` for unknown, expired, or foreign-principal tasks. | +| `update(taskId, inputResponses, access?)` | Deliver answers; `false` when the task is not found. Unknown keys are ignored, partial answers accepted. | +| `cancel(taskId, access?)` | Cooperative; resolves on acknowledgement. Idempotent on terminal tasks. | + +How the work behind a task runs — a queue, a workflow engine, a durable-execution runtime — is invisible to the SDK. The store is also where a writer API lives if your execution needs one; `InMemoryTaskStore.handle` is the reference shape. + +## Wire notes + +- The extension is served on the 2026-07-28 revision. Task tools are ordinary tools without `outputSchema`; the encode seam 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` override for any handle minted some other way. +- `tasks/update`'s `inputResponses` shares its name with the multi-round-trip retry field: the protocol layer lifts it out of the params and the extension reads it back from `ctx.mcpReq.inputResponses`. +- The SDK `Client` rejects `resultType: "task"` on `tools/call` (typescript-sdk#2637); the requester half of the extension is `@modelcontextprotocol/ext-tasks`. +- `notifications/tasks` over `subscriptions/listen` is not implemented (typescript-sdk#2569); polling only. + +## Recap + +- `new TasksExtension(store)` in `ServerOptions.extensions` serves the extension. +- `tasks.create(ctx, options?)` in a tool handler returns the task handle to answer with. +- `TaskStore` is create / get / update / cancel; `InMemoryTaskStore` is the reference and adds a writer `handle`. +- Execution is the server's; the SDK only defines the API shape. diff --git a/packages/server/package.json b/packages/server/package.json index f481e019e7..81a1564504 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -40,6 +40,16 @@ "default": "./dist/stdio.cjs" } }, + "./ext/tasks": { + "import": { + "types": "./dist/ext/tasks/index.d.mts", + "default": "./dist/ext/tasks/index.mjs" + }, + "require": { + "types": "./dist/ext/tasks/index.d.cts", + "default": "./dist/ext/tasks/index.cjs" + } + }, "./validators/ajv": { "import": { "types": "./dist/validators/ajv.d.mts", @@ -115,6 +125,9 @@ ], "stdio": [ "dist/stdio.d.mts" + ], + "ext/tasks": [ + "dist/ext/tasks/index.d.mts" ] } }, @@ -138,9 +151,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:^", @@ -148,6 +160,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..d5d615a8f6 --- /dev/null +++ b/packages/server/src/ext/tasks/inMemoryStore.ts @@ -0,0 +1,254 @@ +/** + * `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 { CreateTaskParams, TaskAccess, TaskStore } from './store'; +import type { DetailedTask, Task, TaskStatus } from './wire/types'; + +interface PendingInput { + requests: InputRequests; + responses: InputResponses; + resolve: (responses: InputResponses) => void; +} + +interface TaskRecord { + taskId: string; + principal?: string; + context?: Record; + status: TaskStatus; + statusMessage?: string; + createdAt: number; + lastUpdatedAt: number; + ttlMs: number | null; + pollIntervalMs?: number; + result?: CallToolResult; + error?: { code: number; message: string; data?: unknown }; + pendingInput?: PendingInput; + abort: AbortController; + ttlTimer?: ReturnType; +} + +const TERMINAL: ReadonlySet = new Set(['completed', 'failed', 'cancelled']); + +/** + * The writer side of a task, for the code that does the work. Obtained from + * {@link InMemoryTaskStore.handle}; a persistent store exposes its own + * equivalent (or none, when the work reports through the store directly). + */ +export interface TaskHandle { + readonly taskId: string; + /** Aborted when `tasks/cancel` is acknowledged. */ + readonly signal: AbortSignal; + /** Writes `statusMessage` for pollers. */ + status(message: string): Promise; + /** + * Moves the task to `input_required` with `requests` outstanding and + * resolves once `tasks/update` has answered every key (partial updates + * accumulate). Rejects if the task is cancelled while waiting. + */ + requireInput(requests: InputRequests): Promise; + complete(result: CallToolResult): Promise; + fail(error: { code: number; message: string; data?: unknown }): Promise; +} + +/** Options for {@link InMemoryTaskStore}. */ +export interface InMemoryTaskStoreOptions { + /** Task id factory. Default `crypto.randomUUID()`. */ + createTaskId?: () => string; + /** Clock, for tests. Default `Date.now`. */ + now?: () => number; +} + +export class InMemoryTaskStore implements TaskStore { + readonly #tasks = new Map(); + readonly #createTaskId: () => string; + readonly #now: () => number; + + constructor(options?: InMemoryTaskStoreOptions) { + this.#createTaskId = options?.createTaskId ?? (() => crypto.randomUUID()); + this.#now = options?.now ?? (() => Date.now()); + } + + /** Clears every timer and forgets every task. For tests and shutdown. */ + close(): void { + for (const record of this.#tasks.values()) { + if (record.ttlTimer !== undefined) clearTimeout(record.ttlTimer); + record.abort.abort(); + } + this.#tasks.clear(); + } + + async create(params: CreateTaskParams): Promise { + if (params.ttlMs !== null && (!Number.isSafeInteger(params.ttlMs) || params.ttlMs < 0)) { + throw new RangeError(`ttlMs must be a non-negative integer or null, got ${params.ttlMs}`); + } + if (params.pollIntervalMs !== undefined && (!Number.isSafeInteger(params.pollIntervalMs) || params.pollIntervalMs < 0)) { + throw new RangeError(`pollIntervalMs must be a non-negative integer, got ${params.pollIntervalMs}`); + } + const now = this.#now(); + const record: TaskRecord = { + taskId: this.#createTaskId(), + ...(params.principal !== undefined && { principal: params.principal }), + ...(params.context !== undefined && { context: params.context }), + status: 'working', + createdAt: now, + lastUpdatedAt: now, + ttlMs: params.ttlMs, + ...(params.pollIntervalMs !== undefined && { pollIntervalMs: params.pollIntervalMs }), + abort: new AbortController() + }; + if (record.ttlMs !== null) { + record.ttlTimer = setTimeout(() => this.#purge(record), record.ttlMs); + record.ttlTimer.unref?.(); + } + this.#tasks.set(record.taskId, record); + return this.#snapshot(record); + } + + async get(taskId: string, access?: TaskAccess): Promise { + const record = this.#lookup(taskId, access); + return record === undefined ? undefined : this.#detailed(record); + } + + async update(taskId: string, inputResponses: InputResponses, access?: TaskAccess): Promise { + const record = this.#lookup(taskId, access); + if (record === undefined) return false; + const pending = record.pendingInput; + if (pending === undefined || TERMINAL.has(record.status)) return true; + for (const [key, response] of Object.entries(inputResponses)) { + // Unknown keys are ignored; the first answer to a key wins. + if (key in pending.requests && !(key in pending.responses)) pending.responses[key] = response; + } + this.#touch(record); + if (Object.keys(pending.requests).every(key => key in pending.responses)) { + record.pendingInput = undefined; + record.status = 'working'; + pending.resolve(pending.responses); + } + return true; + } + + async cancel(taskId: string, access?: TaskAccess): Promise { + const record = this.#lookup(taskId, access); + if (record === undefined) return false; + if (TERMINAL.has(record.status)) return true; + record.status = 'cancelled'; + record.pendingInput = undefined; + this.#touch(record); + record.abort.abort(); + return true; + } + + /** The context recorded at creation, for the server's execution to pick the work up. */ + context(taskId: string): Record | undefined { + return this.#tasks.get(taskId)?.context; + } + + /** The writer handle for a task the store holds. Throws for an unknown task. */ + handle(taskId: string): TaskHandle { + const record = this.#tasks.get(taskId); + if (record === undefined) throw new Error(`Task "${taskId}" not found`); + const live = (): TaskRecord | undefined => { + const current = this.#tasks.get(taskId); + return current === undefined || TERMINAL.has(current.status) ? undefined : current; + }; + return { + taskId, + signal: record.abort.signal, + status: async message => { + const current = live(); + if (current === undefined) return; + current.statusMessage = message; + this.#touch(current); + }, + requireInput: requests => { + const current = live(); + if (current === undefined) return Promise.reject(new Error(`Task "${taskId}" is no longer running`)); + return new Promise((resolve, reject) => { + current.pendingInput = { requests, responses: {}, resolve }; + current.status = 'input_required'; + this.#touch(current); + current.abort.signal.addEventListener('abort', () => reject(new Error(`Task "${taskId}" was cancelled`)), { + once: true + }); + }); + }, + complete: async result => { + const current = live(); + if (current === undefined) return; + current.status = 'completed'; + current.result = result; + this.#touch(current); + }, + fail: async error => { + const current = live(); + if (current === undefined) return; + current.status = 'failed'; + current.error = error; + this.#touch(current); + } + }; + } + + // ------------------------------------------------------------- internals -- + + #lookup(taskId: string, access: TaskAccess | undefined): TaskRecord | undefined { + const record = this.#tasks.get(taskId); + if (record === undefined) return undefined; + if (record.principal !== undefined && record.principal !== access?.principal) return undefined; + return record; + } + + #touch(record: TaskRecord): void { + record.lastUpdatedAt = this.#now(); + } + + #purge(record: TaskRecord): void { + record.abort.abort(); + this.#tasks.delete(record.taskId); + } + + #snapshot(record: TaskRecord): Task { + return { + taskId: record.taskId, + status: record.status, + ...(record.statusMessage !== undefined && { statusMessage: record.statusMessage }), + createdAt: new Date(record.createdAt).toISOString(), + lastUpdatedAt: new Date(record.lastUpdatedAt).toISOString(), + ttlMs: record.ttlMs, + ...(record.pollIntervalMs !== undefined && { pollIntervalMs: record.pollIntervalMs }) + }; + } + + #detailed(record: TaskRecord): DetailedTask { + const base = this.#snapshot(record); + switch (record.status) { + case 'working': { + return { ...base, status: 'working' }; + } + case 'input_required': { + const pending = record.pendingInput; + const inputRequests: InputRequests = {}; + for (const [key, request] of Object.entries(pending?.requests ?? {})) { + if (!(key in (pending?.responses ?? {}))) inputRequests[key] = request; + } + return { ...base, status: 'input_required', inputRequests }; + } + case 'completed': { + return { ...base, status: 'completed', result: (record.result ?? { content: [] }) as { [key: string]: unknown } }; + } + case 'failed': { + return { ...base, status: 'failed', error: record.error ?? { code: -32_603, message: 'Task failed' } }; + } + case 'cancelled': { + return { ...base, status: 'cancelled' }; + } + } + } +} diff --git a/packages/server/src/ext/tasks/index.ts b/packages/server/src/ext/tasks/index.ts new file mode 100644 index 0000000000..87891639b4 --- /dev/null +++ b/packages/server/src/ext/tasks/index.ts @@ -0,0 +1,68 @@ +/** + * `@modelcontextprotocol/server/ext/tasks` — the server side of the MCP + * Tasks extension (`io.modelcontextprotocol/tasks`). + * + * `TasksExtension` owns the wire: capability, `tasks/get` / `tasks/update` / + * `tasks/cancel`, the client-capability check, and the `tools/call` task + * handle. A `TaskStore` owns the task: `InMemoryTaskStore` is the + * in-process reference; durable stores implement the same four methods and + * live outside the SDK. How the work behind a task runs is the server's + * own. + */ + +export type { InMemoryTaskStoreOptions, TaskHandle } from './inMemoryStore'; +export { InMemoryTaskStore } from './inMemoryStore'; +export type { CreateTaskParams, TaskAccess, TaskStore } from './store'; +export type { CreateTaskOptions, TasksExtensionOptions, TaskToolResult } from './tasksExtension'; +export { declaresTasksExtension, TasksExtension } from './tasksExtension'; +export { + cancelledTaskSchema, + cancelTaskParamsSchema, + cancelTaskRequestSchema, + cancelTaskResultSchema, + completedTaskSchema, + createTaskResultSchema, + detailedTaskSchema, + failedTaskSchema, + getTaskParamsSchema, + getTaskRequestSchema, + getTaskResultSchema, + inputRequestSchema, + inputRequestsSchema, + inputRequiredTaskSchema, + inputResponseSchema, + inputResponsesSchema, + taskSchema, + tasksExtensionCapabilitySchema, + taskStatusSchema, + updateTaskParamsSchema, + updateTaskRequestSchema, + updateTaskResultSchema, + workingTaskSchema +} from './wire/schemas'; +export type { + CancelledTask, + CancelTaskParams, + CancelTaskRequest, + CancelTaskResult, + CompletedTask, + CreateTaskResult, + DetailedTask, + FailedTask, + GetTaskParams, + GetTaskRequest, + GetTaskResult, + InputRequest, + InputRequests, + InputRequiredTask, + InputResponse, + InputResponses, + Task, + TasksExtensionCapability, + TaskStatus, + UpdateTaskParams, + UpdateTaskRequest, + UpdateTaskResult, + WorkingTask +} from './wire/types'; +export { TASK_STATUSES, TASKS_EXTENSION_ID } from './wire/types'; diff --git a/packages/server/src/ext/tasks/store.ts b/packages/server/src/ext/tasks/store.ts new file mode 100644 index 0000000000..4d54c477df --- /dev/null +++ b/packages/server/src/ext/tasks/store.ts @@ -0,0 +1,55 @@ +/** + * The store seam 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 './wire/types'; + +/** What `TasksExtension.create` asks a store to durably create. */ +export interface CreateTaskParams { + /** Retention from creation, ms; `null` = unlimited. */ + ttlMs: number | null; + /** Suggested client polling interval, ms; omitted = no hint on the task. */ + pollIntervalMs?: number; + /** + * Auth binding: the principal the creating request was authenticated as, + * when the transport knows one. Stores MUST refuse `tasks/*` access from + * a different principal (fail closed, no existence leak) and MAY ignore + * it when absent. + */ + principal?: string; + /** + * Free-form, store-defined. The originating tool name and arguments, a + * workflow id — whatever the server's execution needs to find or start + * the work. Opaque to the SDK. + */ + context?: Record; +} + +/** The caller identity presented on a `tasks/*` request. */ +export interface TaskAccess { + principal?: string; +} + +/** + * A task store. Engine selection is configuration: the extension and the + * tool handlers that create tasks are byte-identical across stores. + * + * - `create` MUST NOT resolve before a subsequent `get(taskId)` would succeed + * (durable creation, a Tasks extension rule). + * - `get`, `update`, `cancel` resolve `undefined` / `false` for an unknown, + * expired, or foreign-principal task; the extension answers `-32602`. + * - `cancel` is cooperative: it resolves on acknowledgement, the task may + * still settle `completed` or `failed`. Idempotent on terminal tasks. + */ +export interface TaskStore { + create(params: CreateTaskParams): Promise; + get(taskId: string, access?: TaskAccess): Promise; + update(taskId: string, inputResponses: InputResponses, access?: TaskAccess): Promise; + cancel(taskId: string, access?: TaskAccess): Promise; +} diff --git a/packages/server/src/ext/tasks/tasksExtension.ts b/packages/server/src/ext/tasks/tasksExtension.ts new file mode 100644 index 0000000000..4b6de30514 --- /dev/null +++ b/packages/server/src/ext/tasks/tasksExtension.ts @@ -0,0 +1,162 @@ +/** + * `TasksExtension` — the server side of the MCP Tasks extension + * (`io.modelcontextprotocol/tasks`) as a {@linkcode ServerExtension}. It + * owns the wire: the capability, the per-request client-capability check, + * the `tasks/get`, `tasks/update` and `tasks/cancel` methods, and the + * `tools/call` result shape. Everything about the task itself — where its + * state lives, how its work runs — is behind the {@link TaskStore} the + * server passes in. + * + * ```ts + * const store = new InMemoryTaskStore(); + * const tasks = new TasksExtension(store); + * const server = new McpServer(info, { extensions: [tasks] }); + * + * server.registerTool('send_report', { inputSchema }, async (input, ctx) => { + * const task = await tasks.create(ctx, { ttlMs: 3_600_000 }); + * void runReport(store.handle(task.taskId), input); // the server's own execution + * return task; + * }); + * ``` + */ + +import type { CallToolResult, InputResponses, JSONRPCRequest, Result, ServerContext } from '@modelcontextprotocol/core-internal'; +import { + CLIENT_CAPABILITIES_META_KEY, + MissingRequiredClientCapabilityError, + ProtocolError, + ProtocolErrorCode +} from '@modelcontextprotocol/core-internal'; + +import type { ServerExtension } from '../../server/extension'; +import type { Server } from '../../server/server'; +import type { CreateTaskParams, TaskStore } from './store'; +import { cancelTaskParamsSchema, getTaskParamsSchema, inputResponsesSchema } from './wire/schemas'; +import type { CancelTaskResult, CreateTaskResult, GetTaskResult, UpdateTaskResult } from './wire/types'; +import { TASKS_EXTENSION_ID } from './wire/types'; + +/** Options for {@link TasksExtension}. */ +export interface TasksExtensionOptions { + /** Default retention for `create` when the caller gives none, ms; `null` = unlimited. Default 86_400_000 (24h). */ + defaultTtlMs?: number | null; + /** Default polling hint for `create` when the caller gives none, ms. Default 5_000. */ + defaultPollIntervalMs?: number; +} + +/** What a tool handler passes to {@link TasksExtension.create}; every field optional. */ +export type CreateTaskOptions = Partial>; + +/** + * The task handle a tool handler returns in place of a `CallToolResult`. + * The 2026-07-28 encode seam forwards `resultType: "task"` for `tools/call` + * verbatim, so the flat handle is the wire result. + */ +export type TaskToolResult = CallToolResult & CreateTaskResult; + +const isRecord = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value); + +/** Whether this request's `_meta` envelope declared the tasks extension. */ +export const declaresTasksExtension = (ctx: ServerContext): boolean => { + const envelope = ctx.mcpReq.envelope as Record | undefined; + const capabilities = envelope?.[CLIENT_CAPABILITIES_META_KEY]; + if (!isRecord(capabilities)) return false; + const extensions = capabilities['extensions']; + return isRecord(extensions) && TASKS_EXTENSION_ID in extensions; +}; + +const requireTasksExtension = (ctx: ServerContext, what: string): void => { + if (declaresTasksExtension(ctx)) return; + throw new MissingRequiredClientCapabilityError( + { requiredCapabilities: { extensions: { [TASKS_EXTENSION_ID]: {} } } }, + `${what} requires the request to declare the "${TASKS_EXTENSION_ID}" extension capability` + ); +}; + +const principalOf = (ctx: ServerContext): string | undefined => ctx.http?.authInfo?.clientId; + +const notFound = (): never => { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Task not found'); +}; + +/** + * `tasks/update` params as the HANDLER sees them. `inputResponses` is a + * reserved multi-round-trip name on the 2026-07-28 revision: the protocol + * layer lifts it out of every client request's params before dispatch and + * surfaces it at `ctx.mcpReq.inputResponses`. The wire schema + * (`updateTaskParamsSchema`) keeps the field required; here it is optional + * and read back from the context. + */ +const updateTaskHandlerParamsSchema = getTaskParamsSchema.extend({ inputResponses: inputResponsesSchema.optional() }); + +export class TasksExtension implements ServerExtension { + readonly id = TASKS_EXTENSION_ID; + readonly capability = {}; + readonly store: TaskStore; + readonly #defaultTtlMs: number | null; + readonly #defaultPollIntervalMs: number; + + constructor(store: TaskStore, options?: TasksExtensionOptions) { + this.store = store; + this.#defaultTtlMs = options?.defaultTtlMs === undefined ? 86_400_000 : options.defaultTtlMs; + this.#defaultPollIntervalMs = options?.defaultPollIntervalMs ?? 5000; + } + + /** + * Creates a task for the current `tools/call` and returns the handle the + * tool handler answers with. Refuses (`-32021`) when the request did not + * declare the extension; binds the task to the request's principal when + * the transport knows one. + */ + async create(ctx: ServerContext, options?: CreateTaskOptions): Promise { + requireTasksExtension(ctx, `Tool "${ctx.mcpReq.method}" executes as a task and`); + const principal = principalOf(ctx); + const task = await this.store.create({ + ttlMs: options?.ttlMs === undefined ? this.#defaultTtlMs : options.ttlMs, + pollIntervalMs: options?.pollIntervalMs ?? this.#defaultPollIntervalMs, + ...(principal !== undefined && { principal }), + ...(options?.context !== undefined && { context: options.context }) + }); + const result: CreateTaskResult = { resultType: 'task', ...task }; + return result as TaskToolResult; + } + + install(server: Server): void { + server.setRequestHandler('tasks/get', { params: getTaskParamsSchema }, async (params, ctx): Promise => { + requireTasksExtension(ctx, 'tasks/get'); + const task = await this.store.get(params.taskId, { principal: principalOf(ctx) }); + if (task === undefined) return notFound(); + return { resultType: 'complete', ...task }; + }); + + server.setRequestHandler( + 'tasks/update', + { params: updateTaskHandlerParamsSchema }, + async (params, ctx): Promise => { + requireTasksExtension(ctx, 'tasks/update'); + const inputResponses = (ctx.mcpReq.inputResponses ?? params.inputResponses ?? {}) as InputResponses; + const found = await this.store.update(params.taskId, inputResponses, { principal: principalOf(ctx) }); + if (!found) return notFound(); + return { resultType: 'complete' }; + } + ); + + server.setRequestHandler('tasks/cancel', { params: cancelTaskParamsSchema }, async (params, ctx): Promise => { + requireTasksExtension(ctx, 'tasks/cancel'); + const found = await this.store.cancel(params.taskId, { principal: principalOf(ctx) }); + if (!found) return notFound(); + return { resultType: 'complete' }; + }); + + // A task handle may only be answered to a client that can consume it. + // `create(ctx, …)` checks up front; this catches handles minted some + // other way (an external engine's own create) — the extension owns + // the wire regardless of where the task came from. + server.overrideRequestHandler('tools/call', async (request: JSONRPCRequest, ctx, next): Promise => { + const result = await next(request, ctx); + if ((result as { resultType?: unknown }).resultType === 'task') { + requireTasksExtension(ctx, `Tool "${String((request.params as { name?: unknown })?.name)}" executes as a task and`); + } + return result; + }); + } +} diff --git a/packages/server/src/ext/tasks/wire/schemas.ts b/packages/server/src/ext/tasks/wire/schemas.ts new file mode 100644 index 0000000000..93639821b6 --- /dev/null +++ b/packages/server/src/ext/tasks/wire/schemas.ts @@ -0,0 +1,177 @@ +/* + * Hand-written zod v4 schemas for the MCP Tasks extension wire types + * (./types). Upstream commits only generated JSON Schema + * (`schema/draft/schema.json`, vendored at `test/fixtures/ext-tasks.schema.json` + * as the conformance fixture); these runtime schemas are authored against + * modelcontextprotocol/ext-tasks pinned at commit dcc8d2b (SEP-2663 Final). + * https://github.com/modelcontextprotocol/ext-tasks + * + * Deliberate deviations from the generated fixture, which reflects the + * pre-envelope TS source rather than the wire: + * - Objects are loose (unknown keys pass through) where the fixture says + * `additionalProperties: false`: modern responses carry `resultType` and + * `_meta`, and modern request params carry the `_meta` envelope. + * - `resultType` literals are REQUIRED on result schemas (spec MUST; the + * fixture omits the field entirely). + * - `InputRequest`/`InputResponse` get minimal structural checks (the fixture + * degenerates them to `anyOf [{}, {}, {}]`). + * + * Copyright (c) Model Context Protocol contributors + */ + +import { z } from 'zod'; + +import { TASK_STATUSES } from './types'; + +/** `TaskStatus` */ +export const taskStatusSchema = z.enum(TASK_STATUSES); + +const metaSchema = z.record(z.string(), z.unknown()); + +/** + * An embedded (de-JSON-RPC'd) input request: an elicitation, sampling, or + * roots request object (`{method, params}`), validated structurally. + */ +export const inputRequestSchema = z.looseObject({ + method: z.string(), + params: z.record(z.string(), z.unknown()).optional() +}); + +/** An embedded input response: the bare result object for its request. */ +export const inputResponseSchema = z.record(z.string(), z.unknown()); + +/** `InputRequests` — keyed by identifiers unique over the task's lifetime. */ +export const inputRequestsSchema = z.record(z.string(), inputRequestSchema); + +/** `InputResponses` — keys correspond to outstanding inputRequest keys. */ +export const inputResponsesSchema = z.record(z.string(), inputResponseSchema); + +const taskShape = { + taskId: z.string(), + status: taskStatusSchema, + statusMessage: z.string().optional(), + createdAt: z.string(), + lastUpdatedAt: z.string(), + ttlMs: z.int().nullable(), + pollIntervalMs: z.int().optional() +}; + +/** `Task` */ +export const taskSchema = z.looseObject(taskShape); + +/** `WorkingTask` */ +export const workingTaskSchema = z.looseObject({ + ...taskShape, + status: z.literal('working') +}); + +/** `InputRequiredTask` */ +export const inputRequiredTaskSchema = z.looseObject({ + ...taskShape, + status: z.literal('input_required'), + inputRequests: inputRequestsSchema +}); + +/** `CompletedTask` — the original request's result structure inlined. */ +export const completedTaskSchema = z.looseObject({ + ...taskShape, + status: z.literal('completed'), + result: z.record(z.string(), z.unknown()) +}); + +/** `FailedTask` — the JSON-RPC error object inlined. */ +export const failedTaskSchema = z.looseObject({ + ...taskShape, + status: z.literal('failed'), + error: z.record(z.string(), z.unknown()) +}); + +/** `CancelledTask` */ +export const cancelledTaskSchema = z.looseObject({ + ...taskShape, + status: z.literal('cancelled') +}); + +/** `DetailedTask` — discriminated on `status`. */ +export const detailedTaskSchema = z.discriminatedUnion('status', [ + workingTaskSchema, + inputRequiredTaskSchema, + completedTaskSchema, + failedTaskSchema, + cancelledTaskSchema +]); + +/** `CreateTaskResult` — flat `Result & Task` with `resultType: "task"` (MUST). */ +export const createTaskResultSchema = z.looseObject({ + ...taskShape, + resultType: z.literal('task'), + _meta: metaSchema.optional() +}); + +const completeResultShape = { + resultType: z.literal('complete'), + _meta: metaSchema.optional() +}; + +/** `GetTaskResult` — a `DetailedTask` variant with `resultType: "complete"` (MUST). */ +export const getTaskResultSchema = z.discriminatedUnion('status', [ + workingTaskSchema.extend(completeResultShape), + inputRequiredTaskSchema.extend(completeResultShape), + completedTaskSchema.extend(completeResultShape), + failedTaskSchema.extend(completeResultShape), + cancelledTaskSchema.extend(completeResultShape) +]); + +/** `UpdateTaskResult` — empty ack with `resultType: "complete"` (MUST). */ +export const updateTaskResultSchema = z.looseObject(completeResultShape); + +/** `CancelTaskResult` — empty ack with `resultType: "complete"` (MUST). */ +export const cancelTaskResultSchema = z.looseObject(completeResultShape); + +const requestIdSchema = z.union([z.string(), z.int()]); + +/** `tasks/get` params (`_meta` carries the modern per-request envelope). */ +export const getTaskParamsSchema = z.looseObject({ + taskId: z.string(), + _meta: metaSchema.optional() +}); + +/** `GetTaskRequest` */ +export const getTaskRequestSchema = z.looseObject({ + jsonrpc: z.literal('2.0'), + id: requestIdSchema, + method: z.literal('tasks/get'), + params: getTaskParamsSchema +}); + +/** `tasks/update` params (`_meta` carries the modern per-request envelope). */ +export const updateTaskParamsSchema = z.looseObject({ + taskId: z.string(), + inputResponses: inputResponsesSchema, + _meta: metaSchema.optional() +}); + +/** `UpdateTaskRequest` */ +export const updateTaskRequestSchema = z.looseObject({ + jsonrpc: z.literal('2.0'), + id: requestIdSchema, + method: z.literal('tasks/update'), + params: updateTaskParamsSchema +}); + +/** `tasks/cancel` params (`_meta` carries the modern per-request envelope). */ +export const cancelTaskParamsSchema = z.looseObject({ + taskId: z.string(), + _meta: metaSchema.optional() +}); + +/** `CancelTaskRequest` */ +export const cancelTaskRequestSchema = z.looseObject({ + jsonrpc: z.literal('2.0'), + id: requestIdSchema, + method: z.literal('tasks/cancel'), + params: cancelTaskParamsSchema +}); + +/** `TasksExtensionCapability` — an empty object declares support. */ +export const tasksExtensionCapabilitySchema = z.strictObject({}); diff --git a/packages/server/src/ext/tasks/wire/types.ts b/packages/server/src/ext/tasks/wire/types.ts new file mode 100644 index 0000000000..8bc6a133ce --- /dev/null +++ b/packages/server/src/ext/tasks/wire/types.ts @@ -0,0 +1,220 @@ +/* + * MCP Tasks extension wire types (extension id: io.modelcontextprotocol/tasks). + * + * Adapted from modelcontextprotocol/ext-tasks, pinned at commit dcc8d2b + * (SEP-2663 Final): `schema/draft/schema.ts`, re-based on + * `@modelcontextprotocol/server` v2 types — the modern SDK ships the MRTR + * `InputRequest`/`InputResponse` unions the upstream file's TODOs point at, so + * those are re-exported rather than re-declared. The JSON-RPC request shapes + * are declared standalone (not extending the SDK's `JSONRPCRequest`) so the + * wire contract stays pinned to this file. `notifications/tasks` and the + * subscription additions are omitted: v1 is polling-only (the engine contract)). + * + * This module (with ./schemas) is the ONLY import source for task wire types + * in this repo — the SDK's deprecated 2025-11-25 task exports (`Task`, + * `CreateTaskResult`, `TaskStatus`, `GetTaskRequest`, ...) carry the removed + * legacy wire shape and must not be used (the engine contract)). + * https://github.com/modelcontextprotocol/ext-tasks + * + * Copyright (c) Model Context Protocol contributors + */ + +import type { InputRequests, InputResponses, Result } from '@modelcontextprotocol/core-internal'; + +/** + * A single input request / response embedded in a task, re-based on the SDK + * v2 MRTR unions (sampling, roots, or elicitation). Keys in the containing + * maps MUST be unique over the lifetime of a single task. + */ +export type { InputRequest, InputRequests, InputResponse, InputResponses } from '@modelcontextprotocol/core-internal'; + +/** The MCP Tasks extension identifier. An empty-object capability declares support. */ +export const TASKS_EXTENSION_ID = 'io.modelcontextprotocol/tasks'; + +/** All task statuses, in lifecycle order (terminal states last). */ +export const TASK_STATUSES = ['working', 'input_required', 'completed', 'failed', 'cancelled'] as const; + +/** The status of a task. */ +export type TaskStatus = (typeof TASK_STATUSES)[number]; + +/** Data associated with a task. */ +export interface Task { + /** The task identifier. */ + taskId: string; + + /** Current task status. */ + status: TaskStatus; + + /** + * Optional human-readable message describing the current task state: + * progress descriptions for "working", blocked work for "input_required", + * reasons for "cancelled", summaries for "completed", diagnostics for + * "failed". + */ + statusMessage?: string; + + /** ISO 8601 timestamp when the task was created. */ + createdAt: string; + + /** ISO 8601 timestamp when the task was last updated. */ + lastUpdatedAt: string; + + /** + * Time-to-live duration from creation in integer milliseconds, null for + * unlimited. The server may discard the task after the TTL elapses. This + * value MAY change over the lifetime of a task. + */ + ttlMs: number | null; + + /** + * Suggested polling interval in integer milliseconds. Clients SHOULD honor + * this value to avoid overwhelming the server. This value MAY change over + * the lifetime of a task. + */ + pollIntervalMs?: number; +} + +/** A task that is in a normal working state. */ +export interface WorkingTask extends Task { + status: 'working'; +} + +/** A task that is waiting for input from the client. */ +export interface InputRequiredTask extends Task { + status: 'input_required'; + + /** + * Server-to-client requests that need to be fulfilled during task + * execution. Keys are arbitrary identifiers for matching requests to + * responses. + */ + inputRequests: InputRequests; +} + +/** A task that has completed successfully. */ +export interface CompletedTask extends Task { + status: 'completed'; + + /** + * The final result of the task. The structure matches the result type of + * the original request — for a `tools/call` task, the `CallToolResult` + * structure. + */ + result: { [key: string]: unknown }; +} + +/** A task that has failed due to a JSON-RPC error during execution. */ +export interface FailedTask extends Task { + status: 'failed'; + + /** The JSON-RPC error that caused the task to fail. */ + error: { [key: string]: unknown }; +} + +/** A task that has been cancelled. */ +export interface CancelledTask extends Task { + status: 'cancelled'; +} + +/** + * A task with status-specific fields inlined, as returned by `tasks/get`: + * terminal results or pending input requests ride on the snapshot itself. + */ +export type DetailedTask = WorkingTask | InputRequiredTask | CompletedTask | FailedTask | CancelledTask; + +/** + * The result returned by a server in lieu of a standard result shape when it + * elects to process a request asynchronously — flat `Result & Task`. The + * `resultType` field MUST be `"task"` on the wire; it is declared explicitly + * here (the upstream type leaves it to the old SDK `Result`'s index + * signature, which the v2 `Result` no longer has). + */ +export type CreateTaskResult = Result & Task & { resultType: 'task' }; + +/** Parameters of a `tasks/get` request. */ +export interface GetTaskParams { + /** The task identifier to query. */ + taskId: string; + + /** + * The modern (2026-07-28) per-request envelope: carries + * `io.modelcontextprotocol/clientCapabilities` among other keys. Not part + * of the upstream extension schema's params (which lists only `taskId`) — + * declared here because every modern request threads it. + */ + _meta?: Record; +} + +/** A request to retrieve the state of a task. */ +export interface GetTaskRequest { + jsonrpc: '2.0'; + id: string | number; + method: 'tasks/get'; + params: GetTaskParams; +} + +/** + * The response to `tasks/get`: the appropriate {@link DetailedTask} variant + * for the task's current status, with `resultType: "complete"` (MUST). + */ +export type GetTaskResult = Result & DetailedTask & { resultType: 'complete' }; + +/** Parameters of a `tasks/update` request. */ +export interface UpdateTaskParams { + /** The task identifier to update. */ + taskId: string; + + /** + * Responses to outstanding inputRequests previously surfaced by the + * server. Each key MUST correspond to a currently-outstanding inputRequest + * key (unknown keys are ignored; partial responses are accepted). + */ + inputResponses: InputResponses; + + /** The modern per-request envelope (see {@link GetTaskParams._meta}). */ + _meta?: Record; +} + +/** A request to provide input responses to a task in the input_required state. */ +export interface UpdateTaskRequest { + jsonrpc: '2.0'; + id: string | number; + method: 'tasks/update'; + params: UpdateTaskParams; +} + +/** + * The response to `tasks/update`: an empty acknowledgement (eventually + * consistent), with `resultType: "complete"` (MUST). + */ +export type UpdateTaskResult = Result & { resultType: 'complete' }; + +/** Parameters of a `tasks/cancel` request. */ +export interface CancelTaskParams { + /** The task identifier to cancel. */ + taskId: string; + + /** The modern per-request envelope (see {@link GetTaskParams._meta}). */ + _meta?: Record; +} + +/** A request to cancel a task. Cancellation is cooperative and eventually consistent. */ +export interface CancelTaskRequest { + jsonrpc: '2.0'; + id: string | number; + method: 'tasks/cancel'; + params: CancelTaskParams; +} + +/** + * The response to `tasks/cancel`: an empty acknowledgement (ack does not mean + * stopped), with `resultType: "complete"` (MUST). + */ +export type CancelTaskResult = Result & { resultType: 'complete' }; + +/** + * The extension capability declaration for the tasks extension. An empty + * object indicates support; no extension-specific settings are currently + * defined. + */ +export type TasksExtensionCapability = Record; diff --git a/packages/server/test/ext/tasks/inMemoryStore.test.ts b/packages/server/test/ext/tasks/inMemoryStore.test.ts new file mode 100644 index 0000000000..59473ada47 --- /dev/null +++ b/packages/server/test/ext/tasks/inMemoryStore.test.ts @@ -0,0 +1,82 @@ +/** + * `InMemoryTaskStore`: the store semantics a persistent implementation must + * reproduce — durable create, partial input answers, first answer wins, + * cancel aborts a pending input wait, terminal writes are ignored, TTL purge, + * principal fail-closed. + */ +import { describe, expect, it } from 'vitest'; + +import { InMemoryTaskStore } from '../../../src/ext/tasks/index'; + +const tick = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms)); + +describe('InMemoryTaskStore', () => { + it('creates durably and records context for the execution to pick up', async () => { + const store = new InMemoryTaskStore({ createTaskId: () => 'fixed' }); + const task = await store.create({ ttlMs: null, pollIntervalMs: 50, context: { tool: 'greet' } }); + expect(task).toMatchObject({ taskId: 'fixed', status: 'working', ttlMs: null, pollIntervalMs: 50 }); + expect(await store.get('fixed')).toMatchObject({ status: 'working' }); + expect(store.context('fixed')).toEqual({ tool: 'greet' }); + store.close(); + }); + + it('accumulates partial input answers; the first answer to a key wins; unknown keys are ignored', async () => { + const store = new InMemoryTaskStore(); + const { taskId } = await store.create({ ttlMs: null }); + const handle = store.handle(taskId); + const answers = handle.requireInput({ + a: { + method: 'elicitation/create', + params: { message: 'q', mode: 'form', requestedSchema: { type: 'object', properties: {} } } + }, + b: { method: 'elicitation/create', params: { message: 'q', mode: 'form', requestedSchema: { type: 'object', properties: {} } } } + }); + await tick(); + expect(await store.get(taskId)).toMatchObject({ status: 'input_required', inputRequests: { a: {}, b: {} } }); + expect(await store.update(taskId, { a: { action: 'accept' }, zzz: { action: 'accept' } })).toBe(true); + expect(await store.get(taskId)).toMatchObject({ status: 'input_required', inputRequests: { b: {} } }); + await store.update(taskId, { a: { action: 'decline' }, b: { action: 'cancel' } }); + expect(await answers).toEqual({ a: { action: 'accept' }, b: { action: 'cancel' } }); + expect((await store.get(taskId))?.status).toBe('working'); + store.close(); + }); + + it('cancel rejects a pending input wait, aborts the signal, and later writes are ignored', async () => { + const store = new InMemoryTaskStore(); + const { taskId } = await store.create({ ttlMs: null }); + const handle = store.handle(taskId); + const waiting = handle.requireInput({ + a: { method: 'elicitation/create', params: { message: 'q', mode: 'form', requestedSchema: { type: 'object', properties: {} } } } + }); + expect(await store.cancel(taskId)).toBe(true); + await expect(waiting).rejects.toThrow('cancelled'); + expect(handle.signal.aborted).toBe(true); + await handle.complete({ content: [] }); + await handle.status('late'); + expect(await store.get(taskId)).toMatchObject({ status: 'cancelled' }); + expect((await store.get(taskId))?.statusMessage).toBeUndefined(); + expect(await store.cancel(taskId)).toBe(true); + expect(await store.cancel('missing')).toBe(false); + store.close(); + }); + + it('purges at the TTL deadline and fails closed on a foreign principal', async () => { + const store = new InMemoryTaskStore(); + const { taskId } = await store.create({ ttlMs: 10, principal: 'alice' }); + expect(await store.get(taskId)).toBeUndefined(); + expect(await store.get(taskId, { principal: 'bob' })).toBeUndefined(); + expect((await store.get(taskId, { principal: 'alice' }))?.taskId).toBe(taskId); + expect(await store.update(taskId, {}, { principal: 'bob' })).toBe(false); + await tick(30); + expect(await store.get(taskId, { principal: 'alice' })).toBeUndefined(); + expect(() => store.handle(taskId)).toThrow('not found'); + store.close(); + }); + + it('rejects invalid ttlMs and pollIntervalMs', async () => { + const store = new InMemoryTaskStore(); + await expect(store.create({ ttlMs: -1 })).rejects.toBeInstanceOf(RangeError); + await expect(store.create({ ttlMs: null, pollIntervalMs: 1.5 })).rejects.toBeInstanceOf(RangeError); + store.close(); + }); +}); diff --git a/packages/server/test/ext/tasks/tasks.e2e.test.ts b/packages/server/test/ext/tasks/tasks.e2e.test.ts new file mode 100644 index 0000000000..5e48a2e7bb --- /dev/null +++ b/packages/server/test/ext/tasks/tasks.e2e.test.ts @@ -0,0 +1,208 @@ +/** + * 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 { afterEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import type { InputResponses, TaskHandle } from '../../../src/ext/tasks/index'; +import { + createTaskResultSchema, + detailedTaskSchema, + InMemoryTaskStore, + TASKS_EXTENSION_ID, + TasksExtension +} from '../../../src/ext/tasks/index'; +import { CLIENT_CAPABILITIES_META_KEY, createMcpHandler, McpServer, PROTOCOL_VERSION_META_KEY } from '../../../src/index'; + +const TASKS_CAPABILITY = { extensions: { [TASKS_EXTENSION_ID]: {} } }; + +/** The SDK `Client` consumes `resultType` before a caller schema runs, so client-side schemas are the neutral shapes. */ +const ackSchema = z.looseObject({}); + +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; + /** + * `tools/call` is posted raw: the SDK `Client` rejects the extension's + * `resultType: "task"` (typescript-sdk#2637) — the requester half is the + * ext-tasks package's job. Every other method goes through the real Client. + */ + 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 message = await callTool('greet', { name }); + if (message.error !== undefined) throw Object.assign(new Error(message.error.message), { code: message.error.code }); + return createTaskResultSchema.parse(message.result); + }; + const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), { + fetch: (url, init) => mcpHandler.fetch(new Request(url, init)) + }); + const client = new Client( + { name: 'harness', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' }, capabilities: declare ? TASKS_CAPABILITY : {} } + ); + return { store, client, transport, startTask, callTool }; +} + +const getTask = (client: Client, taskId: string) => client.request({ method: 'tasks/get', params: { taskId } }, detailedTaskSchema); +const updateTask = (client: Client, taskId: string, inputResponses: InputResponses) => + client.request({ method: 'tasks/update', params: { taskId, inputResponses } }, ackSchema); +const cancelTask = (client: Client, taskId: string) => client.request({ method: 'tasks/cancel', params: { taskId } }, ackSchema); + +async function pollUntil(client: Client, taskId: string, predicate: (task: z.output) => boolean) { + for (let i = 0; i < 200; i++) { + const task = await getTask(client, 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 done = await pollUntil(h.client, created.taskId, task => task.status === 'completed'); + expect(done.status === 'completed' && done.result).toEqual({ content: [{ type: 'text', text: 'hello ada' }] }); + expect(done.statusMessage).toBe('greeting ada'); + }); + + 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.client, created.taskId, task => task.status === 'input_required'); + expect(waiting.status === 'input_required' && Object.keys(waiting.inputRequests)).toEqual(['confirm']); + + await updateTask(h.client, created.taskId, { confirm: { action: 'accept', content: { ok: true } } }); + const done = await pollUntil(h.client, 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.client, 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 cancelTask(h.client, created.taskId); + const done = await pollUntil(h.client, created.taskId, task => task.status === 'cancelled'); + expect(done.status).toBe('cancelled'); + await new Promise(resolve => setTimeout(resolve, 5)); + expect(aborted).toBe(true); + expect((await getTask(h.client, created.taskId)).status).toBe('cancelled'); + await expect(cancelTask(h.client, created.taskId)).resolves.toBeDefined(); // 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(getTask(h.client, 'nope')).rejects.toMatchObject({ code: -32_602 }); + + const plain = createHarness(async () => {}, { declareExtension: false, plainTool: true }); + await plain.client.connect(plain.transport); + await expect(getTask(plain.client, 'nope')).rejects.toMatchObject({ code: -32_021 }); + const refused = await plain.callTool('greet', { name: 'x' }); + expect(refused.error).toMatchObject({ code: -32_021 }); + const sneaky = await plain.callTool('sneaky', {}); + expect(sneaky.error).toMatchObject({ code: -32_021 }); + plain.store.close(); + }); +}); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 184ab7a899..62bee4bdc0 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -17,7 +17,9 @@ "./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"] } } } diff --git a/packages/server/tsdown.config.ts b/packages/server/tsdown.config.ts index 88004cfc06..cabc9d7d8d 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', From 77b857379659bcd6313100aaa8b06cab534d5619 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 17 Sep 2026 12:55:03 +0200 Subject: [PATCH 3/6] feat(client): Tasks extension at @modelcontextprotocol/client/ext/tasks TasksClientExtension declares io.modelcontextprotocol/tasks on every request, accepts task handles on tools/call through acceptResultType, 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. The wire types and zod schemas move to @modelcontextprotocol/core-internal/ext/tasks, shared by both halves and re-exported from each subpath. The server e2e test now drives tools/call through the client extension instead of a raw POST. --- .changeset/tasks-extension.md | 10 +- docs/.vitepress/nav.ts | 1 + docs/clients/tasks.md | 81 ++++++++++ docs/servers/tasks.md | 2 +- packages/client/package.json | 13 ++ packages/client/src/ext/tasks/index.ts | 62 ++++++++ .../src/ext/tasks/tasksClientExtension.ts | 135 ++++++++++++++++ packages/client/src/index.ts | 2 +- .../ext/tasks/tasksClientExtension.test.ts | 148 ++++++++++++++++++ packages/client/tsconfig.json | 3 +- packages/client/tsdown.config.ts | 2 + packages/core-internal/package.json | 4 + packages/core-internal/src/ext/tasks/index.ts | 7 + .../src/ext/tasks}/schemas.ts | 2 +- .../src/ext/tasks}/types.ts | 4 +- .../test/packageTopologyPins.test.ts | 4 +- .../server/src/ext/tasks/inMemoryStore.ts | 2 +- packages/server/src/ext/tasks/index.ts | 54 +++---- packages/server/src/ext/tasks/store.ts | 5 +- .../server/src/ext/tasks/tasksExtension.ts | 12 +- .../server/test/ext/tasks/tasks.e2e.test.ts | 68 ++++---- packages/server/tsconfig.json | 4 +- packages/server/tsdown.config.ts | 1 + pnpm-lock.yaml | 3 + 24 files changed, 543 insertions(+), 86 deletions(-) create mode 100644 docs/clients/tasks.md create mode 100644 packages/client/src/ext/tasks/index.ts create mode 100644 packages/client/src/ext/tasks/tasksClientExtension.ts create mode 100644 packages/client/test/ext/tasks/tasksClientExtension.test.ts create mode 100644 packages/core-internal/src/ext/tasks/index.ts rename packages/{server/src/ext/tasks/wire => core-internal/src/ext/tasks}/schemas.ts (99%) rename packages/{server/src/ext/tasks/wire => core-internal/src/ext/tasks}/types.ts (98%) diff --git a/.changeset/tasks-extension.md b/.changeset/tasks-extension.md index c1233281c2..b6b80b45ff 100644 --- a/.changeset/tasks-extension.md +++ b/.changeset/tasks-extension.md @@ -1,5 +1,13 @@ --- +'@modelcontextprotocol/core-internal': minor +'@modelcontextprotocol/client': minor '@modelcontextprotocol/server': minor --- -New subpath `@modelcontextprotocol/server/ext/tasks`: the server side of the MCP Tasks extension (`io.modelcontextprotocol/tasks`) as a server extension. `new TasksExtension(store)` in `ServerOptions.extensions` advertises the capability, serves `tasks/get`, `tasks/update` and `tasks/cancel`, gates task handles on the client capability (`-32021`), and offers `tasks.create(ctx)` for a tool handler to answer with a task handle. `TaskStore` (create / get / update / cancel over JSON) is the seam 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. Wire types and zod schemas for the extension's 2026-07-28 schema are exported. +The MCP Tasks extension (`io.modelcontextprotocol/tasks`) as a pair of extensions. + +`@modelcontextprotocol/server/ext/tasks`: `new TasksExtension(store)` in `ServerOptions.extensions` advertises the capability, serves `tasks/get`, `tasks/update` and `tasks/cancel`, gates task handles on the client capability (`-32021`), and offers `tasks.create(ctx)` for a tool handler to answer with a task handle. `TaskStore` (create / get / update / cancel over JSON) is the interface a server implements over its own state and execution; `InMemoryTaskStore` is the in-process reference with a writer `handle` for reporting status, requesting input, and settling. + +`@modelcontextprotocol/client/ext/tasks`: `new TasksClientExtension()` in `ClientOptions.extensions` declares the capability on every request, accepts task handles on `tools/call`, and wraps the extension's methods: `callTool` (a task handle or the plain result), `get`, `update`, `cancel`, and `waitFor`, which polls at the server's suggested interval to a terminal snapshot. + +Wire types and zod schemas for the extension's 2026-07-28 schema live at `@modelcontextprotocol/core-internal/ext/tasks` and are re-exported from both subpaths. diff --git a/docs/.vitepress/nav.ts b/docs/.vitepress/nav.ts index a288cb9ed4..b801648ff6 100644 --- a/docs/.vitepress/nav.ts +++ b/docs/.vitepress/nav.ts @@ -54,6 +54,7 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [ { text: 'Handle server requests', link: '/clients/server-requests' }, { text: 'Roots (sunset)', link: '/clients/roots' }, { text: 'Subscriptions', link: '/clients/subscriptions' }, + { text: 'Tasks (extension)', link: '/clients/tasks' }, { text: 'OAuth', link: '/clients/oauth' }, { text: 'Machine auth', link: '/clients/machine-auth' }, { text: 'Middleware', link: '/clients/middleware' }, 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 index c58b197fd3..77bdb34a50 100644 --- a/docs/servers/tasks.md +++ b/docs/servers/tasks.md @@ -64,7 +64,7 @@ How the work behind a task runs — a queue, a workflow engine, a durable-execut ## Wire notes -- The extension is served on the 2026-07-28 revision. Task tools are ordinary tools without `outputSchema`; the encode seam forwards `resultType: "task"` for `tools/call` verbatim. +- The extension is served on the 2026-07-28 revision. Task tools are ordinary tools without `outputSchema`; the result encoder forwards `resultType: "task"` for `tools/call` verbatim. - A request that does not declare `io.modelcontextprotocol/tasks` in its client capabilities is refused with `-32021` — from `tasks.create`, from the `tasks/*` methods, and by the extension's `tools/call` override for any handle minted some other way. - `tasks/update`'s `inputResponses` shares its name with the multi-round-trip retry field: the protocol layer lifts it out of the params and the extension reads it back from `ctx.mcpReq.inputResponses`. - The SDK `Client` rejects `resultType: "task"` on `tools/call` (typescript-sdk#2637); the requester half of the extension is `@modelcontextprotocol/ext-tasks`. diff --git a/packages/client/package.json b/packages/client/package.json index 3ebfde64d3..3850334738 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -40,6 +40,16 @@ "default": "./dist/stdio.cjs" } }, + "./ext/tasks": { + "import": { + "types": "./dist/ext/tasks/index.d.mts", + "default": "./dist/ext/tasks/index.mjs" + }, + "require": { + "types": "./dist/ext/tasks/index.d.cts", + "default": "./dist/ext/tasks/index.cjs" + } + }, "./validators/ajv": { "import": { "types": "./dist/validators/ajv.d.mts", @@ -115,6 +125,9 @@ ], "stdio": [ "dist/stdio.d.mts" + ], + "ext/tasks": [ + "dist/ext/tasks/index.d.mts" ] } }, diff --git a/packages/client/src/ext/tasks/index.ts b/packages/client/src/ext/tasks/index.ts new file mode 100644 index 0000000000..6a58b1ccac --- /dev/null +++ b/packages/client/src/ext/tasks/index.ts @@ -0,0 +1,62 @@ +/** + * `@modelcontextprotocol/client/ext/tasks` — the client side of the MCP + * Tasks extension (`io.modelcontextprotocol/tasks`). + * + * `TasksClientExtension` advertises the capability, accepts task handles on + * `tools/call`, and wraps `tasks/get`, `tasks/update` and `tasks/cancel`, + * plus `waitFor` to poll a task to a terminal status. + */ + +export type { CallToolOutcome, TerminalTask, WaitForOptions } from './tasksClientExtension'; +export { TasksClientExtension } from './tasksClientExtension'; +export type { + CancelledTask, + CancelTaskParams, + CancelTaskRequest, + CancelTaskResult, + CompletedTask, + CreateTaskResult, + DetailedTask, + FailedTask, + GetTaskParams, + GetTaskRequest, + GetTaskResult, + InputRequest, + InputRequests, + InputRequiredTask, + InputResponse, + InputResponses, + Task, + TasksExtensionCapability, + TaskStatus, + UpdateTaskParams, + UpdateTaskRequest, + UpdateTaskResult, + WorkingTask +} from '@modelcontextprotocol/core-internal/ext/tasks'; +export { + cancelledTaskSchema, + cancelTaskParamsSchema, + cancelTaskRequestSchema, + cancelTaskResultSchema, + completedTaskSchema, + createTaskResultSchema, + detailedTaskSchema, + failedTaskSchema, + getTaskParamsSchema, + getTaskRequestSchema, + getTaskResultSchema, + inputRequestSchema, + inputRequestsSchema, + inputRequiredTaskSchema, + inputResponseSchema, + inputResponsesSchema, + taskSchema, + tasksExtensionCapabilitySchema, + taskStatusSchema, + updateTaskParamsSchema, + updateTaskRequestSchema, + updateTaskResultSchema, + workingTaskSchema +} from '@modelcontextprotocol/core-internal/ext/tasks'; +export { TASK_STATUSES, TASKS_EXTENSION_ID } from '@modelcontextprotocol/core-internal/ext/tasks'; diff --git a/packages/client/src/ext/tasks/tasksClientExtension.ts b/packages/client/src/ext/tasks/tasksClientExtension.ts new file mode 100644 index 0000000000..9b52805f65 --- /dev/null +++ b/packages/client/src/ext/tasks/tasksClientExtension.ts @@ -0,0 +1,135 @@ +/** + * `TasksClientExtension` — the client side of the MCP Tasks extension + * (`io.modelcontextprotocol/tasks`) as a {@linkcode ClientExtension}. It + * advertises the capability on every request, lets `tools/call` answer with a + * task handle, and wraps the extension's methods: `get`, `update`, `cancel`, + * and a `waitFor` that polls at the server's suggested interval until the + * task is terminal. + * + * ```ts + * const tasks = new TasksClientExtension(); + * const client = new Client(info, { extensions: [tasks] }); + * await client.connect(transport); + * + * const outcome = await tasks.callTool({ name: 'send_report', arguments: { to: 'ops' } }); + * if (outcome.kind === 'task') { + * const done = await tasks.waitFor(outcome.task.taskId); + * if (done.status === 'completed') console.log(done.result); + * } + * ``` + * + * One extension instance serves one client: `install` binds it. + */ + +import type { CallToolRequestParams, CallToolResult, InputResponses, RequestOptions } from '@modelcontextprotocol/core-internal'; +import { CompatibilityCallToolResultSchema, SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal'; +import type { CreateTaskResult, DetailedTask } from '@modelcontextprotocol/core-internal/ext/tasks'; +import { createTaskResultSchema, detailedTaskSchema, TASKS_EXTENSION_ID } from '@modelcontextprotocol/core-internal/ext/tasks'; +import * as z from 'zod/v4'; + +import type { Client } from '../../client/client'; +import type { ClientExtension } from '../../client/extension'; + +/** How a task-capable `tools/call` settled: a task handle to follow, or the ordinary result. */ +export type CallToolOutcome = { kind: 'task'; task: CreateTaskResult } | { kind: 'result'; result: CallToolResult }; + +/** Options for {@link TasksClientExtension.waitFor}. */ +export interface WaitForOptions { + /** Aborting stops polling; the task itself is untouched. */ + signal?: AbortSignal; + /** Overrides the server's `pollIntervalMs` hint, ms. */ + pollIntervalMs?: number; + /** Fallback when the task carries no `pollIntervalMs`, ms. Default 1_000. */ + defaultPollIntervalMs?: number; + /** Called with every snapshot, terminal one included. */ + onUpdate?: (task: DetailedTask) => void; +} + +/** A task in a terminal status. */ +export type TerminalTask = Extract; + +const TERMINAL = new Set(['completed', 'failed', 'cancelled']); + +/** + * The `Client` consumes `resultType` before a caller schema runs for every + * kind it knows, so the neutral `DetailedTask` shape is what `tasks/get` + * answers arrive as; a task handle arrives raw, discriminator included. + */ +const callToolOutcomeSchema = z.union([createTaskResultSchema, CompatibilityCallToolResultSchema]); +const ackSchema = z.looseObject({}); + +export class TasksClientExtension implements ClientExtension { + readonly id = TASKS_EXTENSION_ID; + readonly capability = {}; + #client: Client | undefined; + + install(client: Client): void { + if (this.#client !== undefined) throw new Error('TasksClientExtension is already installed on a client'); + this.#client = client; + client.acceptResultType('tools/call', 'task'); + } + + get client(): Client { + if (this.#client === undefined) throw new SdkError(SdkErrorCode.NotConnected, 'TasksClientExtension is not installed on a client'); + return this.#client; + } + + /** + * Calls a tool that may answer with a task handle. The ordinary result + * comes back as `{ kind: 'result' }` for tools that answer synchronously. + */ + async callTool(params: CallToolRequestParams, options?: RequestOptions): Promise { + const raw = await this.client.request({ method: 'tools/call', params }, callToolOutcomeSchema, options); + if ((raw as { resultType?: unknown }).resultType === 'task') { + return { kind: 'task', task: createTaskResultSchema.parse(raw) }; + } + return { kind: 'result', result: raw as CallToolResult }; + } + + /** `tasks/get`: the task's current snapshot, results and input requests inlined. */ + async get(taskId: string, options?: RequestOptions): Promise { + const task = await this.client.request({ method: 'tasks/get', params: { taskId } }, detailedTaskSchema, options); + return task as DetailedTask; + } + + /** `tasks/update`: answers outstanding input requests. Partial answers are accepted. */ + async update(taskId: string, inputResponses: InputResponses, options?: RequestOptions): Promise { + await this.client.request({ method: 'tasks/update', params: { taskId, inputResponses } }, ackSchema, options); + } + + /** `tasks/cancel`: cooperative; resolves on acknowledgement, the task may still settle. */ + async cancel(taskId: string, options?: RequestOptions): Promise { + await this.client.request({ method: 'tasks/cancel', params: { taskId } }, ackSchema, options); + } + + /** + * Polls `tasks/get` at the task's suggested interval until the task is + * terminal. `input_required` snapshots are reported through `onUpdate` + * and polling continues; answer them with {@link update}. + */ + async waitFor(taskId: string, options?: WaitForOptions): Promise { + for (;;) { + options?.signal?.throwIfAborted(); + const task = await this.get(taskId, { signal: options?.signal }); + options?.onUpdate?.(task); + if (TERMINAL.has(task.status)) return task as TerminalTask; + const delay = options?.pollIntervalMs ?? task.pollIntervalMs ?? options?.defaultPollIntervalMs ?? 1000; + await sleep(delay, options?.signal); + } + } +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(signal.reason); + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 12c2667b7f..af875d015f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -68,11 +68,11 @@ export { StaticPrivateKeyJwtProvider } from './client/authExtensions'; export type { CacheableRequestOptions, CallToolRequestOptions, ClientOptions, ConnectOptions, McpSubscription } from './client/client'; -export type { ClientExtension } from './client/extension'; export { Client } from './client/client'; export { getSupportedElicitationModes } from './client/client'; export type { DiscoverAndRequestJwtAuthGrantOptions, JwtAuthGrantResult, RequestJwtAuthGrantOptions } from './client/crossAppAccess'; export { discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant, requestJwtAuthorizationGrant } from './client/crossAppAccess'; +export type { ClientExtension } from './client/extension'; // DPoP (RFC 9449 / SEP-1932) sender-constrained tokens: the signing session plus key-pair // primitives. Wire a DpopSession into OAuthClientProvider.dpop() for full OAuth+DPoP via `auth`/ // the transports' authProvider option, or use `withDpop` directly when you manage tokens yourself. 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..94244e97a9 --- /dev/null +++ b/packages/client/test/ext/tasks/tasksClientExtension.test.ts @@ -0,0 +1,148 @@ +/** + * `TasksClientExtension` against a scripted 2026-07-28 server: the + * capability rides every request, `callTool` splits task handles from plain + * results, and `waitFor` polls at the task's interval until terminal, + * reporting `input_required` on the way. + */ +import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal'; +import { CLIENT_CAPABILITIES_META_KEY, InMemoryTransport } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it } from 'vitest'; + +import { Client } from '../../../src/client/client'; +import { TASKS_EXTENSION_ID, TasksClientExtension } from '../../../src/ext/tasks/index'; + +const MODERN = '2026-07-28'; +const NOW = '2026-09-17T10:00:00.000Z'; + +type Snapshot = Record; + +/** A scripted server: a task whose `tasks/get` answers walk through `script`, one per poll. */ +async function scriptedServer(script: Snapshot[]) { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + let polls = 0; + serverTx.onmessage = message => { + written.push(message); + const request = message as { id?: number | string; method?: string; params?: { name?: string; taskId?: string } }; + if (request.id === undefined) return; + const reply = (result: Snapshot) => void serverTx.send({ jsonrpc: '2.0', id: request.id as number, result }); + switch (request.method) { + case 'server/discover': { + reply({ resultType: 'complete', supportedVersions: [MODERN], capabilities: { extensions: { [TASKS_EXTENSION_ID]: {} } } }); + break; + } + case 'tools/call': { + reply( + request.params?.name === 'slow' + ? { + resultType: 'task', + taskId: 't-1', + status: 'working', + createdAt: NOW, + lastUpdatedAt: NOW, + ttlMs: null, + pollIntervalMs: 5 + } + : { resultType: 'complete', content: [{ type: 'text', text: 'fast' }] } + ); + break; + } + case 'tasks/get': { + const snapshot = script[Math.min(polls, script.length - 1)] ?? {}; + polls += 1; + reply({ + resultType: 'complete', + taskId: 't-1', + createdAt: NOW, + lastUpdatedAt: NOW, + ttlMs: null, + pollIntervalMs: 5, + ...snapshot + }); + break; + } + case 'tasks/update': + case 'tasks/cancel': { + reply({ resultType: 'complete' }); + break; + } + default: { + reply({ resultType: 'complete' }); + } + } + }; + await serverTx.start(); + const tasks = new TasksClientExtension(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' }, extensions: [tasks] }); + await client.connect(clientTx); + return { client, tasks, written, pollCount: () => polls }; +} + +const methodsWritten = (written: JSONRPCMessage[]) => written.map(message => (message as { method?: string }).method).filter(Boolean); + +describe('TasksClientExtension', () => { + it('is bound to one client and refuses use before install', async () => { + const tasks = new TasksClientExtension(); + await expect(tasks.get('t-1')).rejects.toThrow(/not installed/); + new Client({ name: 'a', version: '1' }, { extensions: [tasks] }); + expect(() => new Client({ name: 'b', version: '1' }, { extensions: [tasks] })).toThrow(/already installed/); + }); + + it('declares the extension on every request and splits task handles from plain results', async () => { + const { client, tasks, written } = await scriptedServer([]); + const plain = await tasks.callTool({ name: 'fast', arguments: {} }); + expect(plain).toEqual({ kind: 'result', result: { content: [{ type: 'text', text: 'fast' }] } }); + const slow = await tasks.callTool({ name: 'slow', arguments: {} }); + expect(slow.kind === 'task' && slow.task).toMatchObject({ + resultType: 'task', + taskId: 't-1', + status: 'working', + pollIntervalMs: 5 + }); + const call = written.find(message => (message as { method?: string }).method === 'tools/call') as { + params: { _meta: Record }; + }; + expect(call.params._meta[CLIENT_CAPABILITIES_META_KEY]).toEqual({ extensions: { [TASKS_EXTENSION_ID]: {} } }); + await client.close(); + }); + + it('waitFor polls at the task interval, reports input_required, and resolves on the terminal snapshot', async () => { + const { client, tasks, pollCount } = await scriptedServer([ + { status: 'working', statusMessage: 'starting' }, + { + status: 'input_required', + inputRequests: { + q: { + method: 'elicitation/create', + params: { message: '?', mode: 'form', requestedSchema: { type: 'object', properties: {} } } + } + } + }, + { status: 'completed', result: { content: [{ type: 'text', text: 'done' }] } } + ]); + const seen: string[] = []; + const done = await tasks.waitFor('t-1', { + onUpdate: task => { + seen.push(task.status); + if (task.status === 'input_required') void tasks.update('t-1', { q: { action: 'accept' } }); + } + }); + expect(seen).toEqual(['working', 'input_required', 'completed']); + expect(done).toMatchObject({ status: 'completed', result: { content: [{ type: 'text', text: 'done' }] } }); + expect(pollCount()).toBe(3); + await client.close(); + }); + + it('waitFor stops on abort without touching the task; cancel is an explicit call', async () => { + const { client, tasks, written } = await scriptedServer([{ status: 'working' }]); + const controller = new AbortController(); + const waiting = tasks.waitFor('t-1', { signal: controller.signal, pollIntervalMs: 1000 }); + await new Promise(resolve => setTimeout(resolve, 10)); + controller.abort(new Error('stop polling')); + await expect(waiting).rejects.toThrow('stop polling'); + expect(methodsWritten(written)).not.toContain('tasks/cancel'); + await tasks.cancel('t-1'); + expect(methodsWritten(written)).toContain('tasks/cancel'); + await client.close(); + }); +}); diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 8fc1de9347..d14d344bbe 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -17,7 +17,8 @@ "./node_modules/@modelcontextprotocol/core-internal/src/validators/cfWorkerProvider.ts" ], "@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"], - "@modelcontextprotocol/client/_shims": ["./src/shimsNode.ts"] + "@modelcontextprotocol/client/_shims": ["./src/shimsNode.ts"], + "@modelcontextprotocol/core-internal/ext/tasks": ["./node_modules/@modelcontextprotocol/core-internal/src/ext/tasks/index.ts"] } } } diff --git a/packages/client/tsdown.config.ts b/packages/client/tsdown.config.ts index 45e0d7a28e..c433d067b7 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({ 'fast-uri': ['../core-internal/src/validators/fastUriShim.d.ts'], '@modelcontextprotocol/core-internal': ['../core-internal/src/index.ts'], '@modelcontextprotocol/core-internal/public': ['../core-internal/src/exports/public/index.ts'], + '@modelcontextprotocol/core-internal/ext/tasks': ['../core-internal/src/ext/tasks/index.ts'], '@modelcontextprotocol/core-internal/validators/ajv': ['../core-internal/src/validators/ajvProvider.ts'], '@modelcontextprotocol/core-internal/validators/cfWorker': ['../core-internal/src/validators/cfWorkerProvider.ts'] } diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json index 44a4be1cd3..e2c00be61c 100644 --- a/packages/core-internal/package.json +++ b/packages/core-internal/package.json @@ -33,6 +33,10 @@ "types": "./src/exports/public/index.ts", "import": "./src/exports/public/index.ts" }, + "./ext/tasks": { + "types": "./src/ext/tasks/index.ts", + "import": "./src/ext/tasks/index.ts" + }, "./validators/ajv": { "types": "./src/validators/ajvProvider.ts", "import": "./src/validators/ajvProvider.ts" diff --git a/packages/core-internal/src/ext/tasks/index.ts b/packages/core-internal/src/ext/tasks/index.ts new file mode 100644 index 0000000000..f55a8bda64 --- /dev/null +++ b/packages/core-internal/src/ext/tasks/index.ts @@ -0,0 +1,7 @@ +/** + * Wire types and zod schemas of the MCP Tasks extension + * (`io.modelcontextprotocol/tasks`, schema 2026-07-28), shared by the server + * and client halves of the extension. + */ +export * from './schemas'; +export * from './types'; diff --git a/packages/server/src/ext/tasks/wire/schemas.ts b/packages/core-internal/src/ext/tasks/schemas.ts similarity index 99% rename from packages/server/src/ext/tasks/wire/schemas.ts rename to packages/core-internal/src/ext/tasks/schemas.ts index 93639821b6..2542a5fb47 100644 --- a/packages/server/src/ext/tasks/wire/schemas.ts +++ b/packages/core-internal/src/ext/tasks/schemas.ts @@ -19,7 +19,7 @@ * Copyright (c) Model Context Protocol contributors */ -import { z } from 'zod'; +import * as z from 'zod/v4'; import { TASK_STATUSES } from './types'; diff --git a/packages/server/src/ext/tasks/wire/types.ts b/packages/core-internal/src/ext/tasks/types.ts similarity index 98% rename from packages/server/src/ext/tasks/wire/types.ts rename to packages/core-internal/src/ext/tasks/types.ts index 8bc6a133ce..494b1d5a29 100644 --- a/packages/server/src/ext/tasks/wire/types.ts +++ b/packages/core-internal/src/ext/tasks/types.ts @@ -19,14 +19,14 @@ * Copyright (c) Model Context Protocol contributors */ -import type { InputRequests, InputResponses, Result } from '@modelcontextprotocol/core-internal'; +import type { InputRequests, InputResponses, Result } from '../../types/index'; /** * A single input request / response embedded in a task, re-based on the SDK * v2 MRTR unions (sampling, roots, or elicitation). Keys in the containing * maps MUST be unique over the lifetime of a single task. */ -export type { InputRequest, InputRequests, InputResponse, InputResponses } from '@modelcontextprotocol/core-internal'; +export type { InputRequest, InputRequests, InputResponse, InputResponses } from '../../types/index'; /** The MCP Tasks extension identifier. An empty-object capability declares support. */ export const TASKS_EXTENSION_ID = 'io.modelcontextprotocol/tasks'; diff --git a/packages/core-internal/test/packageTopologyPins.test.ts b/packages/core-internal/test/packageTopologyPins.test.ts index 979ff15070..387564a7a9 100644 --- a/packages/core-internal/test/packageTopologyPins.test.ts +++ b/packages/core-internal/test/packageTopologyPins.test.ts @@ -37,11 +37,11 @@ function readManifest(relativeDir: string): PackageManifest { const PUBLIC_PACKAGES: Record }> = { client: { name: '@modelcontextprotocol/client', - exportKeys: ['.', './stdio', './validators/ajv', './validators/cf-worker', './_shims'] + exportKeys: ['.', './stdio', './ext/tasks', './validators/ajv', './validators/cf-worker', './_shims'] }, server: { name: '@modelcontextprotocol/server', - exportKeys: ['.', './stdio', './validators/ajv', './validators/cf-worker', './_shims'] + exportKeys: ['.', './stdio', './ext/tasks', './validators/ajv', './validators/cf-worker', './_shims'] }, 'server-legacy': { name: '@modelcontextprotocol/server-legacy', diff --git a/packages/server/src/ext/tasks/inMemoryStore.ts b/packages/server/src/ext/tasks/inMemoryStore.ts index d5d615a8f6..23d04d4c70 100644 --- a/packages/server/src/ext/tasks/inMemoryStore.ts +++ b/packages/server/src/ext/tasks/inMemoryStore.ts @@ -7,9 +7,9 @@ */ import type { CallToolResult, InputRequests, InputResponses } from '@modelcontextprotocol/core-internal'; +import type { DetailedTask, Task, TaskStatus } from '@modelcontextprotocol/core-internal/ext/tasks'; import type { CreateTaskParams, TaskAccess, TaskStore } from './store'; -import type { DetailedTask, Task, TaskStatus } from './wire/types'; interface PendingInput { requests: InputRequests; diff --git a/packages/server/src/ext/tasks/index.ts b/packages/server/src/ext/tasks/index.ts index 87891639b4..2cdcf9684a 100644 --- a/packages/server/src/ext/tasks/index.ts +++ b/packages/server/src/ext/tasks/index.ts @@ -15,31 +15,6 @@ export { InMemoryTaskStore } from './inMemoryStore'; export type { CreateTaskParams, TaskAccess, TaskStore } from './store'; export type { CreateTaskOptions, TasksExtensionOptions, TaskToolResult } from './tasksExtension'; export { declaresTasksExtension, TasksExtension } from './tasksExtension'; -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 './wire/schemas'; export type { CancelledTask, CancelTaskParams, @@ -64,5 +39,30 @@ export type { UpdateTaskRequest, UpdateTaskResult, WorkingTask -} from './wire/types'; -export { TASK_STATUSES, TASKS_EXTENSION_ID } from './wire/types'; +} from '@modelcontextprotocol/core-internal/ext/tasks'; +export { + cancelledTaskSchema, + cancelTaskParamsSchema, + cancelTaskRequestSchema, + cancelTaskResultSchema, + completedTaskSchema, + createTaskResultSchema, + detailedTaskSchema, + failedTaskSchema, + getTaskParamsSchema, + getTaskRequestSchema, + getTaskResultSchema, + inputRequestSchema, + inputRequestsSchema, + inputRequiredTaskSchema, + inputResponseSchema, + inputResponsesSchema, + taskSchema, + tasksExtensionCapabilitySchema, + taskStatusSchema, + updateTaskParamsSchema, + updateTaskRequestSchema, + updateTaskResultSchema, + workingTaskSchema +} from '@modelcontextprotocol/core-internal/ext/tasks'; +export { TASK_STATUSES, TASKS_EXTENSION_ID } from '@modelcontextprotocol/core-internal/ext/tasks'; diff --git a/packages/server/src/ext/tasks/store.ts b/packages/server/src/ext/tasks/store.ts index 4d54c477df..dc655f13c9 100644 --- a/packages/server/src/ext/tasks/store.ts +++ b/packages/server/src/ext/tasks/store.ts @@ -1,5 +1,5 @@ /** - * The store seam of the Tasks extension: what the `tasks/*` request handlers + * 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 @@ -7,8 +7,7 @@ */ import type { InputResponses } from '@modelcontextprotocol/core-internal'; - -import type { DetailedTask, Task } from './wire/types'; +import type { DetailedTask, Task } from '@modelcontextprotocol/core-internal/ext/tasks'; /** What `TasksExtension.create` asks a store to durably create. */ export interface CreateTaskParams { diff --git a/packages/server/src/ext/tasks/tasksExtension.ts b/packages/server/src/ext/tasks/tasksExtension.ts index 4b6de30514..c51f23c3f5 100644 --- a/packages/server/src/ext/tasks/tasksExtension.ts +++ b/packages/server/src/ext/tasks/tasksExtension.ts @@ -27,13 +27,17 @@ import { ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/core-internal'; +import type { CancelTaskResult, CreateTaskResult, GetTaskResult, UpdateTaskResult } from '@modelcontextprotocol/core-internal/ext/tasks'; +import { + cancelTaskParamsSchema, + getTaskParamsSchema, + inputResponsesSchema, + TASKS_EXTENSION_ID +} from '@modelcontextprotocol/core-internal/ext/tasks'; import type { ServerExtension } from '../../server/extension'; import type { Server } from '../../server/server'; import type { CreateTaskParams, TaskStore } from './store'; -import { cancelTaskParamsSchema, getTaskParamsSchema, inputResponsesSchema } from './wire/schemas'; -import type { CancelTaskResult, CreateTaskResult, GetTaskResult, UpdateTaskResult } from './wire/types'; -import { TASKS_EXTENSION_ID } from './wire/types'; /** Options for {@link TasksExtension}. */ export interface TasksExtensionOptions { @@ -48,7 +52,7 @@ export type CreateTaskOptions = Partial>; /** * The task handle a tool handler returns in place of a `CallToolResult`. - * The 2026-07-28 encode seam forwards `resultType: "task"` for `tools/call` + * 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; diff --git a/packages/server/test/ext/tasks/tasks.e2e.test.ts b/packages/server/test/ext/tasks/tasks.e2e.test.ts index 5e48a2e7bb..600746e04e 100644 --- a/packages/server/test/ext/tasks/tasks.e2e.test.ts +++ b/packages/server/test/ext/tasks/tasks.e2e.test.ts @@ -6,24 +6,14 @@ * plain async function driving the store's writer handle. */ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { TasksClientExtension } from '@modelcontextprotocol/client/ext/tasks'; import { afterEach, describe, expect, it } from 'vitest'; import * as z from 'zod/v4'; -import type { InputResponses, TaskHandle } from '../../../src/ext/tasks/index'; -import { - createTaskResultSchema, - detailedTaskSchema, - InMemoryTaskStore, - TASKS_EXTENSION_ID, - TasksExtension -} from '../../../src/ext/tasks/index'; +import type { DetailedTask, TaskHandle } from '../../../src/ext/tasks/index'; +import { InMemoryTaskStore, TASKS_EXTENSION_ID, TasksExtension } from '../../../src/ext/tasks/index'; import { CLIENT_CAPABILITIES_META_KEY, createMcpHandler, McpServer, PROTOCOL_VERSION_META_KEY } from '../../../src/index'; -const TASKS_CAPABILITY = { extensions: { [TASKS_EXTENSION_ID]: {} } }; - -/** The SDK `Client` consumes `resultType` before a caller schema runs, so client-side schemas are the neutral shapes. */ -const ackSchema = z.looseObject({}); - type Work = (handle: TaskHandle, input: { name: string }) => Promise; function createHarness(work: Work, options?: { declareExtension?: boolean; plainTool?: boolean }) { @@ -47,11 +37,7 @@ function createHarness(work: Work, options?: { declareExtension?: boolean; plain }; const mcpHandler = createMcpHandler(createServer); const declare = options?.declareExtension !== false; - /** - * `tools/call` is posted raw: the SDK `Client` rejects the extension's - * `resultType: "task"` (typescript-sdk#2637) — the requester half is the - * ext-tasks package's job. Every other method goes through the real Client. - */ + /** 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', @@ -87,28 +73,24 @@ function createHarness(work: Work, options?: { declareExtension?: boolean; plain return JSON.parse(payload ?? '{}') as { result?: Record; error?: { code: number; message: string } }; }; const startTask = async (name: string) => { - const message = await callTool('greet', { name }); - if (message.error !== undefined) throw Object.assign(new Error(message.error.message), { code: message.error.code }); - return createTaskResultSchema.parse(message.result); + 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' }, capabilities: declare ? TASKS_CAPABILITY : {} } + { versionNegotiation: { mode: 'auto' }, extensions: declare ? [clientTasks] : [] } ); - return { store, client, transport, startTask, callTool }; + return { store, client, transport, clientTasks, startTask, callTool }; } -const getTask = (client: Client, taskId: string) => client.request({ method: 'tasks/get', params: { taskId } }, detailedTaskSchema); -const updateTask = (client: Client, taskId: string, inputResponses: InputResponses) => - client.request({ method: 'tasks/update', params: { taskId, inputResponses } }, ackSchema); -const cancelTask = (client: Client, taskId: string) => client.request({ method: 'tasks/cancel', params: { taskId } }, ackSchema); - -async function pollUntil(client: Client, taskId: string, predicate: (task: z.output) => boolean) { +async function pollUntil(tasks: TasksClientExtension, taskId: string, predicate: (task: DetailedTask) => boolean) { for (let i = 0; i < 200; i++) { - const task = await getTask(client, taskId); + const task = await tasks.get(taskId); if (predicate(task)) return task; await new Promise(resolve => setTimeout(resolve, 5)); } @@ -132,9 +114,11 @@ describe('TasksExtension end to end (stateless handler, in-memory store)', () => const created = await h.startTask('ada'); expect(created).toMatchObject({ resultType: 'task', status: 'working', ttlMs: 86_400_000, pollIntervalMs: 10 }); - const done = await pollUntil(h.client, created.taskId, task => task.status === 'completed'); + const seen: string[] = []; + const done = await h.clientTasks.waitFor(created.taskId, { onUpdate: task => seen.push(task.status) }); expect(done.status === 'completed' && done.result).toEqual({ content: [{ type: 'text', text: 'hello ada' }] }); expect(done.statusMessage).toBe('greeting ada'); + expect(seen.at(-1)).toBe('completed'); }); it('surfaces input_required, resumes on tasks/update, and inlines the answer', async () => { @@ -150,11 +134,11 @@ describe('TasksExtension end to end (stateless handler, in-memory store)', () => cleanup = () => h.store.close(); await h.client.connect(h.transport); const created = await h.startTask('bob'); - const waiting = await pollUntil(h.client, created.taskId, task => task.status === 'input_required'); + 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 updateTask(h.client, created.taskId, { confirm: { action: 'accept', content: { ok: true } } }); - const done = await pollUntil(h.client, created.taskId, task => task.status === 'completed'); + 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 } } }) }] }); @@ -167,7 +151,7 @@ describe('TasksExtension end to end (stateless handler, in-memory store)', () => cleanup = () => h.store.close(); await h.client.connect(h.transport); const created = await h.startTask('x'); - const done = await pollUntil(h.client, created.taskId, task => task.status === 'failed'); + 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' }); }); @@ -181,24 +165,26 @@ describe('TasksExtension end to end (stateless handler, in-memory store)', () => cleanup = () => h.store.close(); await h.client.connect(h.transport); const created = await h.startTask('x'); - await cancelTask(h.client, created.taskId); - const done = await pollUntil(h.client, created.taskId, task => task.status === 'cancelled'); + 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 getTask(h.client, created.taskId)).status).toBe('cancelled'); - await expect(cancelTask(h.client, created.taskId)).resolves.toBeDefined(); // idempotent + 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(getTask(h.client, 'nope')).rejects.toMatchObject({ code: -32_602 }); + 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(getTask(plain.client, 'nope')).rejects.toMatchObject({ code: -32_021 }); + await expect(plain.client.request({ method: 'tasks/get', params: { taskId: 'nope' } }, z.looseObject({}))).rejects.toMatchObject({ + code: -32_021 + }); const refused = await plain.callTool('greet', { name: 'x' }); expect(refused.error).toMatchObject({ code: -32_021 }); const sneaky = await plain.callTool('sneaky', {}); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 62bee4bdc0..d0d45302b4 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -19,7 +19,9 @@ "@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.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/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], + "@modelcontextprotocol/core-internal/ext/tasks": ["./node_modules/@modelcontextprotocol/core-internal/src/ext/tasks/index.ts"], + "@modelcontextprotocol/client/ext/tasks": ["./node_modules/@modelcontextprotocol/client/src/ext/tasks/index.ts"] } } } diff --git a/packages/server/tsdown.config.ts b/packages/server/tsdown.config.ts index cabc9d7d8d..8d1df171cd 100644 --- a/packages/server/tsdown.config.ts +++ b/packages/server/tsdown.config.ts @@ -29,6 +29,7 @@ export default defineConfig({ 'fast-uri': ['../core-internal/src/validators/fastUriShim.d.ts'], '@modelcontextprotocol/core-internal': ['../core-internal/src/index.ts'], '@modelcontextprotocol/core-internal/public': ['../core-internal/src/exports/public/index.ts'], + '@modelcontextprotocol/core-internal/ext/tasks': ['../core-internal/src/ext/tasks/index.ts'], '@modelcontextprotocol/core-internal/validators/ajv': ['../core-internal/src/validators/ajvProvider.ts'], '@modelcontextprotocol/core-internal/validators/cfWorker': ['../core-internal/src/validators/cfWorkerProvider.ts'] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c663ad7086..7b05427df1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1797,6 +1797,9 @@ importers: '@eslint/js': specifier: catalog:devTools version: 9.39.4 + '@modelcontextprotocol/client': + specifier: workspace:^ + version: link:../client '@modelcontextprotocol/core-internal': specifier: workspace:^ version: link:../core-internal From 681cee30a93616ae626684631117adc1dba52977 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 17 Sep 2026 12:57:42 +0200 Subject: [PATCH 4/6] test(server): restore the tasks capability constant in the e2e harness --- packages/server/test/ext/tasks/tasks.e2e.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/server/test/ext/tasks/tasks.e2e.test.ts b/packages/server/test/ext/tasks/tasks.e2e.test.ts index 600746e04e..7e19463ecf 100644 --- a/packages/server/test/ext/tasks/tasks.e2e.test.ts +++ b/packages/server/test/ext/tasks/tasks.e2e.test.ts @@ -12,6 +12,8 @@ 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; From 9ca4aaa3e3e9d805c089c70f7e23f493cb9404b0 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 17 Sep 2026 13:10:57 +0200 Subject: [PATCH 5/6] refactor(tasks): drop the capability field from both tasks extensions --- packages/client/src/ext/tasks/tasksClientExtension.ts | 1 - packages/server/src/ext/tasks/tasksExtension.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/client/src/ext/tasks/tasksClientExtension.ts b/packages/client/src/ext/tasks/tasksClientExtension.ts index 9b52805f65..c80d98662b 100644 --- a/packages/client/src/ext/tasks/tasksClientExtension.ts +++ b/packages/client/src/ext/tasks/tasksClientExtension.ts @@ -60,7 +60,6 @@ const ackSchema = z.looseObject({}); export class TasksClientExtension implements ClientExtension { readonly id = TASKS_EXTENSION_ID; - readonly capability = {}; #client: Client | undefined; install(client: Client): void { diff --git a/packages/server/src/ext/tasks/tasksExtension.ts b/packages/server/src/ext/tasks/tasksExtension.ts index c51f23c3f5..734603f0b3 100644 --- a/packages/server/src/ext/tasks/tasksExtension.ts +++ b/packages/server/src/ext/tasks/tasksExtension.ts @@ -94,7 +94,6 @@ const updateTaskHandlerParamsSchema = getTaskParamsSchema.extend({ inputResponse export class TasksExtension implements ServerExtension { readonly id = TASKS_EXTENSION_ID; - readonly capability = {}; readonly store: TaskStore; readonly #defaultTtlMs: number | null; readonly #defaultPollIntervalMs: number; From 100decd9af4a1bb5592582801808f30b247f919b Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 17 Sep 2026 16:16:27 +0200 Subject: [PATCH 6/6] refactor(tasks): use() middleware on tools/call --- docs/servers/tasks.md | 2 +- packages/server/src/ext/tasks/tasksExtension.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/servers/tasks.md b/docs/servers/tasks.md index 77bdb34a50..1d6eea2954 100644 --- a/docs/servers/tasks.md +++ b/docs/servers/tasks.md @@ -65,7 +65,7 @@ How the work behind a task runs — a queue, a workflow engine, a durable-execut ## Wire notes - The extension is served on the 2026-07-28 revision. Task tools are ordinary tools without `outputSchema`; the result encoder forwards `resultType: "task"` for `tools/call` verbatim. -- 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` override for any handle minted some other way. +- A request that does not declare `io.modelcontextprotocol/tasks` in its client capabilities is refused with `-32021` — from `tasks.create`, from the `tasks/*` methods, and by the extension's `tools/call` middleware for any handle minted some other way. - `tasks/update`'s `inputResponses` shares its name with the multi-round-trip retry field: the protocol layer lifts it out of the params and the extension reads it back from `ctx.mcpReq.inputResponses`. - The SDK `Client` rejects `resultType: "task"` on `tools/call` (typescript-sdk#2637); the requester half of the extension is `@modelcontextprotocol/ext-tasks`. - `notifications/tasks` over `subscriptions/listen` is not implemented (typescript-sdk#2569); polling only. diff --git a/packages/server/src/ext/tasks/tasksExtension.ts b/packages/server/src/ext/tasks/tasksExtension.ts index 734603f0b3..6f17fc40ca 100644 --- a/packages/server/src/ext/tasks/tasksExtension.ts +++ b/packages/server/src/ext/tasks/tasksExtension.ts @@ -154,7 +154,7 @@ export class TasksExtension implements ServerExtension { // `create(ctx, …)` checks up front; this catches handles minted some // other way (an external engine's own create) — the extension owns // the wire regardless of where the task came from. - server.overrideRequestHandler('tools/call', async (request: JSONRPCRequest, ctx, next): Promise => { + server.use('tools/call', async (request: JSONRPCRequest, ctx, next): Promise => { const result = await next(request, ctx); if ((result as { resultType?: unknown }).resultType === 'task') { requireTasksExtension(ctx, `Tool "${String((request.params as { name?: unknown })?.name)}" executes as a task and`);