From c49b7ba8bccbe6f5c62e8503a694e7a4e1e81b43 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/3] 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 13e869c0f2..2b0e949151 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -347,10 +347,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: @@ -684,9 +691,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 0a19770082..0f500f911f 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -559,6 +559,16 @@ export abstract class Protocol { private _transport?: Transport; private _requestMessageId = 0; private _requestHandlers: Map Promise> = 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(); @@ -994,11 +1004,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; } @@ -1268,10 +1294,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().`); @@ -1320,7 +1352,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)) { @@ -1735,6 +1771,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)); } @@ -1771,6 +1816,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 d52ef5537de8f6e52a41ed99dbf0eb0d4c660309 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 10 Sep 2026 17:58:00 +0100 Subject: [PATCH 2/3] feat(tasks): server-side Tasks extension package with a pluggable execution engine Adds @modelcontextprotocol/tasks: installTasks(server, { engine }) serves tasks/get, tasks/update and tasks/cancel as explicit-schema custom methods and returns registerTask, which runs a tool handler as a replayable workflow against a Step API (do, sleep, sleepUntil, elicit, offer, checkInput, status). Two interfaces keep handlers engine-invariant: TaskEngine (create / get / update / cancel) and StepJournal (what the step API drives). The package ships InMemoryTaskEngine as the in-process reference; durable engines implement the same two seams outside the SDK. Stacked on #2599 (explicit-schema handlers escape the era gate), which tasks/get and tasks/cancel need on the 2026-07-28 era. --- .changeset/tasks-extension-package.md | 5 + packages/tasks/README.md | 61 +++ packages/tasks/eslint.config.mjs | 5 + packages/tasks/package.json | 75 +++ packages/tasks/src/engine/backoff.ts | 20 + packages/tasks/src/engine/defaults.ts | 26 + packages/tasks/src/engine/duration.ts | 43 ++ packages/tasks/src/engine/errors.ts | 167 +++++++ packages/tasks/src/engine/executor.ts | 66 +++ packages/tasks/src/engine/inMemory.ts | 543 +++++++++++++++++++++ packages/tasks/src/engine/protocol.ts | 180 +++++++ packages/tasks/src/engine/serialization.ts | 57 +++ packages/tasks/src/engine/taskEngine.ts | 61 +++ packages/tasks/src/index.ts | 104 ++++ packages/tasks/src/server/installTasks.ts | 207 ++++++++ packages/tasks/src/server/registration.ts | 58 +++ packages/tasks/src/step/replayStep.ts | 337 +++++++++++++ packages/tasks/src/step/types.ts | 162 ++++++ packages/tasks/src/wire/schemas.ts | 177 +++++++ packages/tasks/src/wire/types.ts | 220 +++++++++ packages/tasks/test/inMemoryEngine.test.ts | 192 ++++++++ packages/tasks/test/tasks.e2e.test.ts | 239 +++++++++ packages/tasks/tsconfig.json | 23 + packages/tasks/tsdown.config.ts | 22 + packages/tasks/typedoc.json | 10 + packages/tasks/vitest.config.js | 3 + pnpm-lock.yaml | 55 +++ 27 files changed, 3118 insertions(+) create mode 100644 .changeset/tasks-extension-package.md create mode 100644 packages/tasks/README.md create mode 100644 packages/tasks/eslint.config.mjs create mode 100644 packages/tasks/package.json create mode 100644 packages/tasks/src/engine/backoff.ts create mode 100644 packages/tasks/src/engine/defaults.ts create mode 100644 packages/tasks/src/engine/duration.ts create mode 100644 packages/tasks/src/engine/errors.ts create mode 100644 packages/tasks/src/engine/executor.ts create mode 100644 packages/tasks/src/engine/inMemory.ts create mode 100644 packages/tasks/src/engine/protocol.ts create mode 100644 packages/tasks/src/engine/serialization.ts create mode 100644 packages/tasks/src/engine/taskEngine.ts create mode 100644 packages/tasks/src/index.ts create mode 100644 packages/tasks/src/server/installTasks.ts create mode 100644 packages/tasks/src/server/registration.ts create mode 100644 packages/tasks/src/step/replayStep.ts create mode 100644 packages/tasks/src/step/types.ts create mode 100644 packages/tasks/src/wire/schemas.ts create mode 100644 packages/tasks/src/wire/types.ts create mode 100644 packages/tasks/test/inMemoryEngine.test.ts create mode 100644 packages/tasks/test/tasks.e2e.test.ts create mode 100644 packages/tasks/tsconfig.json create mode 100644 packages/tasks/tsdown.config.ts create mode 100644 packages/tasks/typedoc.json create mode 100644 packages/tasks/vitest.config.js diff --git a/.changeset/tasks-extension-package.md b/.changeset/tasks-extension-package.md new file mode 100644 index 0000000000..03206c666f --- /dev/null +++ b/.changeset/tasks-extension-package.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/tasks': minor +--- + +New package: server-side MCP Tasks extension (`io.modelcontextprotocol/tasks`) with a pluggable execution engine. `installTasks(server, { engine })` serves `tasks/get`, `tasks/update` and `tasks/cancel` and returns `registerTask`, which is `registerTool` for long-running work: the handler runs as a replayable workflow against a `Step` API (`do`, `sleep`, `sleepUntil`, `elicit`, `offer`, `checkInput`, `status`). Engines implement two interfaces — `TaskEngine` (create / get / update / cancel) and `StepJournal` (what the step API drives) — and `InMemoryTaskEngine` is the in-process reference. Durable engines live outside the SDK. diff --git a/packages/tasks/README.md b/packages/tasks/README.md new file mode 100644 index 0000000000..f4fa098f93 --- /dev/null +++ b/packages/tasks/README.md @@ -0,0 +1,61 @@ +# `@modelcontextprotocol/tasks` + +Server-side [MCP Tasks extension](https://github.com/modelcontextprotocol/ext-tasks) (`io.modelcontextprotocol/tasks`) for `@modelcontextprotocol/server`, with a pluggable execution engine. + +`registerTask` is `registerTool` for long-running work: the handler runs as a replayable workflow (`step.do`, `step.sleep`, `step.elicit`, `step.status`, `step.offer`), and the extension's `tasks/get`, `tasks/update` and `tasks/cancel` methods route to per-task state that outlives the request that created it. + +```ts +import { McpServer } from '@modelcontextprotocol/server'; +import { InMemoryTaskEngine, installTasks } from '@modelcontextprotocol/tasks'; +import * as z from 'zod/v4'; + +const engine = new InMemoryTaskEngine(); + +export function createServer() { + const server = new McpServer({ name: 'report-server', version: '1.0.0' }); + const tasks = installTasks(server, { engine }); + + tasks.registerTask('send_report', { description: 'Compile and send a report', inputSchema: z.object({ to: z.string() }) }, async (input, step) => { + const report = await step.do('fetch-data', () => fetchReportData(input.to)); + await step.status(`compiled ${report.pages} pages`); + await step.sleep('cool-off', '5s'); + await step.do('send', { retries: { limit: 10 } }, () => sendReport(input.to, report)); + return { content: [{ type: 'text', text: `report sent to ${input.to}` }] }; + }); + + return server; +} +``` + +A `tools/call` of `send_report` from a client that declared the extension answers a task handle (`resultType: "task"`) immediately. The handler then runs on the engine; `tasks/get` polls it, `tasks/update` answers a `step.elicit`, `tasks/cancel` stops it at the next step. + +## Two seams + +Handlers never touch an engine directly. Two interfaces keep them engine-invariant: + +| Seam | Interface | Who calls it | +| ----------- | ------------- | -------------------------------------------------------------------------------- | +| **control** | `TaskEngine` | the `tasks/*` request handlers and the task tool: create / get / update / cancel | +| **step** | `StepJournal` | the replay-aware `Step` API while a handler runs | + +`InMemoryTaskEngine` implements both in-process and is the reference: task records and step journals in a `Map`, one timer per task computed from the rows, handlers run through the attached executor. State does not survive the process. + +A durable engine implements the same two interfaces and lives outside this package: `TaskEngine` over a database or durable-execution runtime, `StepJournal` over its journal rows, and either `attach`es the executor (`createTaskExecutor(tasks)`) to run handlers in-process or builds one where the handlers run. Swapping engines changes the `installTasks` call and nothing else. + +## Step API + +Step names are journal keys, unique per task. All side effects belong inside `step.do`; the handler body re-runs from the top on every resume, with completed steps returning their persisted results. + +- `step.do(name, [config], fn)` — journaled closure with per-step retries (`{ retries: { limit, baseDelayMs, maxDelayMs }, timeoutMs }`). Throw `NonRetryableError` to skip retries. Results must be JSON-serializable. +- `step.sleep(name, "5m" | ms)` / `step.sleepUntil(name, when)` — durable sleep; suspends the run, the engine resumes it. +- `step.elicit(name, inputRequest, [{ timeoutMs }])` — moves the task to `input_required` and suspends until `tasks/update` answers `name` (or the deadline passes, resolving `{ outcome: "timed_out" }`). +- `step.offer(key, inputRequest)` + `step.checkInput(name, key)` — a standing, non-blocking input channel: the task stays `working`; an answer wakes it. +- `step.status(message)` — writes `statusMessage` for pollers. The handler is its only writer. + +## Wire notes + +- The extension is served on the 2026-07-28 revision. The task tool is advertised as a normal tool 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`. For `tools/call` the SDK's tool dispatch surfaces that refusal as an `isError` tool result. +- `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 handler reads it back from `ctx.mcpReq.inputResponses`. +- Serving `tasks/get` and `tasks/cancel` needs the explicit-schema era-gate exemption from typescript-sdk#2599 (those names were 2025-11-25 core methods). +- 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/tasks/eslint.config.mjs b/packages/tasks/eslint.config.mjs new file mode 100644 index 0000000000..c1267b73c1 --- /dev/null +++ b/packages/tasks/eslint.config.mjs @@ -0,0 +1,5 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default [...baseConfig]; diff --git a/packages/tasks/package.json b/packages/tasks/package.json new file mode 100644 index 0000000000..7265722f77 --- /dev/null +++ b/packages/tasks/package.json @@ -0,0 +1,75 @@ +{ + "name": "@modelcontextprotocol/tasks", + "version": "0.1.0", + "description": "Model Context Protocol implementation for TypeScript - server-side Tasks extension (io.modelcontextprotocol/tasks) with a pluggable execution engine", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "tasks", + "workflow" + ], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.mts", + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "pnpm run build", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@modelcontextprotocol/core": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "workspace:^" + }, + "devDependencies": { + "@eslint/js": "catalog:devTools", + "@modelcontextprotocol/client": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsdown": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + } +} diff --git a/packages/tasks/src/engine/backoff.ts b/packages/tasks/src/engine/backoff.ts new file mode 100644 index 0000000000..b7324a2f3b --- /dev/null +++ b/packages/tasks/src/engine/backoff.ts @@ -0,0 +1,20 @@ +/* + * Backoff utilities for step and invocation retries. + * + * Adapted from avenceslau/durability, pinned at commit 78cb099 (v2.1.0): + * `packages/durability/src/utils.ts` (whole file). + * https://github.com/avenceslau/durability + */ + +/** + * Calculates a capped exponential retry delay. + * + * @param attempt One-based attempt number. + * @param initialDelayMs Delay after the first failed attempt. Defaults to 1 second. + * @param maxDelayMs Maximum returned delay. Defaults to 5 minutes. + */ +export const exponential = (attempt: number, initialDelayMs = 1000, maxDelayMs = 300_000): number => + Math.min(initialDelayMs * 2 ** (attempt - 1), maxDelayMs); + +/** Applies equal jitter, returning a value between half and all of the delay. */ +export const jitter = (delayMs: number): number => delayMs / 2 + Math.random() * (delayMs / 2); diff --git a/packages/tasks/src/engine/defaults.ts b/packages/tasks/src/engine/defaults.ts new file mode 100644 index 0000000000..7f9270786d --- /dev/null +++ b/packages/tasks/src/engine/defaults.ts @@ -0,0 +1,26 @@ +/** + * Engine defaults (the engine contract). All per-task configurable via + * `registerTask` config; steps can further override retries and timeout. + */ + +import type { RetryPolicy } from '../step/types'; + +/** Default task retention: 24 hours from creation. `null` disables the TTL. */ +export const DEFAULT_TTL_MS = 86_400_000; + +/** Default suggested polling interval: 5 seconds. */ +export const DEFAULT_POLL_INTERVAL_MS = 5000; + +/** Default per-attempt step closure timeout: 5 minutes. */ +export const DEFAULT_STEP_TIMEOUT_MS = 300_000; + +/** + * Default step retry policy: 5 total attempts, exponential backoff with + * jitter from a 1-second base to a 5-minute cap. `limit` counts claims — a + * crash after a claim consumes an attempt. + */ +export const DEFAULT_RETRY_POLICY = { + limit: 5, + baseDelayMs: 1000, + maxDelayMs: 300_000 +} as const satisfies Required; diff --git a/packages/tasks/src/engine/duration.ts b/packages/tasks/src/engine/duration.ts new file mode 100644 index 0000000000..94a1683396 --- /dev/null +++ b/packages/tasks/src/engine/duration.ts @@ -0,0 +1,43 @@ +/** + * Duration-string parsing for `step.sleep` (`"30s" | "5m" | "1h" | "2d"` or a + * plain number of milliseconds). + */ + +const UNIT_MS = { + ms: 1, + s: 1000, + m: 60_000, + h: 3_600_000, + d: 86_400_000 +} as const; + +export type DurationUnit = keyof typeof UNIT_MS; + +/** A duration literal such as `"30s"`, `"5m"`, `"1h"`, `"2d"`, or `"250ms"`. */ +export type DurationString = `${number}${DurationUnit}`; + +const DURATION_PATTERN = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/; + +/** + * Parses a duration into integer milliseconds. + * + * Numbers are taken as milliseconds and must be finite and non-negative. + * Strings must match `` with a non-negative value. + * Fractional results are rounded to the nearest millisecond. + * + * @throws RangeError on negative, non-finite, or malformed input. + */ +export function parseDuration(duration: number | DurationString): number { + if (typeof duration === 'number') { + if (!Number.isFinite(duration) || duration < 0) { + throw new RangeError(`Duration must be a non-negative finite number of milliseconds, got ${duration}`); + } + return Math.round(duration); + } + const match = DURATION_PATTERN.exec(duration); + if (match === null) { + throw new RangeError(`Invalid duration "${duration}" — expected forms like "30s", "5m", "1h", "2d", or "250ms"`); + } + const [, value = '', unit = 'ms'] = match; + return Math.round(Number(value) * UNIT_MS[unit as DurationUnit]); +} diff --git a/packages/tasks/src/engine/errors.ts b/packages/tasks/src/engine/errors.ts new file mode 100644 index 0000000000..55f59d72ee --- /dev/null +++ b/packages/tasks/src/engine/errors.ts @@ -0,0 +1,167 @@ +/* + * Error taxonomy for the durable task engine. + * + * Adapted from avenceslau/durability, pinned at commit 78cb099 (v2.1.0): + * `packages/durability/src/index.ts` — the error classes and the + * `serializeError` / `isNonRetryable` helpers (there defined inside + * `createDurability`; extracted to module scope here). Class names are + * re-mapped to this engine's vocabulary (steps and tasks instead of durable + * calls and named alarms). + * https://github.com/avenceslau/durability + */ + +/** + * Marks a handler failure as terminal so it is persisted without another retry. + * + * Use this for permanent failures such as invalid input. Transient errors + * should be thrown normally so the configured retry policy can handle them. + * + * Recognition is duck-typed by name ({@link isNonRetryable}), so the check + * survives the executor RPC boundary and also honors `cloudflare:workflows`' + * class of the same name. + */ +export class NonRetryableError extends Error { + constructor(message: string) { + super(message); + this.name = 'NonRetryableError'; + } +} + +/** + * Error recorded when a `step.do` closure attempt exceeds its configured + * timeout. The engine creates this error; step code does not throw it itself. + * Timeout failures follow the step's retry policy. + */ +export class StepTimeoutError extends Error { + constructor(stepKey: string, timeoutMs: number) { + super(`Step "${stepKey}" timed out after ${timeoutMs}ms`); + this.name = 'StepTimeoutError'; + } +} + +/** Error persisted when retry-delay evaluation fails or produces an unsafe value. */ +export class RetryPolicyError extends Error { + constructor(entity: string, reason?: unknown) { + super(`Retry policy for ${entity} must produce a non-negative safe-integer delay`); + this.name = 'RetryPolicyError'; + if (reason !== undefined) { + Object.defineProperty(this, 'cause', { value: reason }); + } + } +} + +/** Error persisted when a successful result cannot be JSON-serialized. */ +export class ResultSerializationError extends Error { + constructor(entity: string, reason?: unknown) { + super(`Result for ${entity} is not JSON-serializable`); + this.name = 'ResultSerializationError'; + if (reason !== undefined) { + Object.defineProperty(this, 'cause', { value: reason }); + } + } +} + +/** Error persisted when pending work has already reached its attempt limit. */ +export class AttemptsExhaustedError extends Error { + constructor(entity: string, maxAttempts: number) { + super(`${entity} exhausted its ${maxAttempts} attempts`); + this.name = 'AttemptsExhaustedError'; + } +} + +/** + * Error thrown when a step name is reused within a single task. + * + * Step names are the journal keys (unique per task, decision D8): calling + * `step.do` twice with one name in a single run is a hard error. Loops must + * suffix an index. + */ +export class DuplicateStepError extends Error { + constructor(stepKey: string) { + super( + `Step name "${stepKey}" was already used in this task. ` + + `Step names are journal keys and must be unique per task; suffix an index for loops.` + ); + this.name = 'DuplicateStepError'; + } +} + +/** A thrown value reduced to a JSON-safe `{name, message}` pair. */ +export interface SerializedError { + name: string; + message: string; +} + +/** + * Extracts `{name, message}` from an arbitrary thrown value, defending against + * hostile getters — a throwing `name`/`message` accessor cannot break the + * engine. + */ +export const serializeError = (error: unknown): SerializedError => { + let name = 'Error'; + let message = 'Unknown thrown value'; + try { + if (error === null) { + return { name, message: 'null' }; + } + if (typeof error !== 'object' && typeof error !== 'function') { + return { name, message: String(error) }; + } + try { + const candidate = Reflect.get(error, 'name'); + if (typeof candidate === 'string' && candidate.length > 0) { + name = candidate; + } + } catch { + name = 'Error'; + } + try { + const candidate = Reflect.get(error, 'message'); + if (typeof candidate === 'string' && candidate.length > 0) { + message = candidate; + } + } catch { + message = 'Unknown thrown value'; + } + return { name, message }; + } catch { + return { name: 'Error', message: 'Unknown thrown value' }; + } +}; + +const isErrorInstance = (error: unknown, constructor: new (...args: never[]) => Error): boolean => { + try { + return error instanceof constructor; + } catch { + return false; + } +}; + +/** + * Duck-typed non-retryable check: recognizes this module's + * {@link NonRetryableError}, any value whose `name` (or constructor name) is + * `"NonRetryableError"` — which covers instances that crossed an RPC boundary + * and `cloudflare:workflows`' class of the same name — while staying safe + * against hostile getters. + */ +export const isNonRetryable = (error: unknown): boolean => { + if (isErrorInstance(error, NonRetryableError)) { + return true; + } + if ((typeof error !== 'object' && typeof error !== 'function') || error === null) { + return false; + } + try { + if (Reflect.get(error, 'name') === 'NonRetryableError') { + return true; + } + const constructor = Reflect.get(error, 'constructor'); + return ( + constructor !== null && + (typeof constructor === 'object' || typeof constructor === 'function') && + Reflect.get(constructor, 'name') === 'NonRetryableError' + ); + } catch { + return false; + } +}; diff --git a/packages/tasks/src/engine/executor.ts b/packages/tasks/src/engine/executor.ts new file mode 100644 index 0000000000..4ca6ae56e9 --- /dev/null +++ b/packages/tasks/src/engine/executor.ts @@ -0,0 +1,66 @@ +/** + * `createTaskExecutor` — the engine-neutral half of running a task: resolve + * the handler from the registration table, wrap the engine's per-attempt + * journal in the replay-aware `Step`, run the handler, and map how it settled. + * + * A handler throw is journaled as a `completed` + `isError` result — exactly + * what the synchronous tool call would have returned. `failed` is reserved for + * engine-level errors (unknown task, serialization, an invalid retry policy). + */ + +import type { TaskRegistration } from '../server/registration'; +import { ReplayStep, SuspendSignal } from '../step/replayStep'; +import { ResultSerializationError, RetryPolicyError, serializeError } from './errors'; +import type { RunOutcome, StepJournal, TaskExecutor, TaskInvocation } from './protocol'; +import { isStaleLeaseError } from './protocol'; + +/** Where the executor looks handlers up. */ +export interface TaskRegistry { + getTaskRegistration(name: string): TaskRegistration | undefined; +} + +/** + * Builds the executor for a registry. A registry that resolves lazily (for + * example by constructing a fresh server per invocation) is fine — the lookup + * happens per `runTask`. + */ +export function createTaskExecutor(registry: TaskRegistry): TaskExecutor { + return { + async runTask(invocation: TaskInvocation, journal: StepJournal): Promise { + let registration: TaskRegistration | undefined; + try { + registration = registry.getTaskRegistration(invocation.taskName); + } catch (error) { + // A throwing registry is an engine failure, not a handler error. + return { outcome: 'failed', error: serializeError(error) }; + } + if (registration === undefined) { + return { + outcome: 'failed', + error: { name: 'UnknownTaskError', message: `No task named "${invocation.taskName}" is registered` } + }; + } + + const step = new ReplayStep(journal, invocation.taskId, registration.retries, invocation.attempt); + try { + const result = await registration.handler(invocation.input, step); + return { outcome: 'completed', result }; + } catch (error) { + if (error instanceof SuspendSignal || isStaleLeaseError(error)) { + // Sleep recorded / retry scheduled / input requested / + // cancelled — or the attempt was superseded. The engine + // already knows why; abandon this attempt. + return { outcome: 'suspended' }; + } + if (error instanceof ResultSerializationError || error instanceof RetryPolicyError) { + return { outcome: 'failed', error: serializeError(error) }; + } + const detail = serializeError(error); + return { + outcome: 'completed', + result: { content: [{ type: 'text', text: `${detail.name}: ${detail.message}` }], isError: true } + }; + } + } + }; +} diff --git a/packages/tasks/src/engine/inMemory.ts b/packages/tasks/src/engine/inMemory.ts new file mode 100644 index 0000000000..5f7f9b79ad --- /dev/null +++ b/packages/tasks/src/engine/inMemory.ts @@ -0,0 +1,543 @@ +/** + * `InMemoryTaskEngine` — the reference {@link TaskEngine}: task records and + * step journals in a `Map`, one timer per task computed from the rows (the + * same "the alarm is derived, never stored" shape a durable engine uses), and + * handlers run in-process through the attached executor. + * + * Suitable for tests, single-process servers, and as the model for a durable + * engine: every method here maps one-to-one onto a row write a persistent + * store would make. State does not survive the process. + */ + +import type { CallToolResult, InputRequests, InputResponses } from '@modelcontextprotocol/server'; + +import type { DetailedTask, Task, TaskStatus } from '../wire/types'; +import type { SerializedError } from './errors'; +import { DuplicateStepError } from './errors'; +import type { + BeginStepOptions, + BeginStepResult, + CheckInputState, + ElicitState, + SleepState, + StepFailureDisposition, + StepJournal, + TaskExecutor +} from './protocol'; +import { StaleLeaseError } from './protocol'; +import type { CreateTaskParams, TaskAccess, TaskEngine } from './taskEngine'; + +interface StepRow { + status: 'pending' | 'completed' | 'failed'; + attempt: number; + nextAttemptAt: number; + value?: unknown; + error?: SerializedError; +} + +interface SleepRow { + seq: number; + wakeAt: number; + completed: boolean; +} + +interface InputRow { + seq: number; + request: unknown; + blocking: boolean; + answered: boolean; + timedOut: boolean; + consumed: boolean; + response?: unknown; + timeoutAt?: number; +} + +interface TaskRecord { + taskId: string; + taskName: string; + input: unknown; + principal?: string; + status: TaskStatus; + statusMessage?: string; + createdAt: number; + lastUpdatedAt: number; + ttlMs: number | null; + pollIntervalMs: number; + result?: CallToolResult; + error?: SerializedError; + cancelRequested: boolean; + /** Run attempt counter (claims). */ + attempt: number; + /** Generation of the attempt that currently owns the journal. */ + generation: number; + running: boolean; + /** Earliest time the next run is due, or `undefined` when nothing is pending. */ + runNextAt: number | undefined; + /** An offer answer landed mid-run: the next recorded sleep resolves at once. */ + cutNextSleep: boolean; + seq: number; + steps: Map; + sleeps: Map; + inputs: Map; + checks: Map; + timer: ReturnType | undefined; +} + +const TERMINAL: ReadonlySet = new Set(['completed', 'failed', 'cancelled']); + +/** Options for {@link InMemoryTaskEngine}. */ +export interface InMemoryTaskEngineOptions { + /** Task id factory. Default `crypto.randomUUID()`. */ + createTaskId?: () => string; + /** Clock, for tests. Default `Date.now`. */ + now?: () => number; +} + +export class InMemoryTaskEngine implements TaskEngine { + readonly #tasks = new Map(); + readonly #createTaskId: () => string; + readonly #now: () => number; + #executor: TaskExecutor | undefined; + + constructor(options?: InMemoryTaskEngineOptions) { + this.#createTaskId = options?.createTaskId ?? (() => crypto.randomUUID()); + this.#now = options?.now ?? (() => Date.now()); + } + + attach(executor: TaskExecutor): void { + this.#executor = executor; + } + + /** Clears every timer and forgets every task. For tests and shutdown. */ + close(): void { + for (const record of this.#tasks.values()) { + if (record.timer !== undefined) clearTimeout(record.timer); + } + 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 (!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(), + taskName: params.taskName, + input: params.input, + ...(params.principal !== undefined && { principal: params.principal }), + status: 'working', + createdAt: now, + lastUpdatedAt: now, + ttlMs: params.ttlMs, + pollIntervalMs: params.pollIntervalMs, + cancelRequested: false, + attempt: 0, + generation: 0, + running: false, + runNextAt: now, + cutNextSleep: false, + seq: 0, + steps: new Map(), + sleeps: new Map(), + inputs: new Map(), + checks: new Map(), + timer: undefined + }; + this.#tasks.set(record.taskId, record); + this.#reconcile(record); + return this.#baseSnapshot(record); + } + + async get(taskId: string, access?: TaskAccess): Promise { + const record = this.#lookup(taskId, access); + return record === undefined ? undefined : this.#detailedSnapshot(record); + } + + async update(taskId: string, inputResponses: InputResponses, access?: TaskAccess): Promise { + const record = this.#lookup(taskId, access); + if (record === undefined) return false; + if (TERMINAL.has(record.status)) return true; + let wake = false; + for (const [key, response] of Object.entries(inputResponses)) { + const row = record.inputs.get(key); + // Unknown keys are ignored; the first answer to a key wins. + if (row === undefined || row.answered) continue; + row.answered = true; + row.response = response; + wake = true; + if (!row.blocking) { + // An offer answer cuts a pending sleep short so the handler + // can react; mid-run, the next recorded sleep is cut instead. + if (record.running) { + record.cutNextSleep = true; + } else { + for (const sleep of record.sleeps.values()) { + if (!sleep.completed) sleep.completed = true; + } + } + } + } + if (!wake) return true; + this.#touch(record); + if (this.#outstandingBlocking(record).length === 0) { + record.status = 'working'; + record.runNextAt = this.#now(); + } + this.#reconcile(record); + 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.cancelRequested = true; + if (!record.running) { + // Nothing is executing: settle now instead of waiting for a wake. + this.#settle(record, 'cancelled'); + } + return true; + } + + // ------------------------------------------------------------ scheduling -- + + #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(); + } + + #outstandingBlocking(record: TaskRecord): Array<[string, InputRow]> { + return [...record.inputs].filter(([, row]) => row.blocking && !row.answered); + } + + #ttlDeadline(record: TaskRecord): number | undefined { + return record.ttlMs === null ? undefined : record.createdAt + record.ttlMs; + } + + /** The earliest pending wake among step retries and sleeps, plus `runNextAt`. */ + #earliestExecutionWake(record: TaskRecord): number | undefined { + const candidates: number[] = []; + if (record.runNextAt !== undefined) candidates.push(record.runNextAt); + for (const step of record.steps.values()) { + if (step.status === 'pending' && step.attempt > 0) candidates.push(step.nextAttemptAt); + } + for (const sleep of record.sleeps.values()) { + if (!sleep.completed) candidates.push(sleep.wakeAt); + } + return candidates.length === 0 ? undefined : Math.min(...candidates); + } + + #earliestElicitDeadline(record: TaskRecord): number | undefined { + const deadlines = this.#outstandingBlocking(record) + .map(([, row]) => row.timeoutAt) + .filter((value): value is number => value !== undefined); + return deadlines.length === 0 ? undefined : Math.min(...deadlines); + } + + /** Recomputes the single timer from the rows. Runs after every scheduling-relevant write. */ + #reconcile(record: TaskRecord): void { + if (record.timer !== undefined) { + clearTimeout(record.timer); + record.timer = undefined; + } + const candidates: number[] = []; + const ttl = this.#ttlDeadline(record); + if (ttl !== undefined) candidates.push(ttl); + if (!TERMINAL.has(record.status) && !record.running) { + const wake = record.status === 'input_required' ? undefined : this.#earliestExecutionWake(record); + if (wake !== undefined) candidates.push(wake); + const deadline = this.#earliestElicitDeadline(record); + if (deadline !== undefined) candidates.push(deadline); + } + if (candidates.length === 0) return; + const delay = Math.max(0, Math.min(...candidates) - this.#now()); + record.timer = setTimeout(() => { + record.timer = undefined; + void this.#tick(record); + }, delay); + record.timer.unref?.(); + } + + async #tick(record: TaskRecord): Promise { + const now = this.#now(); + const ttl = this.#ttlDeadline(record); + if (ttl !== undefined && ttl <= now) { + this.#tasks.delete(record.taskId); + return; + } + if (TERMINAL.has(record.status) || record.running) { + this.#reconcile(record); + return; + } + // Elicit deadlines that elapsed: answered-by-timeout, back to working. + let resumed = false; + for (const row of this.#outstandingBlocking(record).map(([, row]) => row)) { + if (row.timeoutAt !== undefined && row.timeoutAt <= now) { + row.answered = true; + row.timedOut = true; + resumed = true; + } + } + if (resumed && this.#outstandingBlocking(record).length === 0) { + record.status = 'working'; + record.runNextAt = now; + this.#touch(record); + } + if (record.status === 'working') { + const wake = this.#earliestExecutionWake(record); + if (wake !== undefined && wake <= now) { + await this.#run(record); + return; + } + } + this.#reconcile(record); + } + + async #run(record: TaskRecord): Promise { + const executor = this.#executor; + if (executor === undefined) { + this.#settle(record, 'failed', { name: 'EngineNotAttached', message: 'No task executor is attached to the engine' }); + return; + } + record.running = true; + record.runNextAt = undefined; + record.attempt += 1; + record.generation += 1; + const generation = record.generation; + const journal = this.#journal(record, generation); + let outcome; + try { + outcome = await executor.runTask( + { taskId: record.taskId, taskName: record.taskName, input: record.input, attempt: record.attempt }, + journal + ); + } catch (error) { + outcome = { outcome: 'failed' as const, error: { name: 'ExecutorError', message: String(error) } }; + } + if (record.generation !== generation || !this.#tasks.has(record.taskId)) return; // superseded or purged + record.running = false; + switch (outcome.outcome) { + case 'completed': { + this.#settle(record, 'completed', undefined, outcome.result); + return; + } + case 'failed': { + this.#settle(record, 'failed', outcome.error); + return; + } + case 'suspended': { + if (record.cancelRequested) { + this.#settle(record, 'cancelled'); + return; + } + if (this.#outstandingBlocking(record).length > 0) { + record.status = 'input_required'; + this.#touch(record); + } + this.#reconcile(record); + } + } + } + + #settle(record: TaskRecord, status: 'completed' | 'failed' | 'cancelled', error?: SerializedError, result?: CallToolResult): void { + record.status = status; + record.running = false; + record.runNextAt = undefined; + if (result !== undefined) record.result = result; + if (error !== undefined) record.error = error; + this.#touch(record); + this.#reconcile(record); + } + + // --------------------------------------------------------------- journal -- + + #journal(record: TaskRecord, generation: number): StepJournal { + const guard = (): void => { + if (!this.#tasks.has(record.taskId)) throw new StaleLeaseError(record.taskId, 'task purged'); + if (record.generation !== generation) throw new StaleLeaseError(record.taskId, 'attempt superseded'); + if (TERMINAL.has(record.status)) throw new StaleLeaseError(record.taskId, `task is ${record.status}`); + }; + const latestSuspension = (): string | undefined => { + let best: { key: string; seq: number } | undefined; + for (const [key, row] of record.sleeps) { + if (best === undefined || row.seq > best.seq) best = { key, seq: row.seq }; + } + for (const [key, row] of record.inputs) { + if (row.blocking && (best === undefined || row.seq > best.seq)) best = { key, seq: row.seq }; + } + return best?.key; + }; + const now = this.#now; + return { + taskId: record.taskId, + attempt: record.attempt, + async beginStep(stepKey: string, _options?: BeginStepOptions): Promise { + guard(); + if (record.cancelRequested) return { state: 'cancelled' }; + const row = record.steps.get(stepKey); + if (row === undefined) { + record.steps.set(stepKey, { status: 'pending', attempt: 1, nextAttemptAt: now() }); + return { state: 'run', attempt: 1 }; + } + if (row.status === 'completed') return { state: 'completed', value: row.value }; + if (row.status === 'failed') return { state: 'failed', error: row.error ?? { name: 'Error', message: 'step failed' } }; + row.attempt += 1; + return { state: 'run', attempt: row.attempt }; + }, + async completeStep(stepKey: string, value: unknown): Promise { + guard(); + const row = record.steps.get(stepKey); + if (row === undefined || row.status !== 'pending') return false; + row.status = 'completed'; + row.value = value; + return true; + }, + async failStep(stepKey: string, error: SerializedError, disposition: StepFailureDisposition): Promise { + guard(); + const row = record.steps.get(stepKey); + if (row === undefined || row.status !== 'pending') return false; + row.error = error; + if ('terminal' in disposition) { + row.status = 'failed'; + } else { + row.nextAttemptAt = disposition.retryAtMs; + } + return true; + }, + async recordSleep(stepKey: string, wakeAtMs: number): Promise { + guard(); + let row = record.sleeps.get(stepKey); + if (row === undefined) { + row = { seq: ++record.seq, wakeAt: wakeAtMs, completed: false }; + record.sleeps.set(stepKey, row); + if (record.cutNextSleep) { + record.cutNextSleep = false; + row.completed = true; + return { state: 'completed', latest: true }; + } + if (wakeAtMs > now()) return { state: 'pending' }; + row.completed = true; + return { state: 'completed', latest: true }; + } + if (!row.completed) { + if (row.wakeAt > now()) return { state: 'pending' }; + row.completed = true; + } + return { state: 'completed', latest: latestSuspension() === stepKey }; + }, + async recordElicit(stepKey: string, request: unknown, timeoutAtMs?: number): Promise { + guard(); + let row = record.inputs.get(stepKey); + if (row === undefined) { + row = { + seq: ++record.seq, + request, + blocking: true, + answered: false, + timedOut: false, + consumed: false, + ...(timeoutAtMs !== undefined && { timeoutAt: timeoutAtMs }) + }; + record.inputs.set(stepKey, row); + return { state: 'pending' }; + } + if (!row.blocking) throw new DuplicateStepError(stepKey); + if (!row.answered) return { state: 'pending' }; + const latest = latestSuspension() === stepKey; + return row.timedOut ? { state: 'timed_out', latest } : { state: 'answered', response: row.response, latest }; + }, + async recordOffer(key: string, request: unknown): Promise { + guard(); + const row = record.inputs.get(key); + if (row !== undefined) { + if (row.blocking) throw new DuplicateStepError(key); + return; // replay: the first recorded offer stands + } + record.inputs.set(key, { + seq: ++record.seq, + request, + blocking: false, + answered: false, + timedOut: false, + consumed: false + }); + }, + async checkInput(stepKey: string, key: string): Promise { + guard(); + const journaled = record.checks.get(stepKey); + if (journaled !== undefined) return journaled; + const row = record.inputs.get(key); + if (row === undefined || row.blocking) { + throw new Error(`step.checkInput("${stepKey}"): "${key}" is not a registered offer`); + } + const state: CheckInputState = + row.answered && !row.consumed ? { state: 'answered', response: row.response } : { state: 'unanswered' }; + if (state.state === 'answered') row.consumed = true; + record.checks.set(stepKey, state); + return state; + }, + async setStatus(message: string): Promise { + guard(); + record.statusMessage = message; + record.lastUpdatedAt = now(); + }, + async checkCancel(): Promise { + guard(); + return record.cancelRequested; + } + }; + } + + // ------------------------------------------------------------- snapshots -- + + #baseSnapshot(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, + pollIntervalMs: record.pollIntervalMs + }; + } + + #detailedSnapshot(record: TaskRecord): DetailedTask { + const base = this.#baseSnapshot(record); + switch (record.status) { + case 'working': { + return { ...base, status: 'working' }; + } + case 'input_required': { + const inputRequests: InputRequests = {}; + for (const [key, row] of this.#outstandingBlocking(record)) { + inputRequests[key] = row.request as InputRequests[string]; + } + 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: { code: -32_603, message: record.error?.message ?? 'Task failed', data: { name: record.error?.name } } + }; + } + case 'cancelled': { + return { ...base, status: 'cancelled' }; + } + } + } +} diff --git a/packages/tasks/src/engine/protocol.ts b/packages/tasks/src/engine/protocol.ts new file mode 100644 index 0000000000..646d2bcaca --- /dev/null +++ b/packages/tasks/src/engine/protocol.ts @@ -0,0 +1,180 @@ +/** + * The engine <-> executor protocol: the shapes that cross between whatever + * runs a task's handler (the executor, built from the task registrations) and + * whatever owns the task's durable state (the engine). Kept in one module so + * engines and the executor share them without importing each other. + * + * Everything here must survive structured serialization: plain JSON in, plain + * JSON out. The one capability that crosses as an object is the per-attempt + * {@link StepJournal}, the surface the replay-aware step API drives. + */ + +import type { CallToolResult } from '@modelcontextprotocol/server'; + +import type { SerializedError } from './errors'; + +/** One claimed execution attempt, as dispatched by the engine. */ +export interface TaskInvocation { + taskId: string; + taskName: string; + input: unknown; + /** The task-level claim counter (`1` for the first run). */ + attempt: number; +} + +/** How a `runTask` invocation settled. */ +export type RunOutcome = + /** The handler returned; the engine persists the result and completes the task. */ + | { outcome: 'completed'; result: CallToolResult } + /** Sleep recorded / step retry scheduled / input requested / cancelled — the engine already knows why. */ + | { outcome: 'suspended' } + /** Engine-level failure only (e.g. unknown task) — never a handler throw. */ + | { outcome: 'failed'; error: SerializedError }; + +/** Directive returned by `beginStep`: what the executor should do with a step. */ +export type BeginStepResult = + /** Journal miss (or pending retry): run the closure as this step attempt. */ + | { state: 'run'; attempt: number } + /** Journal hit: the persisted result, closure MUST NOT run. */ + | { state: 'completed'; value: unknown } + /** The step already failed terminally in an earlier attempt. */ + | { state: 'failed'; error: SerializedError } + /** Cancellation was requested: abort the invocation (suspend). */ + | { state: 'cancelled' }; + +/** + * State of a journaled sleep after `recordSleep`. + * + * `latest` on a completed sleep / resolved elicit: this row is the LAST + * suspension point the previous run recorded. A resumed handler replays + * earlier suspension points as plain hits; the latest one is where it goes + * back on new ground (and `step.status` starts writing again). + */ +export type SleepState = { state: 'pending' } | { state: 'completed'; latest: boolean }; + +/** + * State of an input request after `recordElicit`. `timed_out` means the + * request's deadline elapsed unanswered: the engine marked it + * answered-by-timeout (late `tasks/update` responses to the key are ignored) + * and the replay resolves the elicit with a timeout outcome instead of a + * response. + */ +export type ElicitState = + | { state: 'pending' } + | { state: 'answered'; response: unknown; latest: boolean } + | { state: 'timed_out'; latest: boolean }; + +/** + * Result of a journaled `checkInput` against a standing (non-blocking) offer: + * the offer's answer, consumed by this step (or journaled by it earlier — + * replays observe the same value), or nothing to consume. Never suspends. + */ +export type CheckInputState = { state: 'answered'; response: unknown } | { state: 'unanswered' }; + +/** How a failed `step.do` attempt should be disposed of. */ +export type StepFailureDisposition = + /** Retry: the engine redelivers at `retryAtMs` (the executor computed the backoff). */ + | { retryAtMs: number } + /** Terminal: no further attempts; the step is marked `failed`. */ + | { terminal: true }; + +/** Options recorded when a `do` step first enters the journal. */ +export interface BeginStepOptions { + /** Per-attempt closure timeout, ms (journaled for observability). */ + timeoutMs?: number; +} + +/** + * The per-attempt journal an engine hands to `runTask`: constructed for + * exactly one execution attempt, every write is guarded by that attempt's + * generation. A superseded attempt's calls throw {@link StaleLeaseError}. + * Handler code never sees it — the replay-aware `Step` wraps it. + */ +export interface StepJournal { + /** The task this attempt belongs to. */ + readonly taskId: string; + /** The claim counter of the attempt this journal was minted for. */ + readonly attempt: number; + beginStep(stepKey: string, options?: BeginStepOptions): Promise; + completeStep(stepKey: string, value: unknown): Promise; + failStep(stepKey: string, error: SerializedError, disposition: StepFailureDisposition): Promise; + recordSleep(stepKey: string, wakeAtMs: number): Promise; + /** + * Journals an input request. `timeoutAtMs` (ms epoch) is the answer + * deadline, stored with the request on first record and immutable across + * replays — recomputed deadlines from later invocations are ignored. + * Omitted = no deadline (waits forever). + */ + recordElicit(stepKey: string, request: unknown, timeoutAtMs?: number): Promise; + /** + * Registers a standing, NON-blocking input request (`step.offer`) under a + * lifetime-unique key without suspending: the task stays `working` and + * the offer never appears in `tasks/get` `inputRequests`. Journal-safe: a + * replay's re-offer of the same key finds the existing row. A key already + * used by a blocking elicit throws `DuplicateStepError`. + */ + recordOffer(key: string, request: unknown): Promise; + /** + * Journaled, non-blocking consume (`step.checkInput`) of the offer under + * `key`, as the step named `stepKey`: an unconsumed answer is returned and + * marked consumed; otherwise the step journals a miss. Either outcome is + * journaled under `stepKey`, so a replay observes the same value. Throws + * for a key that is not a registered offer. + */ + checkInput(stepKey: string, key: string): Promise; + /** + * Durable handler telemetry (`step.status`): writes the task's + * `statusMessage`. The handler is the single writer — the engine never + * narrates its own transitions. Not a journal write: replays may deliver + * the same message again, harmlessly. A no-op once the task is terminal. + */ + setStatus(message: string): Promise; + checkCancel(): Promise; +} + +/** + * The executor surface an engine dispatches to: runs one attempt of a task's + * handler against a journal and reports how it settled. Built from the task + * registrations by `createTaskExecutor`; engines that run handlers in-process + * receive it through `TaskEngine.attach`. + */ +export interface TaskExecutor { + runTask(invocation: TaskInvocation, journal: StepJournal): Promise; +} + +/** + * Thrown by journal methods when the calling attempt no longer owns the task + * — its generation was superseded by a newer claim, the task reached a + * terminal state, or the task was purged. The executor abandons the attempt; + * the engine re-drives with a fresh journal. + */ +export class StaleLeaseError extends Error { + constructor(taskId: string, detail: string) { + super(`Stale lease for task "${taskId}": ${detail}`); + this.name = 'StaleLeaseError'; + } +} + +/** + * Duck-typed {@link StaleLeaseError} check for the executor side of an RPC + * boundary: matches an instance, the preserved `name`, or the distinctive + * message prefix (whichever survives serialization), defensively against + * hostile getters. + */ +export const isStaleLeaseError = (error: unknown): boolean => { + if (error instanceof StaleLeaseError) { + return true; + } + if ((typeof error !== 'object' && typeof error !== 'function') || error === null) { + return false; + } + try { + if (Reflect.get(error, 'name') === 'StaleLeaseError') { + return true; + } + const message = Reflect.get(error, 'message'); + return typeof message === 'string' && message.startsWith('Stale lease for task'); + } catch { + return false; + } +}; diff --git a/packages/tasks/src/engine/serialization.ts b/packages/tasks/src/engine/serialization.ts new file mode 100644 index 0000000000..eaf2107cdc --- /dev/null +++ b/packages/tasks/src/engine/serialization.ts @@ -0,0 +1,57 @@ +/* + * Undefined-safe JSON serialization envelope for journaled step results. + * + * Adapted from avenceslau/durability, pinned at commit 78cb099 (v2.1.0): + * `packages/durability/src/index.ts` — `storedValueSchema` and the + * `serialize`/`deserialize` pair. Local adaptation: non-JSON-serializable + * values are wrapped in {@link ResultSerializationError} here at the envelope + * (upstream wrapped at the call site). + * https://github.com/avenceslau/durability + */ + +import { z } from 'zod'; + +import { ResultSerializationError } from './errors'; + +/** + * The stored envelope: a plain JSON value, or an explicit `undefined` marker. + * `z.json()` guarantees the `value` branch holds only JSON-representable data + * (no functions, bigints, NaN/Infinity, or `undefined` — including nested + * `undefined` property values, which are rejected rather than silently + * dropped). + */ +export const storedValueSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('value'), value: z.json() }), + z.object({ kind: z.literal('undefined') }) +]); + +export type StoredValue = z.output; + +/** + * Serializes a step result into the undefined-safe envelope. + * + * @param value The value to persist. `undefined` round-trips faithfully. + * @param entity Label used in the {@link ResultSerializationError} message, + * e.g. `step "fetch-data"`. + * @throws ResultSerializationError when the value is not JSON-serializable. + */ +export const serializeValue = (value: unknown, entity = 'value'): string => { + try { + return JSON.stringify(value === undefined ? { kind: 'undefined' } : { kind: 'value', value: z.json().parse(value) }); + } catch (error) { + throw new ResultSerializationError(entity, error); + } +}; + +/** + * Deserializes an envelope produced by {@link serializeValue}. + * + * @throws ZodError (via `storedValueSchema.parse`) when the stored text is not + * a valid envelope — a corrupt journal row is a programming error, not user + * input. + */ +export const deserializeValue = (text: string): unknown => { + const parsed: unknown = JSON.parse(text); + const stored = storedValueSchema.parse(parsed); + return stored.kind === 'undefined' ? undefined : stored.value; +}; diff --git a/packages/tasks/src/engine/taskEngine.ts b/packages/tasks/src/engine/taskEngine.ts new file mode 100644 index 0000000000..5b175973d7 --- /dev/null +++ b/packages/tasks/src/engine/taskEngine.ts @@ -0,0 +1,61 @@ +/** + * The control seam: what the `tasks/*` request handlers and the task tool's + * `tools/call` handler call. Everything here is request/response over JSON, + * so any execution engine — in-process timers, a database plus a worker + * pool, a durable-execution runtime — can satisfy it. Engines never see wire + * shapes beyond the `DetailedTask` snapshot they return. + */ + +import type { InputResponses } from '@modelcontextprotocol/server'; + +import type { DetailedTask, Task } from '../wire/types'; +import type { TaskExecutor } from './protocol'; + +/** What the tool handler asks an engine to durably create and schedule. */ +export interface CreateTaskParams { + /** The registered task name (the tool name). */ + taskName: string; + /** Schema-validated tool arguments. */ + input: unknown; + /** Retention from creation, ms; `null` = unlimited. */ + ttlMs: number | null; + /** Suggested client polling interval, ms. */ + pollIntervalMs: number; + /** + * Opportunistic auth binding: the principal the creating request was + * authenticated as, when the transport knows one. Engines MUST refuse + * `tasks/*` access from a different principal (fail closed, no existence + * leak) and MAY ignore it when absent. + */ + principal?: string; +} + +/** The caller identity presented on a `tasks/*` request. */ +export interface TaskAccess { + principal?: string; +} + +/** + * A task execution engine. Engine selection is configuration: handler + * bodies and `registerTask` calls are byte-identical across engines. + * + * - `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 caller answers `-32602`. + * - `cancel` is cooperative: it resolves on acknowledgement, the task may + * still settle `completed` or `failed`. Idempotent on terminal tasks. + */ +export interface TaskEngine { + create(params: CreateTaskParams): Promise; + get(taskId: string, access?: TaskAccess): Promise; + update(taskId: string, inputResponses: InputResponses, access?: TaskAccess): Promise; + cancel(taskId: string, access?: TaskAccess): Promise; + /** + * Engines that run handlers in the same process receive the executor + * here when the tasks are installed on a server. Engines whose handlers + * run elsewhere (a separate worker, another runtime) omit it and build + * their own executor from the same registrations. + */ + attach?(executor: TaskExecutor): void; +} diff --git a/packages/tasks/src/index.ts b/packages/tasks/src/index.ts new file mode 100644 index 0000000000..a4f338c10c --- /dev/null +++ b/packages/tasks/src/index.ts @@ -0,0 +1,104 @@ +/** + * `@modelcontextprotocol/tasks` — server-side MCP Tasks extension + * (`io.modelcontextprotocol/tasks`) with a pluggable execution engine. + * + * Two seams keep handlers engine-invariant: + * + * - the control seam ({@link TaskEngine}): create / get / update / cancel, + * called by the `tasks/*` request handlers and the task tool; + * - the step seam ({@link StepJournal}): what the replay-aware {@link Step} + * API drives while a handler runs. + * + * {@link InMemoryTaskEngine} is the reference engine. Durable engines + * (a database plus workers, a durable-execution runtime) implement the same + * two interfaces and live outside this package. + */ + +export { DEFAULT_POLL_INTERVAL_MS, DEFAULT_RETRY_POLICY, DEFAULT_STEP_TIMEOUT_MS, DEFAULT_TTL_MS } from './engine/defaults'; +export type { DurationString, DurationUnit } from './engine/duration'; +export { parseDuration } from './engine/duration'; +export type { SerializedError } from './engine/errors'; +export { + AttemptsExhaustedError, + DuplicateStepError, + isNonRetryable, + NonRetryableError, + ResultSerializationError, + RetryPolicyError, + serializeError, + StepTimeoutError +} from './engine/errors'; +export type { TaskRegistry } from './engine/executor'; +export { createTaskExecutor } from './engine/executor'; +export type { InMemoryTaskEngineOptions } from './engine/inMemory'; +export { InMemoryTaskEngine } from './engine/inMemory'; +export type { + BeginStepOptions, + BeginStepResult, + CheckInputState, + ElicitState, + RunOutcome, + SleepState, + StepFailureDisposition, + StepJournal, + TaskExecutor, + TaskInvocation +} from './engine/protocol'; +export { isStaleLeaseError, StaleLeaseError } from './engine/protocol'; +export type { CreateTaskParams, TaskAccess, TaskEngine } from './engine/taskEngine'; +export type { InstallTasksOptions, Tasks } from './server/installTasks'; +export { installTasks } from './server/installTasks'; +export type { RegisteredTask, TaskConfig, TaskHandler, TaskInput, TaskRegistration } from './server/registration'; +export { ReplayStep, SuspendSignal } from './step/replayStep'; +export type { ElicitConfig, ElicitOutcome, JsonSerializable, RetryPolicy, Step, StepConfig } from './step/types'; +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/tasks/src/server/installTasks.ts b/packages/tasks/src/server/installTasks.ts new file mode 100644 index 0000000000..ab6df3da1d --- /dev/null +++ b/packages/tasks/src/server/installTasks.ts @@ -0,0 +1,207 @@ +/** + * `installTasks` — wires the Tasks extension (`io.modelcontextprotocol/tasks`) + * onto an `McpServer` for a chosen {@link TaskEngine}, and returns the + * `registerTask` surface. It is `registerTool` for long-running work: the + * handler runs as a replayable workflow against the engine, and the + * extension's `tasks/get`, `tasks/update` and `tasks/cancel` methods are + * served as explicit-schema custom methods that route to the engine. + * + * The tool's `tools/call` handler answers a flat `CreateTaskResult` + * (`resultType: "task"`), which the 2026-07-28 encode seam passes through + * verbatim for `tools/call`. A client that did not declare the extension on + * the request is refused with `MissingRequiredClientCapability` (`-32021`). + */ + +import type { + CallToolResult, + InputResponses, + McpServer, + ServerContext, + StandardSchemaWithJSON, + ToolAnnotations +} from '@modelcontextprotocol/server'; +import { + CLIENT_CAPABILITIES_META_KEY, + MissingRequiredClientCapabilityError, + ProtocolError, + ProtocolErrorCode +} from '@modelcontextprotocol/server'; + +import { DEFAULT_POLL_INTERVAL_MS, DEFAULT_RETRY_POLICY, DEFAULT_TTL_MS } from '../engine/defaults'; +import { createTaskExecutor } from '../engine/executor'; +import type { TaskEngine } from '../engine/taskEngine'; +import { cancelTaskParamsSchema, getTaskParamsSchema, inputResponsesSchema } from '../wire/schemas'; +import type { CancelTaskResult, CreateTaskResult, GetTaskResult, UpdateTaskResult } from '../wire/types'; +import { TASKS_EXTENSION_ID } from '../wire/types'; +import type { AnyTaskHandler, RegisteredTask, TaskConfig, TaskHandler, TaskRegistration } from './registration'; + +/** Options for {@link installTasks}. */ +export interface InstallTasksOptions { + /** The execution engine every task registered on this server runs on. */ + engine: TaskEngine; +} + +/** The task registration surface returned by {@link installTasks}. */ +export interface Tasks { + readonly engine: TaskEngine; + /** + * Registers a task. It is advertised as a normal tool (no `outputSchema`); + * a `tools/call` from a client that declared the tasks extension returns a + * `CreateTaskResult` immediately while the handler runs on the engine. + */ + registerTask( + name: string, + config: TaskConfig, + handler: TaskHandler + ): RegisteredTask; + /** Resolved registration for an executor. */ + getTaskRegistration(name: string): TaskRegistration | undefined; + /** The registered task names. */ + readonly taskNames: string[]; +} + +/** Whether this request's `_meta` envelope declared the tasks extension. */ +const declaredTasksExtension = (ctx: ServerContext): boolean => { + const envelope = ctx.mcpReq?.envelope as Record | undefined; + const capabilities = envelope?.[CLIENT_CAPABILITIES_META_KEY]; + if (capabilities === null || typeof capabilities !== 'object') return false; + const extensions = (capabilities as Record)['extensions']; + return extensions !== null && typeof extensions === 'object' && TASKS_EXTENSION_ID in extensions; +}; + +const requireTasksExtension = (ctx: ServerContext, what: string): void => { + if (declaredTasksExtension(ctx)) return; + throw new MissingRequiredClientCapabilityError( + { requiredCapabilities: { extensions: { [TASKS_EXTENSION_ID]: {} } } }, + `${what} requires the request to declare the "${TASKS_EXTENSION_ID}" extension capability` + ); +}; + +/** + * `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() }); + +const principalOf = (ctx: ServerContext): string | undefined => ctx.http?.authInfo?.clientId; + +const notFound = (): never => { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Task not found'); +}; + +/** + * Installs the Tasks extension on `server` against `engine` and returns the + * `registerTask` surface. Call once per server instance; the extension + * capability is advertised once the first task is registered. + */ +export function installTasks(server: McpServer, options: InstallTasksOptions): Tasks { + const { engine } = options; + const registrations = new Map(); + const registry = { getTaskRegistration: (name: string) => registrations.get(name) }; + engine.attach?.(createTaskExecutor(registry)); + + server.server.setRequestHandler('tasks/get', { params: getTaskParamsSchema }, async (params, ctx): Promise => { + requireTasksExtension(ctx, 'tasks/get'); + const task = await engine.get(params.taskId, { principal: principalOf(ctx) }); + if (task === undefined) return notFound(); + return { resultType: 'complete', ...task }; + }); + + server.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 engine.update(params.taskId, inputResponses, { principal: principalOf(ctx) }); + if (!found) return notFound(); + return { resultType: 'complete' }; + } + ); + + server.server.setRequestHandler('tasks/cancel', { params: cancelTaskParamsSchema }, async (params, ctx): Promise => { + requireTasksExtension(ctx, 'tasks/cancel'); + const found = await engine.cancel(params.taskId, { principal: principalOf(ctx) }); + if (!found) return notFound(); + return { resultType: 'complete' }; + }); + + const createTask = async (registration: TaskRegistration, input: unknown, ctx: ServerContext): Promise => { + requireTasksExtension(ctx, `Tool "${registration.name}" executes as a task and`); + const principal = principalOf(ctx); + const task = await engine.create({ + taskName: registration.name, + input, + ttlMs: registration.ttlMs, + pollIntervalMs: registration.pollIntervalMs, + ...(principal !== undefined && { principal }) + }); + const result: CreateTaskResult = { resultType: 'task', ...task }; + // The 2026-07-28 encode seam forwards a handler-provided `resultType` + // for `tools/call` verbatim; the flat task handle is the wire result. + return result as unknown as CallToolResult; + }; + + return { + engine, + registerTask(name, config, handler) { + if ('outputSchema' in config && config.outputSchema !== undefined) { + throw new TypeError(`registerTask("${name}"): outputSchema is not supported — the tool answers a task handle`); + } + if (registrations.has(name)) { + throw new Error(`Task "${name}" is already registered`); + } + const registration: TaskRegistration = { + name, + ttlMs: config.ttlMs === undefined ? DEFAULT_TTL_MS : config.ttlMs, + pollIntervalMs: config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, + retries: { + limit: config.retries?.limit ?? DEFAULT_RETRY_POLICY.limit, + baseDelayMs: config.retries?.baseDelayMs ?? DEFAULT_RETRY_POLICY.baseDelayMs, + maxDelayMs: config.retries?.maxDelayMs ?? DEFAULT_RETRY_POLICY.maxDelayMs + }, + inputSchema: config.inputSchema, + handler: handler as AnyTaskHandler + }; + const sdkConfig: { + title?: string; + description?: string; + inputSchema?: StandardSchemaWithJSON; + annotations?: ToolAnnotations; + } = { + title: config.title, + description: config.description, + inputSchema: config.inputSchema, + annotations: config.annotations + }; + // The SDK invokes the callback as (args, ctx) when an inputSchema is + // registered and as (ctx) otherwise; one wire handler covers both. + const hasInput = config.inputSchema !== undefined; + const wireHandler = (first: unknown, second?: unknown): Promise => { + const ctx = (hasInput ? second : first) as ServerContext; + return createTask(registration, hasInput ? first : undefined, ctx); + }; + const tool = server.registerTool(name, sdkConfig, wireHandler); + registrations.set(name, registration); + if (registrations.size === 1) { + server.server.registerCapabilities({ extensions: { [TASKS_EXTENSION_ID]: {} } }); + } + return { + enable: () => tool.enable(), + disable: () => tool.disable(), + remove: () => { + registrations.delete(name); + tool.remove(); + } + }; + }, + getTaskRegistration: registry.getTaskRegistration, + get taskNames() { + return [...registrations.keys()]; + } + }; +} diff --git a/packages/tasks/src/server/registration.ts b/packages/tasks/src/server/registration.ts new file mode 100644 index 0000000000..2e6a909ada --- /dev/null +++ b/packages/tasks/src/server/registration.ts @@ -0,0 +1,58 @@ +import type { CallToolResult, StandardSchemaWithJSON, ToolAnnotations } from '@modelcontextprotocol/server'; + +import type { RetryPolicy, Step } from '../step/types'; + +/** The validated input type a task handler receives for its input schema. */ +export type TaskInput = In extends StandardSchemaWithJSON ? Output : undefined; + +/** + * A task handler: receives the validated tool input and the replay-aware + * {@link Step} API, and returns the `CallToolResult` that `tasks/get` will + * inline once the task completes. A throwing handler completes the task with + * an `isError: true` result (`failed` is reserved for engine errors). + */ +export type TaskHandler = ( + input: TaskInput, + step: Step +) => Promise; + +/** Configuration for `registerTask`. */ +export interface TaskConfig { + title?: string; + description?: string; + /** zod v4 object (or any Standard Schema with JSON), same as `registerTool`. */ + inputSchema?: In; + annotations?: ToolAnnotations; + /** + * Forbidden: an `outputSchema` breaks the `CreateTaskResult` wire path + * (the tool answers a task handle, not a structured result). Enforced at + * compile time (`never`) and at runtime. + */ + outputSchema?: never; + /** Task retention from creation, ms; `null` = unlimited. Default 86_400_000 (24h). */ + ttlMs?: number | null; + /** Suggested client polling interval, ms. Default 5_000. */ + pollIntervalMs?: number; + /** Default step retry policy for this task. */ + retries?: RetryPolicy; +} + +/** Handle returned by `registerTask`. */ +export interface RegisteredTask { + enable(): void; + disable(): void; + remove(): void; +} + +/** @internal Type-erased handler stored in the registration table. */ +export type AnyTaskHandler = (input: unknown, step: Step) => Promise; + +/** A resolved task registration (defaults applied). */ +export interface TaskRegistration { + name: string; + ttlMs: number | null; + pollIntervalMs: number; + retries: Required; + inputSchema: StandardSchemaWithJSON | undefined; + handler: AnyTaskHandler; +} diff --git a/packages/tasks/src/step/replayStep.ts b/packages/tasks/src/step/replayStep.ts new file mode 100644 index 0000000000..3136506521 --- /dev/null +++ b/packages/tasks/src/step/replayStep.ts @@ -0,0 +1,337 @@ +/* + * The local replay-aware `Step` implementation (the engine contract)): the wrapper the + * executor builds around the per-lease `DurableStep` stub for exactly one + * `runTask` invocation. `step.do` memoizes through the journal, `step.sleep` / + * `step.sleepUntil` / `step.elicit` record durable wakes and suspend the + * invocation, and per-step retry policy is computed here and recorded DO-side. + * + * The timeout/abort race, the retry/terminal failure catch, and the + * memoization shape are adapted from avenceslau/durability, pinned at commit + * 78cb099 (v2.1.0): `packages/durability/src/index.ts` (`execute()` skeleton — + * claim, timeout race, guarded success, retry/terminal catch). The in-DO + * journal writes are replaced by calls on the `DurableStep` RPC stub. + * https://github.com/avenceslau/durability + */ + +import { exponential, jitter } from '../engine/backoff'; +import { DEFAULT_STEP_TIMEOUT_MS } from '../engine/defaults'; +import type { DurationString } from '../engine/duration'; +import { parseDuration } from '../engine/duration'; +import type { SerializedError } from '../engine/errors'; +import { + AttemptsExhaustedError, + DuplicateStepError, + isNonRetryable, + RetryPolicyError, + serializeError, + StepTimeoutError +} from '../engine/errors'; +import type { StepJournal } from '../engine/protocol'; +import { serializeValue } from '../engine/serialization'; +import type { InputRequest, InputResponse } from '../wire/types'; +import type { ElicitConfig, ElicitOutcome, JsonSerializable, RetryPolicy, Step, StepConfig } from './types'; + +/** + * @internal Thrown by the step wrapper to end the current invocation without + * failing the task: a sleep/elicit was recorded, a step retry was scheduled, + * or cancellation was observed — the Durable Object already journaled why and + * the alarm resumes later. The executor maps it to `{outcome: "suspended"}`; + * it never crosses an RPC boundary. + */ +export class SuspendSignal extends Error { + constructor(reason: string) { + super(`Task invocation suspended: ${reason}`); + this.name = 'SuspendSignal'; + } +} + +/** + * Merges the task-level retry policy with a per-step override (the engine contract)) + * and validates it — an invalid policy is a terminal engine failure. + * + * @throws RetryPolicyError when the merged policy is unusable. + */ +export const resolveRetryPolicy = ( + taskPolicy: Required, + override: RetryPolicy | undefined, + entity: string +): Required => { + const merged = { + limit: override?.limit ?? taskPolicy.limit, + baseDelayMs: override?.baseDelayMs ?? taskPolicy.baseDelayMs, + maxDelayMs: override?.maxDelayMs ?? taskPolicy.maxDelayMs + }; + if ( + !Number.isSafeInteger(merged.limit) || + merged.limit < 1 || + !Number.isFinite(merged.baseDelayMs) || + merged.baseDelayMs < 0 || + !Number.isFinite(merged.maxDelayMs) || + merged.maxDelayMs < 0 + ) { + throw new RetryPolicyError(entity); + } + return merged; +}; + +/** + * Computes the backoff delay before retrying a failed step attempt: + * exponential from the policy base to its cap, with equal jitter. + * + * @throws RetryPolicyError when the policy produces an unsafe delay. + */ +export const computeStepRetryDelayMs = (policy: Required, attempt: number, entity: string): number => { + const delay = Math.round(jitter(exponential(attempt, policy.baseDelayMs, policy.maxDelayMs))); + if (!Number.isSafeInteger(delay) || delay < 0) { + throw new RetryPolicyError(entity); + } + return delay; +}; + +/** Rebuilds a throwable from a persisted `{name, message}` pair. */ +const rehydrateError = (detail: SerializedError): Error => { + const error = new Error(detail.message); + error.name = detail.name; + return error; +}; + +/** + * Races a step closure against its per-attempt timeout. The closure promise + * gets a no-op rejection handler so a late failure after a lost race is never + * reported as unhandled. + */ +const runClosureWithTimeout = async (name: string, fn: () => T | Promise, timeoutMs: number): Promise => { + const closure = (async () => fn())(); + void closure.catch(() => {}); + let timer: ReturnType | undefined; + try { + return await Promise.race([ + closure, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new StepTimeoutError(name, timeoutMs)), timeoutMs); + }) + ]); + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } +}; + +/** + * The replay-aware {@link Step} handed to task handlers. Constructed by the + * executor per invocation around the per-lease {@link StepJournal}; every + * journal effect goes through the stub (and is therefore generation-guarded + * DO-side). Step-name uniqueness within one run is enforced locally so a + * same-run duplicate fails loudly instead of silently replaying a journal hit + * (decision D8). + */ +export class ReplayStep implements Step { + readonly #stub: StepJournal; + readonly #taskId: string; + readonly #taskRetries: Required; + readonly #usedNames = new Set(); + /** + * False while the handler is replaying ground an earlier run already + * published; true once it is on new ground. The first claim is live from + * the start. A later claim goes live at the point the previous run stopped: + * the sleep or elicit it suspended on (a hit on replay), or the first + * journal miss (a closure that runs, a pending sleep, a fresh elicit). + * `step.status` writes only when live, so a resume never re-publishes the + * beats that came before the suspension point. + */ + #live: boolean; + + constructor(stub: StepJournal, taskId: string, taskRetries: Required, attempt = 1) { + this.#stub = stub; + this.#taskId = taskId; + this.#taskRetries = taskRetries; + // The first claim has no journal to replay: everything it does is live. + // Later claims (resume after a suspend, redelivery after a crash) replay + // journaled ground first and go live at their first miss. + this.#live = attempt <= 1; + } + + readonly idempotencyKey = (stepName: string): string => `${this.#taskId}:${stepName}`; + + do(name: string, fn: () => T | Promise): Promise; + do(name: string, config: StepConfig, fn: () => T | Promise): Promise; + do( + name: string, + configOrFn: StepConfig | (() => T | Promise), + maybeFn?: () => T | Promise + ): Promise { + const config = typeof configOrFn === 'function' ? undefined : configOrFn; + const fn = typeof configOrFn === 'function' ? configOrFn : maybeFn; + if (typeof fn !== 'function') { + throw new TypeError(`step.do("${name}") requires a closure`); + } + return this.#runDo(name, config, fn); + } + + async sleep(name: string, duration: number | DurationString): Promise { + this.#claimName(name, 'step.sleep'); + await this.#recordSleep(name, Date.now() + parseDuration(duration)); + } + + async sleepUntil(name: string, when: number | Date): Promise { + this.#claimName(name, 'step.sleepUntil'); + const wakeAtMs = when instanceof Date ? when.getTime() : when; + if (!Number.isFinite(wakeAtMs)) { + throw new RangeError(`step.sleepUntil("${name}") requires a valid time, got ${String(when)}`); + } + await this.#recordSleep(name, Math.round(wakeAtMs)); + } + + elicit(name: string, request: InputRequest): Promise; + elicit(name: string, request: InputRequest, config: ElicitConfig): Promise; + async elicit(name: string, request: InputRequest, config?: ElicitConfig): Promise { + this.#claimName(name, 'step.elicit'); + let timeoutAtMs: number | undefined; + const timeoutMs = config?.timeoutMs; + if (timeoutMs !== undefined) { + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) { + throw new RangeError(`step.elicit("${name}") timeoutMs must be a positive integer, got ${timeoutMs}`); + } + // The deadline is journaled with the request on FIRST record and is + // immutable across replays: this recomputation is ignored on a hit. + timeoutAtMs = Date.now() + timeoutMs; + } + const state = await this.#stub.recordElicit(name, request, timeoutAtMs); + // A pending elicit is a fresh suspension (new ground). An answered or + // timed-out one is a hit: only the LATEST suspension point marks the + // boundary back onto new ground (see SleepState.latest). + if (state.state === 'pending' || state.latest) this.#live = true; + if (state.state === 'timed_out') { + // Only reachable through a config: a deadline exists only when one was + // recorded, and replays re-run the same call shape (determinism rule). + return { outcome: 'timed_out' }; + } + if (state.state === 'answered') { + const response = state.response as InputResponse; + return config === undefined ? response : { outcome: 'answered', response }; + } + throw new SuspendSignal(`waiting for input "${name}"`); + } + + async offer(key: string, request: InputRequest): Promise { + // The key shares the lifetime-unique namespace with step names (decision + // D8): a same-run reuse fails loudly here; a replay's re-offer is the + // DO-side no-op (the existing row stands). + this.#claimName(key, 'step.offer'); + await this.#stub.recordOffer(key, request); + } + + async checkInput(name: string, key: string): Promise { + this.#claimName(name, 'step.checkInput'); + if (typeof key !== 'string' || key.length === 0) { + throw new TypeError(`step.checkInput("${name}") requires a non-empty offer key`); + } + // Journaled DO-side under `name`: a hit consumes the answer, a miss is + // recorded as such, and replays observe the journaled value either way. + const state = await this.#stub.checkInput(name, key); + return state.state === 'answered' ? (state.response as InputResponse) : null; + } + + async status(message: string): Promise { + if (typeof message !== 'string') { + throw new TypeError(`step.status requires a string message, got ${typeof message}`); + } + // Not a journaled step: no name claim, no journal row. It writes ONLY + // once the handler is live (past its last journal hit): a replay re-runs + // the handler from the top, and without this gate it would re-publish + // every earlier beat with a fresh lastUpdatedAt — pollers saw old prose + // come back as new after a fork. Shape + size of `meta` are enforced + // DO-side (the single authority). + if (!this.#live) return; + await this.#stub.setStatus(message); + } + + // ------------------------------------------------------------ internals -- + + #claimName(name: string, api: string): void { + if (name.length === 0) { + throw new TypeError(`${api} requires a non-empty step name`); + } + if (this.#usedNames.has(name)) { + throw new DuplicateStepError(name); + } + this.#usedNames.add(name); + } + + async #recordSleep(name: string, wakeAtMs: number): Promise { + const state = await this.#stub.recordSleep(name, wakeAtMs); + if (state.state === 'pending') { + this.#live = true; + throw new SuspendSignal(`sleeping "${name}" until ${new Date(wakeAtMs).toISOString()}`); + } + // Journal hit: the wake already elapsed. Only the LATEST suspension point + // (the last sleep/elicit the previous run recorded) marks the boundary + // back onto new ground; earlier completed sleeps are old ground replayed. + if (state.latest) this.#live = true; + } + + async #runDo(name: string, config: StepConfig | undefined, fn: () => T | Promise): Promise { + this.#claimName(name, 'step.do'); + const entity = `step "${name}"`; + const policy = resolveRetryPolicy(this.#taskRetries, config?.retries, entity); + const timeoutMs = config?.timeoutMs ?? DEFAULT_STEP_TIMEOUT_MS; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) { + throw new RangeError(`step.do("${name}") timeoutMs must be a positive integer, got ${timeoutMs}`); + } + + const directive = await this.#stub.beginStep(name, { timeoutMs }); + switch (directive.state) { + case 'completed': { + // Journal hit: the persisted result; the closure MUST NOT run. + return directive.value as T; + } + case 'failed': { + // The step failed terminally in an earlier run; replays surface it. + throw rehydrateError(directive.error); + } + case 'cancelled': { + throw new SuspendSignal(`step "${name}" aborted by cancellation request`); + } + case 'run': { + this.#live = true; + break; + } + } + + const attempt = directive.attempt; + if (attempt > policy.limit) { + // A crash after a claim consumes an attempt (the engine contract)): claims can + // exhaust the limit without a recorded closure error. + const exhausted = new AttemptsExhaustedError(`Step "${name}"`, policy.limit); + await this.#stub.failStep(name, serializeError(exhausted), { terminal: true }); + throw exhausted; + } + + let value: T; + try { + value = await runClosureWithTimeout(name, fn, timeoutMs); + } catch (error) { + return this.#settleFailedAttempt(name, attempt, policy, error); + } + // Validate serialization locally so a non-JSON result surfaces as + // ResultSerializationError in the executor (engine failure, the engine contract)) + // rather than an opaque RPC rejection. + serializeValue(value, entity); + await this.#stub.completeStep(name, value); + return value; + } + + async #settleFailedAttempt(name: string, attempt: number, policy: Required, error: unknown): Promise { + if (error instanceof SuspendSignal) { + throw error; // a nested suspension propagates untouched + } + if (isNonRetryable(error) || attempt >= policy.limit) { + await this.#stub.failStep(name, serializeError(error), { terminal: true }); + throw error; + } + const delayMs = computeStepRetryDelayMs(policy, attempt, `step "${name}"`); + await this.#stub.failStep(name, serializeError(error), { retryAtMs: Date.now() + delayMs }); + throw new SuspendSignal(`step "${name}" attempt ${attempt} failed; retry in ${delayMs}ms`); + } +} diff --git a/packages/tasks/src/step/types.ts b/packages/tasks/src/step/types.ts new file mode 100644 index 0000000000..f918ec01ba --- /dev/null +++ b/packages/tasks/src/step/types.ts @@ -0,0 +1,162 @@ +/** + * The replay-aware step API handed to task handlers (the engine contract)). + */ + +import type { DurationString } from '../engine/duration'; +import type { InputRequest, InputResponse } from '../wire/types'; + +/** + * A value the engine can persist in the step journal: plain JSON, plus + * `undefined` (round-tripped faithfully through the undefined-safe envelope). + * Nested `undefined` property values are rejected at serialization time. + */ +export type JsonSerializable = string | number | boolean | null | undefined | JsonSerializable[] | { [key: string]: JsonSerializable }; + +/** A plain JSON value (no `undefined` anywhere): what `step.status` meta carries. */ +export type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject; + +/** A plain JSON object: the optional structured `meta` of `step.status`. */ +export interface JsonObject { + [key: string]: JsonValue; +} + +/** Retry policy for step closures (defaults per decision D11). */ +export interface RetryPolicy { + /** Total attempts, including the first. Default 5. */ + limit?: number; + /** Base delay for exponential backoff with jitter. Default 1_000. */ + baseDelayMs?: number; + /** Backoff cap. Default 300_000 (5 minutes). */ + maxDelayMs?: number; +} + +/** Per-step configuration overriding the task defaults. */ +export interface StepConfig { + /** Overrides the task's default retry policy for this step. */ + retries?: RetryPolicy; + /** Per-attempt closure timeout in milliseconds. Default 300_000 (5 minutes). */ + timeoutMs?: number; +} + +/** Configuration for `step.elicit`. */ +export interface ElicitConfig { + /** + * Answer deadline in milliseconds from when the request is first recorded. + * If no `tasks/update` answers within it, the engine resolves the request + * as timed out at the deadline (via the task alarm): the request is marked + * answered-by-timeout — late responses to its key are ignored — the task + * returns to `working`, and the resumed replay resolves the elicit with + * `{ outcome: "timed_out" }`. Omitted = today's behavior: waits forever. + */ + timeoutMs?: number; +} + +/** + * Discriminated result of a `step.elicit` called with an {@link ElicitConfig}: + * the client's response when answered in time, or the timeout marker. The + * marker is engine-internal state, never a synthetic wire `InputResponse` — + * the wire only ever sees real client responses. + */ +export type ElicitOutcome = { outcome: 'answered'; response: InputResponse } | { outcome: 'timed_out' }; + +/** + * The step API. Step names are the journal keys — unique per task; reusing a + * name in a single run throws `DuplicateStepError` (loops must suffix an + * index). All side effects belong inside `step.do`; code between steps must + * be cheap and deterministic because the whole handler body re-runs on every + * resume, with completed steps returning persisted results. + */ +export interface Step { + /** + * Runs a journaled closure. On replay, a completed step resolves with its + * persisted result without executing the closure. Return values must be + * JSON-serializable ({@link JsonSerializable}). + */ + do(name: string, fn: () => T | Promise): Promise; + do(name: string, config: StepConfig, fn: () => T | Promise): Promise; + + /** + * Durable sleep: records the wake time, suspends the invocation, and + * resumes via the TaskRunner alarm. Never blocks a running invocation. + * An answer to a standing {@link Step.offer} cuts a pending sleep short + * (the resumed replay resolves it at once) so the story can react; an + * answer that lands while the handler is executing cuts the next sleep + * the handler records instead (it resolves immediately, no suspension). + */ + sleep(name: string, duration: number | DurationString): Promise; + + /** Durable sleep until an absolute time (ms epoch or Date). */ + sleepUntil(name: string, when: number | Date): Promise; + + /** + * EXPERIMENTAL (v1, decision D13): records an input request under a + * lifetime-unique key (the step name), moves the task to `input_required`, + * and suspends until a `tasks/update` supplies a matching response — the + * task then returns to `working` and the step resolves with the client's + * response. Partial responses are accepted; unknown keys are ignored. + * + * With an {@link ElicitConfig} carrying `timeoutMs`, the wait is bounded: + * an unanswered request is resolved as timed out at the deadline and the + * step resolves with the discriminated {@link ElicitOutcome} instead of a + * bare response. Without a config, today's wait-forever contract holds. + */ + elicit(name: string, request: InputRequest): Promise; + elicit(name: string, request: InputRequest, config: ElicitConfig): Promise; + + /** + * Standing, NON-blocking input channel: registers an input request under a + * lifetime-unique key (shared with elicit names — a key never repeats + * within a task) WITHOUT suspending. The task stays `working`, the status + * is untouched, the story continues; the offer is announced in-story (via + * `step.status`), never in `tasks/get` `inputRequests` (that field is tied + * to `input_required` and shows blocking elicits only). A `tasks/update` + * naming the key stores the first answer (later answers to the key ack and + * change nothing) and wakes the task at once: a `step.sleep` pending at + * that moment is cut short so the next {@link Step.checkInput} can consume + * the answer without waiting for the beat to end; if the handler is + * executing at that moment, its next `checkInput` consumes the answer, or + * the next sleep it records is cut instead. (A pending step retry backoff + * is never pre-empted: the answer is consumed by the retried run.) An + * outstanding offer never holds the task in `input_required` and never + * blocks a fork elicit's resume. Journal-safe: re-offering the key on + * replay is a no-op (the existing offer stands); reusing the key in one + * run throws `DuplicateStepError`. + */ + offer(key: string, request: InputRequest): Promise; + + /** + * Journaled, non-blocking consume of a standing offer: resolves with the + * offer's answer — marking it consumed, so later checks of the same key + * resolve `null` — or `null` when no unconsumed answer exists. Never + * suspends. Each call site is a journaled step under its own unique + * `name`, and the journaled value stands on replay (an answer that lands + * after a journaled miss is consumed by the NEXT check, not retroactively + * by the replay of this one). Throws for a key that is not a registered + * offer (unknown, or a blocking elicit). + */ + checkInput(name: string, key: string): Promise; + + /** + * Durable handler telemetry: writes the task's `statusMessage` so + * `tasks/get` pollers see it. The handler is the ONLY writer of + * `statusMessage` — the engine never narrates its own transitions — so the + * field is absent until the first call, and the last written message + * stands until the handler writes again, through suspensions, replays, and + * terminal transitions alike (a completed/failed task keeps its last + * message next to the stored result/error). A side-effecting convenience, + * not a journaled step: it creates no journal rows, never disturbs replay + * ordering, and duplicate delivery on replay is harmless (the re-run + * handler body rewrites the same message). Calling it after a terminal + * state is a no-op. Generation-guarded like every lease write: a + * superseded attempt's call throws and the invocation is abandoned. + */ + status(message: string): Promise; + + /** + * The idempotency key for a step — `${taskId}:${stepName}` — to pass to + * external systems: a crash between an external side effect and the journal + * commit re-runs exactly that step, so external calls should deduplicate on + * this key. + */ + readonly idempotencyKey: (stepName: string) => string; +} diff --git a/packages/tasks/src/wire/schemas.ts b/packages/tasks/src/wire/schemas.ts new file mode 100644 index 0000000000..93639821b6 --- /dev/null +++ b/packages/tasks/src/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/tasks/src/wire/types.ts b/packages/tasks/src/wire/types.ts new file mode 100644 index 0000000000..87112a1ff6 --- /dev/null +++ b/packages/tasks/src/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/server'; + +/** + * 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/server'; + +/** 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/tasks/test/inMemoryEngine.test.ts b/packages/tasks/test/inMemoryEngine.test.ts new file mode 100644 index 0000000000..2de73d6a90 --- /dev/null +++ b/packages/tasks/test/inMemoryEngine.test.ts @@ -0,0 +1,192 @@ +/** + * The in-memory engine against a scripted executor: journal semantics that a + * durable engine must reproduce (replay hits, generation fencing, offers, + * elicit timeouts, TTL purge, principal binding). + */ +import { describe, expect, it } from 'vitest'; + +import type { RunOutcome, StepJournal, TaskExecutor, TaskInvocation } from '../src/index'; +import { DuplicateStepError, InMemoryTaskEngine, StaleLeaseError } from '../src/index'; + +const tick = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms)); + +/** An executor whose behaviour is a script over the journal. */ +const scripted = (script: (invocation: TaskInvocation, journal: StepJournal) => Promise): TaskExecutor => ({ + runTask: script +}); + +const params = { taskName: 't', input: { a: 1 }, ttlMs: null, pollIntervalMs: 100 }; + +describe('InMemoryTaskEngine', () => { + it('creates durably: get succeeds before the first run has happened', async () => { + const engine = new InMemoryTaskEngine(); + engine.attach(scripted(async () => ({ outcome: 'completed', result: { content: [] } }))); + const task = await engine.create(params); + expect(await engine.get(task.taskId)).toMatchObject({ status: 'working', taskId: task.taskId }); + await tick(5); + expect(await engine.get(task.taskId)).toMatchObject({ status: 'completed', result: { content: [] } }); + engine.close(); + }); + + it('replays completed steps and resumes after a sleep on a new attempt', async () => { + const engine = new InMemoryTaskEngine(); + const seen: Array<[number, string]> = []; + engine.attach( + scripted(async (invocation, journal) => { + const first = await journal.beginStep('one'); + seen.push([invocation.attempt, first.state]); + if (first.state === 'run') await journal.completeStep('one', 42); + const sleep = await journal.recordSleep('nap', Date.now() + 5); + if (sleep.state === 'pending') return { outcome: 'suspended' }; + seen.push([invocation.attempt, `sleep:${sleep.latest}`]); + return { + outcome: 'completed', + result: { content: [{ type: 'text', text: String(first.state === 'completed' && first.value) }] } + }; + }) + ); + const task = await engine.create(params); + await tick(30); + expect(seen).toEqual([ + [1, 'run'], + [2, 'completed'], + [2, 'sleep:true'] + ]); + expect(await engine.get(task.taskId)).toMatchObject({ status: 'completed', result: { content: [{ type: 'text', text: '42' }] } }); + engine.close(); + }); + + it('fences a superseded attempt with StaleLeaseError', async () => { + const engine = new InMemoryTaskEngine(); + let stale: StepJournal | undefined; + engine.attach( + scripted(async (invocation, journal) => { + if (invocation.attempt === 1) { + stale = journal; + await journal.recordSleep('nap', Date.now() + 1); + return { outcome: 'suspended' }; + } + return { outcome: 'completed', result: { content: [] } }; + }) + ); + await engine.create(params); + await tick(20); + await expect(stale?.beginStep('late')).rejects.toBeInstanceOf(StaleLeaseError); + engine.close(); + }); + + it('holds input_required until tasks/update answers the blocking request', async () => { + const engine = new InMemoryTaskEngine(); + engine.attach( + scripted(async (_invocation, journal) => { + const state = await journal.recordElicit('q', { method: 'elicitation/create', params: {} }); + if (state.state !== 'answered') return { outcome: 'suspended' }; + return { outcome: 'completed', result: { content: [{ type: 'text', text: JSON.stringify(state.response) }] } }; + }) + ); + const task = await engine.create(params); + await tick(5); + expect(await engine.get(task.taskId)).toMatchObject({ + status: 'input_required', + inputRequests: { q: { method: 'elicitation/create', params: {} } } + }); + expect(await engine.update(task.taskId, { ignored: { action: 'accept' } })).toBe(true); + await tick(5); + expect((await engine.get(task.taskId))?.status).toBe('input_required'); + await engine.update(task.taskId, { q: { action: 'decline' } }); + await tick(5); + expect(await engine.get(task.taskId)).toMatchObject({ + status: 'completed', + result: { content: [{ type: 'text', text: '{"action":"decline"}' }] } + }); + engine.close(); + }); + + it('resolves a timed elicit as timed_out at its deadline', async () => { + const engine = new InMemoryTaskEngine(); + const outcomes: string[] = []; + engine.attach( + scripted(async (_invocation, journal) => { + const state = await journal.recordElicit('q', {}, Date.now() + 5); + outcomes.push(state.state); + if (state.state === 'pending') return { outcome: 'suspended' }; + return { outcome: 'completed', result: { content: [] } }; + }) + ); + const task = await engine.create(params); + await tick(30); + expect(outcomes).toEqual(['pending', 'timed_out']); + expect((await engine.get(task.taskId))?.status).toBe('completed'); + engine.close(); + }); + + it('offers never block; an answer cuts a pending sleep and checkInput consumes once', async () => { + const engine = new InMemoryTaskEngine(); + const checks: string[] = []; + engine.attach( + scripted(async (_invocation, journal) => { + await journal.recordOffer('side', { method: 'elicitation/create' }); + await expect(journal.recordElicit('side', {})).rejects.toBeInstanceOf(DuplicateStepError); + const sleep = await journal.recordSleep('long', Date.now() + 60_000); + if (sleep.state === 'pending') return { outcome: 'suspended' }; + const first = await journal.checkInput('check-1', 'side'); + const second = await journal.checkInput('check-2', 'side'); + checks.push(first.state, second.state); + return { outcome: 'completed', result: { content: [] } }; + }) + ); + const task = await engine.create(params); + await tick(5); + expect(await engine.get(task.taskId)).toMatchObject({ status: 'working' }); + await engine.update(task.taskId, { side: { action: 'accept' } }); + await tick(10); + expect(checks).toEqual(['answered', 'unanswered']); + expect((await engine.get(task.taskId))?.status).toBe('completed'); + engine.close(); + }); + + it('cancels: immediately when idle, at the next beginStep when running', async () => { + const engine = new InMemoryTaskEngine(); + let release: (() => void) | undefined; + engine.attach( + scripted(async (_invocation, journal) => { + await new Promise(resolve => { + release = resolve; + }); + const step = await journal.beginStep('after-cancel'); + if (step.state === 'cancelled') return { outcome: 'suspended' }; + return { outcome: 'completed', result: { content: [] } }; + }) + ); + const task = await engine.create(params); + await tick(5); + expect(await engine.cancel(task.taskId)).toBe(true); + expect((await engine.get(task.taskId))?.status).toBe('working'); + release?.(); + await tick(5); + expect((await engine.get(task.taskId))?.status).toBe('cancelled'); + expect(await engine.cancel(task.taskId)).toBe(true); + expect(await engine.cancel('missing')).toBe(false); + engine.close(); + }); + + it('purges at the TTL deadline and fails closed on a foreign principal', async () => { + const engine = new InMemoryTaskEngine(); + engine.attach(scripted(async () => ({ outcome: 'completed', result: { content: [] } }))); + const task = await engine.create({ ...params, ttlMs: 10, principal: 'alice' }); + expect(await engine.get(task.taskId)).toBeUndefined(); + expect(await engine.get(task.taskId, { principal: 'bob' })).toBeUndefined(); + expect((await engine.get(task.taskId, { principal: 'alice' }))?.taskId).toBe(task.taskId); + await tick(30); + expect(await engine.get(task.taskId, { principal: 'alice' })).toBeUndefined(); + engine.close(); + }); + + it('fails the task when no executor is attached', async () => { + const engine = new InMemoryTaskEngine(); + const task = await engine.create(params); + await tick(5); + expect(await engine.get(task.taskId)).toMatchObject({ status: 'failed', error: { code: -32_603 } }); + engine.close(); + }); +}); diff --git a/packages/tasks/test/tasks.e2e.test.ts b/packages/tasks/test/tasks.e2e.test.ts new file mode 100644 index 0000000000..c531292d5e --- /dev/null +++ b/packages/tasks/test/tasks.e2e.test.ts @@ -0,0 +1,239 @@ +/** + * End to end through a real `Client` against the stateless `createMcpHandler` + * (a fresh `McpServer` per request), which is the deployment shape the Tasks + * extension exists for: nothing about a running task lives on the server + * instance that answered `tools/call`; the engine is the only shared state. + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { CLIENT_CAPABILITIES_META_KEY, createMcpHandler, McpServer, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/server'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import type { InputResponses, Step } from '../src/index'; +import { + createTaskResultSchema, + detailedTaskSchema, + InMemoryTaskEngine, + installTasks, + NonRetryableError, + TASKS_EXTENSION_ID +} from '../src/index'; + +/** + * The SDK `Client` consumes `resultType` (a wire-only field) before a caller + * schema runs, so the client-side schemas are the neutral shapes. + */ +const ackSchema = z.looseObject({}); + +const TASKS_CAPABILITY = { extensions: { [TASKS_EXTENSION_ID]: {} } }; + +type Handler = (input: { name: string }, step: Step) => Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }>; + +function createHarness(handler: Handler, options?: { declareExtension?: boolean }) { + const engine = new InMemoryTaskEngine(); + const createServer = () => { + const server = new McpServer({ name: 'tasks-test', version: '1.0.0' }); + const tasks = installTasks(server, { engine }); + tasks.registerTask( + 'greet', + { description: 'greets, slowly', inputSchema: z.object({ name: z.string() }), retries: { baseDelayMs: 1, maxDelayMs: 2 } }, + handler + ); + return server; + }; + const mcpHandler = createMcpHandler(createServer); + /** + * `tools/call` is posted raw: the SDK `Client` decodes results before any + * caller schema runs and rejects the extension's `resultType: "task"` + * (typescript-sdk#2637) — the client half of the extension is the + * ext-tasks package's job. Every other method goes through the real Client. + */ + const startTask = async (name: string) => { + const body = { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'greet', + arguments: { name }, + _meta: { + [PROTOCOL_VERSION_META_KEY]: '2026-07-28', + [CLIENT_CAPABILITIES_META_KEY]: options?.declareExtension === false ? {} : 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': 'greet' + }, + 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; + const message = JSON.parse(payload ?? '{}') as { result?: unknown; error?: { code: number; message: string } }; + 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: options?.declareExtension === false ? {} : TASKS_CAPABILITY } + ); + return { engine, client, transport, startTask }; +} + +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('tasks end to end (stateless handler, in-memory engine)', () => { + let cleanup: (() => void) | undefined; + afterEach(() => cleanup?.()); + + it('advertises the extension, answers a task handle, and completes through do/sleep/status', async () => { + const calls: string[] = []; + const h = createHarness(async ({ name }, step) => { + const upper = await step.do('upper', () => { + calls.push('upper'); + return name.toUpperCase(); + }); + await step.status(`greeting ${upper}`); + await step.sleep('pause', 10); + const text = await step.do('compose', () => `hello ${upper}`); + return { content: [{ type: 'text', text }] }; + }); + cleanup = () => h.engine.close(); + await h.client.connect(h.transport); + expect(h.client.getServerCapabilities()?.extensions).toEqual({ [TASKS_EXTENSION_ID]: {} }); + + const created = await h.startTask('ada'); + expect(created.resultType).toBe('task'); + expect(created.status).toBe('working'); + expect(created.ttlMs).toBe(86_400_000); + + 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'); + // The sleep suspended the first run; the resume replayed `upper` from the journal. + expect(calls).toEqual(['upper']); + }); + + it('surfaces input_required, resumes on tasks/update, and inlines the answer', async () => { + const h = createHarness(async ({ name }, step) => { + const answer = await step.elicit('confirm', { + method: 'elicitation/create', + params: { message: `greet ${name}?`, mode: 'form', requestedSchema: { type: 'object', properties: {} } } + }); + return { content: [{ type: 'text', text: JSON.stringify(answer) }] }; + }); + cleanup = () => h.engine.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({ action: 'accept', content: { ok: true } }) }] + }); + }); + + it('retries a failing step with the task policy and completes', async () => { + let attempts = 0; + const h = createHarness(async ({ name }, step) => { + const text = await step.do('flaky', () => { + attempts += 1; + if (attempts < 3) throw new Error('transient'); + return `hi ${name}`; + }); + return { content: [{ type: 'text', text }] }; + }); + cleanup = () => h.engine.close(); + await h.client.connect(h.transport); + const created = await h.startTask('cy'); + const done = await pollUntil(h.client, created.taskId, task => task.status === 'completed'); + expect(attempts).toBe(3); + expect(done.status === 'completed' && done.result).toEqual({ content: [{ type: 'text', text: 'hi cy' }] }); + }); + + it('completes with isError for a handler throw, and NonRetryableError skips retries', async () => { + let attempts = 0; + const h = createHarness(async (_input, step) => { + await step.do('boom', () => { + attempts += 1; + throw new NonRetryableError('bad input'); + }); + return { content: [] }; + }); + cleanup = () => h.engine.close(); + await h.client.connect(h.transport); + const created = await h.startTask('x'); + const done = await pollUntil(h.client, created.taskId, task => task.status === 'completed'); + expect(attempts).toBe(1); + expect(done.status === 'completed' && done.result).toEqual({ + content: [{ type: 'text', text: 'NonRetryableError: bad input' }], + isError: true + }); + }); + + it('cancels cooperatively at the next step', async () => { + const h = createHarness(async (_input, step) => { + await step.sleep('long', 60_000); + return { content: [] }; + }); + cleanup = () => h.engine.close(); + await h.client.connect(h.transport); + const created = await h.startTask('x'); + await pollUntil(h.client, created.taskId, task => task.status === 'working'); + await new Promise(resolve => setTimeout(resolve, 10)); // let the run reach the sleep + await cancelTask(h.client, created.taskId); + const done = await pollUntil(h.client, created.taskId, task => task.status === 'cancelled'); + expect(done.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', async () => { + const h = createHarness(async () => ({ content: [] })); + cleanup = () => h.engine.close(); + await h.client.connect(h.transport); + await expect(getTask(h.client, 'nope')).rejects.toMatchObject({ code: -32_602 }); + + const plain = createHarness(async () => ({ content: [] }), { declareExtension: false }); + await plain.client.connect(plain.transport); + await expect(getTask(plain.client, 'nope')).rejects.toMatchObject({ code: -32_021 }); + // The SDK's tool dispatch converts handler throws into isError results; the + // -32021 refusal therefore reaches a non-declaring caller as a tool error. + const refused = await plain.client.callTool({ name: 'greet', arguments: { name: 'x' } }); + expect(refused.isError).toBe(true); + expect(refused.content).toEqual([{ type: 'text', text: expect.stringContaining(TASKS_EXTENSION_ID) }]); + plain.engine.close(); + }); +}); diff --git a/packages/tasks/tsconfig.json b/packages/tasks/tsconfig.json new file mode 100644 index 0000000000..9f2dcd1f22 --- /dev/null +++ b/packages/tasks/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "paths": { + "*": ["./*"], + "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], + "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], + "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], + "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], + "@modelcontextprotocol/core-internal": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/index.ts" + ], + "@modelcontextprotocol/core/internal": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/internal.ts" + ], + "@modelcontextprotocol/core-internal/public": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" + ] + } + } +} diff --git a/packages/tasks/tsdown.config.ts b/packages/tasks/tsdown.config.ts new file mode 100644 index 0000000000..2565de4620 --- /dev/null +++ b/packages/tasks/tsdown.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + failOnWarn: 'ci-only', + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + fixedExtension: true, + outDir: 'dist', + clean: true, + sourcemap: true, + target: 'esnext', + platform: 'neutral', + dts: { + resolver: 'tsc', + // Keep workspace deps as external imports in the bundled .d.ts instead of + // inlining their type graph — see ../middleware/hono/tsdown.config.ts. + compilerOptions: { + paths: {}, + preserveSymlinks: true + } + } +}); diff --git a/packages/tasks/typedoc.json b/packages/tasks/typedoc.json new file mode 100644 index 0000000000..a9fd090d0f --- /dev/null +++ b/packages/tasks/typedoc.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["src"], + "entryPointStrategy": "expand", + "exclude": ["**/*.test.ts", "**/__*__/**"], + "navigation": { + "includeGroups": true, + "includeCategories": true + } +} diff --git a/packages/tasks/vitest.config.js b/packages/tasks/vitest.config.js new file mode 100644 index 0000000000..496fca3200 --- /dev/null +++ b/packages/tasks/vitest.config.js @@ -0,0 +1,3 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c663ad7086..5a27efdf4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1940,6 +1940,61 @@ importers: specifier: catalog:devTools version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + packages/tasks: + dependencies: + '@modelcontextprotocol/core': + specifier: workspace:* + version: link:../core + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + '@eslint/js': + specifier: catalog:devTools + version: 9.39.4 + '@modelcontextprotocol/client': + specifier: workspace:^ + version: link:../client + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config + '@modelcontextprotocol/server': + specifier: workspace:^ + version: link:../server + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../common/vitest-config + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 + eslint: + specifier: catalog:devTools + version: 9.39.4 + eslint-config-prettier: + specifier: catalog:devTools + version: 10.1.8(eslint@9.39.4) + eslint-plugin-n: + specifier: catalog:devTools + version: 17.24.0(eslint@9.39.4)(typescript@5.9.3) + prettier: + specifier: catalog:devTools + version: 3.6.2 + tsdown: + specifier: catalog:devTools + version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) + typescript: + specifier: catalog:devTools + version: 5.9.3 + typescript-eslint: + specifier: catalog:devTools + version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) + vitest: + specifier: catalog:devTools + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + test/conformance: devDependencies: '@modelcontextprotocol/client': From 774c3f936fb3b81a0078af1506cfca518b1754d2 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Fri, 11 Sep 2026 11:51:26 +0100 Subject: [PATCH 3/3] refactor(tasks): move the Tasks extension into @modelcontextprotocol/server/ext/tasks The extension is a subpath export of the server package rather than a separate package: src/ext/tasks, built as dist/ext/tasks/index.*, exported as @modelcontextprotocol/server/ext/tasks. Tests move to packages/server/test/ext/tasks; the package README becomes docs/servers/tasks.md. --- .changeset/tasks-extension-package.md | 4 +- docs/.vitepress/nav.ts | 1 + .../tasks/README.md => docs/servers/tasks.md | 12 ++- packages/server/package.json | 18 ++++- .../src/ext/tasks}/engine/backoff.ts | 0 .../src/ext/tasks}/engine/defaults.ts | 0 .../src/ext/tasks}/engine/duration.ts | 0 .../src/ext/tasks}/engine/errors.ts | 0 .../src/ext/tasks}/engine/executor.ts | 0 .../src/ext/tasks}/engine/inMemory.ts | 3 +- .../src/ext/tasks}/engine/protocol.ts | 3 +- .../src/ext/tasks}/engine/serialization.ts | 0 .../src/ext/tasks}/engine/taskEngine.ts | 3 +- .../src => server/src/ext/tasks}/index.ts | 0 .../src/ext/tasks}/server/installTasks.ts | 17 +---- .../src/ext/tasks}/server/registration.ts | 3 +- .../src/ext/tasks}/step/replayStep.ts | 0 .../src/ext/tasks}/step/types.ts | 0 .../src/ext/tasks}/wire/schemas.ts | 0 .../src/ext/tasks}/wire/types.ts | 4 +- .../test/ext/tasks}/inMemoryEngine.test.ts | 8 +- .../test/ext/tasks}/tasks.e2e.test.ts | 6 +- packages/server/tsdown.config.ts | 1 + packages/tasks/eslint.config.mjs | 5 -- packages/tasks/package.json | 75 ------------------- packages/tasks/tsconfig.json | 23 ------ packages/tasks/tsdown.config.ts | 22 ------ packages/tasks/typedoc.json | 10 --- packages/tasks/vitest.config.js | 3 - pnpm-lock.yaml | 58 +------------- 30 files changed, 46 insertions(+), 233 deletions(-) rename packages/tasks/README.md => docs/servers/tasks.md (90%) rename packages/{tasks/src => server/src/ext/tasks}/engine/backoff.ts (100%) rename packages/{tasks/src => server/src/ext/tasks}/engine/defaults.ts (100%) rename packages/{tasks/src => server/src/ext/tasks}/engine/duration.ts (100%) rename packages/{tasks/src => server/src/ext/tasks}/engine/errors.ts (100%) rename packages/{tasks/src => server/src/ext/tasks}/engine/executor.ts (100%) rename packages/{tasks/src => server/src/ext/tasks}/engine/inMemory.ts (99%) rename packages/{tasks/src => server/src/ext/tasks}/engine/protocol.ts (99%) rename packages/{tasks/src => server/src/ext/tasks}/engine/serialization.ts (100%) rename packages/{tasks/src => server/src/ext/tasks}/engine/taskEngine.ts (97%) rename packages/{tasks/src => server/src/ext/tasks}/index.ts (100%) rename packages/{tasks/src => server/src/ext/tasks}/server/installTasks.ts (96%) rename packages/{tasks/src => server/src/ext/tasks}/server/registration.ts (97%) rename packages/{tasks/src => server/src/ext/tasks}/step/replayStep.ts (100%) rename packages/{tasks/src => server/src/ext/tasks}/step/types.ts (100%) rename packages/{tasks/src => server/src/ext/tasks}/wire/schemas.ts (100%) rename packages/{tasks/src => server/src/ext/tasks}/wire/types.ts (98%) rename packages/{tasks/test => server/test/ext/tasks}/inMemoryEngine.test.ts (97%) rename packages/{tasks/test => server/test/ext/tasks}/tasks.e2e.test.ts (98%) delete mode 100644 packages/tasks/eslint.config.mjs delete mode 100644 packages/tasks/package.json delete mode 100644 packages/tasks/tsconfig.json delete mode 100644 packages/tasks/tsdown.config.ts delete mode 100644 packages/tasks/typedoc.json delete mode 100644 packages/tasks/vitest.config.js diff --git a/.changeset/tasks-extension-package.md b/.changeset/tasks-extension-package.md index 03206c666f..556d5fa9d3 100644 --- a/.changeset/tasks-extension-package.md +++ b/.changeset/tasks-extension-package.md @@ -1,5 +1,5 @@ --- -'@modelcontextprotocol/tasks': minor +'@modelcontextprotocol/server': minor --- -New package: server-side MCP Tasks extension (`io.modelcontextprotocol/tasks`) with a pluggable execution engine. `installTasks(server, { engine })` serves `tasks/get`, `tasks/update` and `tasks/cancel` and returns `registerTask`, which is `registerTool` for long-running work: the handler runs as a replayable workflow against a `Step` API (`do`, `sleep`, `sleepUntil`, `elicit`, `offer`, `checkInput`, `status`). Engines implement two interfaces — `TaskEngine` (create / get / update / cancel) and `StepJournal` (what the step API drives) — and `InMemoryTaskEngine` is the in-process reference. Durable engines live outside the SDK. +New subpath `@modelcontextprotocol/server/ext/tasks`: the server side of the MCP Tasks extension (`io.modelcontextprotocol/tasks`) with a pluggable execution engine. `installTasks(server, { engine })` serves `tasks/get`, `tasks/update` and `tasks/cancel` and returns `registerTask`, which is `registerTool` for long-running work: the handler runs as a replayable workflow against a `Step` API (`do`, `sleep`, `sleepUntil`, `elicit`, `offer`, `checkInput`, `status`). Engines implement two interfaces — `TaskEngine` (create / get / update / cancel) and `StepJournal` (what the step API drives) — and `InMemoryTaskEngine` is the in-process reference. Durable engines live outside the SDK. diff --git a/docs/.vitepress/nav.ts b/docs/.vitepress/nav.ts index 02ab4d2ff6..0bc9631dc4 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/packages/tasks/README.md b/docs/servers/tasks.md similarity index 90% rename from packages/tasks/README.md rename to docs/servers/tasks.md index f4fa098f93..5e5c2ac345 100644 --- a/packages/tasks/README.md +++ b/docs/servers/tasks.md @@ -1,12 +1,16 @@ -# `@modelcontextprotocol/tasks` +--- +shape: how-to +--- -Server-side [MCP Tasks extension](https://github.com/modelcontextprotocol/ext-tasks) (`io.modelcontextprotocol/tasks`) for `@modelcontextprotocol/server`, with a pluggable execution engine. +# Tasks + +Server-side [MCP Tasks extension](https://github.com/modelcontextprotocol/ext-tasks) (`io.modelcontextprotocol/tasks`) with a pluggable execution engine. `registerTask` is `registerTool` for long-running work: the handler runs as a replayable workflow (`step.do`, `step.sleep`, `step.elicit`, `step.status`, `step.offer`), and the extension's `tasks/get`, `tasks/update` and `tasks/cancel` methods route to per-task state that outlives the request that created it. ```ts import { McpServer } from '@modelcontextprotocol/server'; -import { InMemoryTaskEngine, installTasks } from '@modelcontextprotocol/tasks'; +import { InMemoryTaskEngine, installTasks } from '@modelcontextprotocol/server/ext/tasks'; import * as z from 'zod/v4'; const engine = new InMemoryTaskEngine(); @@ -40,7 +44,7 @@ Handlers never touch an engine directly. Two interfaces keep them engine-invaria `InMemoryTaskEngine` implements both in-process and is the reference: task records and step journals in a `Map`, one timer per task computed from the rows, handlers run through the attached executor. State does not survive the process. -A durable engine implements the same two interfaces and lives outside this package: `TaskEngine` over a database or durable-execution runtime, `StepJournal` over its journal rows, and either `attach`es the executor (`createTaskExecutor(tasks)`) to run handlers in-process or builds one where the handlers run. Swapping engines changes the `installTasks` call and nothing else. +A durable engine implements the same two interfaces and lives outside the SDK: `TaskEngine` over a database or durable-execution runtime, `StepJournal` over its journal rows, and either `attach`es the executor (`createTaskExecutor(tasks)`) to run handlers in-process or builds one where the handlers run. Swapping engines changes the `installTasks` call and nothing else. ## Step API 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/tasks/src/engine/backoff.ts b/packages/server/src/ext/tasks/engine/backoff.ts similarity index 100% rename from packages/tasks/src/engine/backoff.ts rename to packages/server/src/ext/tasks/engine/backoff.ts diff --git a/packages/tasks/src/engine/defaults.ts b/packages/server/src/ext/tasks/engine/defaults.ts similarity index 100% rename from packages/tasks/src/engine/defaults.ts rename to packages/server/src/ext/tasks/engine/defaults.ts diff --git a/packages/tasks/src/engine/duration.ts b/packages/server/src/ext/tasks/engine/duration.ts similarity index 100% rename from packages/tasks/src/engine/duration.ts rename to packages/server/src/ext/tasks/engine/duration.ts diff --git a/packages/tasks/src/engine/errors.ts b/packages/server/src/ext/tasks/engine/errors.ts similarity index 100% rename from packages/tasks/src/engine/errors.ts rename to packages/server/src/ext/tasks/engine/errors.ts diff --git a/packages/tasks/src/engine/executor.ts b/packages/server/src/ext/tasks/engine/executor.ts similarity index 100% rename from packages/tasks/src/engine/executor.ts rename to packages/server/src/ext/tasks/engine/executor.ts diff --git a/packages/tasks/src/engine/inMemory.ts b/packages/server/src/ext/tasks/engine/inMemory.ts similarity index 99% rename from packages/tasks/src/engine/inMemory.ts rename to packages/server/src/ext/tasks/engine/inMemory.ts index 5f7f9b79ad..a0bad71640 100644 --- a/packages/tasks/src/engine/inMemory.ts +++ b/packages/server/src/ext/tasks/engine/inMemory.ts @@ -9,8 +9,7 @@ * store would make. State does not survive the process. */ -import type { CallToolResult, InputRequests, InputResponses } from '@modelcontextprotocol/server'; - +import type { CallToolResult, InputRequests, InputResponses } from '../../../index'; import type { DetailedTask, Task, TaskStatus } from '../wire/types'; import type { SerializedError } from './errors'; import { DuplicateStepError } from './errors'; diff --git a/packages/tasks/src/engine/protocol.ts b/packages/server/src/ext/tasks/engine/protocol.ts similarity index 99% rename from packages/tasks/src/engine/protocol.ts rename to packages/server/src/ext/tasks/engine/protocol.ts index 646d2bcaca..62b034ca77 100644 --- a/packages/tasks/src/engine/protocol.ts +++ b/packages/server/src/ext/tasks/engine/protocol.ts @@ -9,8 +9,7 @@ * {@link StepJournal}, the surface the replay-aware step API drives. */ -import type { CallToolResult } from '@modelcontextprotocol/server'; - +import type { CallToolResult } from '../../../index'; import type { SerializedError } from './errors'; /** One claimed execution attempt, as dispatched by the engine. */ diff --git a/packages/tasks/src/engine/serialization.ts b/packages/server/src/ext/tasks/engine/serialization.ts similarity index 100% rename from packages/tasks/src/engine/serialization.ts rename to packages/server/src/ext/tasks/engine/serialization.ts diff --git a/packages/tasks/src/engine/taskEngine.ts b/packages/server/src/ext/tasks/engine/taskEngine.ts similarity index 97% rename from packages/tasks/src/engine/taskEngine.ts rename to packages/server/src/ext/tasks/engine/taskEngine.ts index 5b175973d7..97960ee614 100644 --- a/packages/tasks/src/engine/taskEngine.ts +++ b/packages/server/src/ext/tasks/engine/taskEngine.ts @@ -6,8 +6,7 @@ * shapes beyond the `DetailedTask` snapshot they return. */ -import type { InputResponses } from '@modelcontextprotocol/server'; - +import type { InputResponses } from '../../../index'; import type { DetailedTask, Task } from '../wire/types'; import type { TaskExecutor } from './protocol'; diff --git a/packages/tasks/src/index.ts b/packages/server/src/ext/tasks/index.ts similarity index 100% rename from packages/tasks/src/index.ts rename to packages/server/src/ext/tasks/index.ts diff --git a/packages/tasks/src/server/installTasks.ts b/packages/server/src/ext/tasks/server/installTasks.ts similarity index 96% rename from packages/tasks/src/server/installTasks.ts rename to packages/server/src/ext/tasks/server/installTasks.ts index ab6df3da1d..d8f69d64ce 100644 --- a/packages/tasks/src/server/installTasks.ts +++ b/packages/server/src/ext/tasks/server/installTasks.ts @@ -12,21 +12,8 @@ * the request is refused with `MissingRequiredClientCapability` (`-32021`). */ -import type { - CallToolResult, - InputResponses, - McpServer, - ServerContext, - StandardSchemaWithJSON, - ToolAnnotations -} from '@modelcontextprotocol/server'; -import { - CLIENT_CAPABILITIES_META_KEY, - MissingRequiredClientCapabilityError, - ProtocolError, - ProtocolErrorCode -} from '@modelcontextprotocol/server'; - +import type { CallToolResult, InputResponses, McpServer, ServerContext, StandardSchemaWithJSON, ToolAnnotations } from '../../../index'; +import { CLIENT_CAPABILITIES_META_KEY, MissingRequiredClientCapabilityError, ProtocolError, ProtocolErrorCode } from '../../../index'; import { DEFAULT_POLL_INTERVAL_MS, DEFAULT_RETRY_POLICY, DEFAULT_TTL_MS } from '../engine/defaults'; import { createTaskExecutor } from '../engine/executor'; import type { TaskEngine } from '../engine/taskEngine'; diff --git a/packages/tasks/src/server/registration.ts b/packages/server/src/ext/tasks/server/registration.ts similarity index 97% rename from packages/tasks/src/server/registration.ts rename to packages/server/src/ext/tasks/server/registration.ts index 2e6a909ada..b97d9aee21 100644 --- a/packages/tasks/src/server/registration.ts +++ b/packages/server/src/ext/tasks/server/registration.ts @@ -1,5 +1,4 @@ -import type { CallToolResult, StandardSchemaWithJSON, ToolAnnotations } from '@modelcontextprotocol/server'; - +import type { CallToolResult, StandardSchemaWithJSON, ToolAnnotations } from '../../../index'; import type { RetryPolicy, Step } from '../step/types'; /** The validated input type a task handler receives for its input schema. */ diff --git a/packages/tasks/src/step/replayStep.ts b/packages/server/src/ext/tasks/step/replayStep.ts similarity index 100% rename from packages/tasks/src/step/replayStep.ts rename to packages/server/src/ext/tasks/step/replayStep.ts diff --git a/packages/tasks/src/step/types.ts b/packages/server/src/ext/tasks/step/types.ts similarity index 100% rename from packages/tasks/src/step/types.ts rename to packages/server/src/ext/tasks/step/types.ts diff --git a/packages/tasks/src/wire/schemas.ts b/packages/server/src/ext/tasks/wire/schemas.ts similarity index 100% rename from packages/tasks/src/wire/schemas.ts rename to packages/server/src/ext/tasks/wire/schemas.ts diff --git a/packages/tasks/src/wire/types.ts b/packages/server/src/ext/tasks/wire/types.ts similarity index 98% rename from packages/tasks/src/wire/types.ts rename to packages/server/src/ext/tasks/wire/types.ts index 87112a1ff6..e5e216266f 100644 --- a/packages/tasks/src/wire/types.ts +++ b/packages/server/src/ext/tasks/wire/types.ts @@ -19,14 +19,14 @@ * Copyright (c) Model Context Protocol contributors */ -import type { InputRequests, InputResponses, Result } from '@modelcontextprotocol/server'; +import type { InputRequests, InputResponses, Result } from '../../../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/server'; +export type { InputRequest, InputRequests, InputResponse, InputResponses } from '../../../index'; /** The MCP Tasks extension identifier. An empty-object capability declares support. */ export const TASKS_EXTENSION_ID = 'io.modelcontextprotocol/tasks'; diff --git a/packages/tasks/test/inMemoryEngine.test.ts b/packages/server/test/ext/tasks/inMemoryEngine.test.ts similarity index 97% rename from packages/tasks/test/inMemoryEngine.test.ts rename to packages/server/test/ext/tasks/inMemoryEngine.test.ts index 2de73d6a90..0e31a9b745 100644 --- a/packages/tasks/test/inMemoryEngine.test.ts +++ b/packages/server/test/ext/tasks/inMemoryEngine.test.ts @@ -5,8 +5,8 @@ */ import { describe, expect, it } from 'vitest'; -import type { RunOutcome, StepJournal, TaskExecutor, TaskInvocation } from '../src/index'; -import { DuplicateStepError, InMemoryTaskEngine, StaleLeaseError } from '../src/index'; +import type { RunOutcome, StepJournal, TaskExecutor, TaskInvocation } from '../../../src/ext/tasks/index'; +import { DuplicateStepError, InMemoryTaskEngine, StaleLeaseError } from '../../../src/ext/tasks/index'; const tick = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms)); @@ -69,8 +69,8 @@ describe('InMemoryTaskEngine', () => { return { outcome: 'completed', result: { content: [] } }; }) ); - await engine.create(params); - await tick(20); + const task = await engine.create(params); + for (let i = 0; i < 100 && (await engine.get(task.taskId))?.status !== 'completed'; i++) await tick(5); await expect(stale?.beginStep('late')).rejects.toBeInstanceOf(StaleLeaseError); engine.close(); }); diff --git a/packages/tasks/test/tasks.e2e.test.ts b/packages/server/test/ext/tasks/tasks.e2e.test.ts similarity index 98% rename from packages/tasks/test/tasks.e2e.test.ts rename to packages/server/test/ext/tasks/tasks.e2e.test.ts index c531292d5e..370d9cc9f3 100644 --- a/packages/tasks/test/tasks.e2e.test.ts +++ b/packages/server/test/ext/tasks/tasks.e2e.test.ts @@ -5,11 +5,11 @@ * instance that answered `tools/call`; the engine is the only shared state. */ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { CLIENT_CAPABILITIES_META_KEY, createMcpHandler, McpServer, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/server'; +import { CLIENT_CAPABILITIES_META_KEY, createMcpHandler, McpServer, PROTOCOL_VERSION_META_KEY } from '../../../src/index'; import { afterEach, describe, expect, it } from 'vitest'; import * as z from 'zod/v4'; -import type { InputResponses, Step } from '../src/index'; +import type { InputResponses, Step } from '../../../src/ext/tasks/index'; import { createTaskResultSchema, detailedTaskSchema, @@ -17,7 +17,7 @@ import { installTasks, NonRetryableError, TASKS_EXTENSION_ID -} from '../src/index'; +} from '../../../src/ext/tasks/index'; /** * The SDK `Client` consumes `resultType` (a wire-only field) before a caller 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', diff --git a/packages/tasks/eslint.config.mjs b/packages/tasks/eslint.config.mjs deleted file mode 100644 index c1267b73c1..0000000000 --- a/packages/tasks/eslint.config.mjs +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-check - -import baseConfig from '@modelcontextprotocol/eslint-config'; - -export default [...baseConfig]; diff --git a/packages/tasks/package.json b/packages/tasks/package.json deleted file mode 100644 index 7265722f77..0000000000 --- a/packages/tasks/package.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "name": "@modelcontextprotocol/tasks", - "version": "0.1.0", - "description": "Model Context Protocol implementation for TypeScript - server-side Tasks extension (io.modelcontextprotocol/tasks) with a pluggable execution engine", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" - }, - "engines": { - "node": ">=20" - }, - "keywords": [ - "modelcontextprotocol", - "mcp", - "tasks", - "workflow" - ], - "exports": { - ".": { - "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } - } - }, - "main": "./dist/index.cjs", - "types": "./dist/index.d.mts", - "files": [ - "dist" - ], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "pnpm run build", - "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": { - "@modelcontextprotocol/core": "workspace:*", - "zod": "catalog:runtimeShared" - }, - "peerDependencies": { - "@modelcontextprotocol/server": "workspace:^" - }, - "devDependencies": { - "@eslint/js": "catalog:devTools", - "@modelcontextprotocol/client": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", - "@modelcontextprotocol/server": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@typescript/native-preview": "catalog:devTools", - "eslint": "catalog:devTools", - "eslint-config-prettier": "catalog:devTools", - "eslint-plugin-n": "catalog:devTools", - "prettier": "catalog:devTools", - "tsdown": "catalog:devTools", - "typescript": "catalog:devTools", - "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" - } -} diff --git a/packages/tasks/tsconfig.json b/packages/tasks/tsconfig.json deleted file mode 100644 index 9f2dcd1f22..0000000000 --- a/packages/tasks/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "extends": "@modelcontextprotocol/tsconfig", - "include": ["./"], - "exclude": ["node_modules", "dist"], - "compilerOptions": { - "paths": { - "*": ["./*"], - "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], - "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], - "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], - "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], - "@modelcontextprotocol/core-internal": [ - "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/index.ts" - ], - "@modelcontextprotocol/core/internal": [ - "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/internal.ts" - ], - "@modelcontextprotocol/core-internal/public": [ - "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" - ] - } - } -} diff --git a/packages/tasks/tsdown.config.ts b/packages/tasks/tsdown.config.ts deleted file mode 100644 index 2565de4620..0000000000 --- a/packages/tasks/tsdown.config.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { defineConfig } from 'tsdown'; - -export default defineConfig({ - failOnWarn: 'ci-only', - entry: ['src/index.ts'], - format: ['esm', 'cjs'], - fixedExtension: true, - outDir: 'dist', - clean: true, - sourcemap: true, - target: 'esnext', - platform: 'neutral', - dts: { - resolver: 'tsc', - // Keep workspace deps as external imports in the bundled .d.ts instead of - // inlining their type graph — see ../middleware/hono/tsdown.config.ts. - compilerOptions: { - paths: {}, - preserveSymlinks: true - } - } -}); diff --git a/packages/tasks/typedoc.json b/packages/tasks/typedoc.json deleted file mode 100644 index a9fd090d0f..0000000000 --- a/packages/tasks/typedoc.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "https://typedoc.org/schema.json", - "entryPoints": ["src"], - "entryPointStrategy": "expand", - "exclude": ["**/*.test.ts", "**/__*__/**"], - "navigation": { - "includeGroups": true, - "includeCategories": true - } -} diff --git a/packages/tasks/vitest.config.js b/packages/tasks/vitest.config.js deleted file mode 100644 index 496fca3200..0000000000 --- a/packages/tasks/vitest.config.js +++ /dev/null @@ -1,3 +0,0 @@ -import baseConfig from '@modelcontextprotocol/vitest-config'; - -export default baseConfig; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a27efdf4c..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 @@ -1940,61 +1943,6 @@ importers: specifier: catalog:devTools version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) - packages/tasks: - dependencies: - '@modelcontextprotocol/core': - specifier: workspace:* - version: link:../core - zod: - specifier: catalog:runtimeShared - version: 4.3.6 - devDependencies: - '@eslint/js': - specifier: catalog:devTools - version: 9.39.4 - '@modelcontextprotocol/client': - specifier: workspace:^ - version: link:../client - '@modelcontextprotocol/eslint-config': - specifier: workspace:^ - version: link:../../common/eslint-config - '@modelcontextprotocol/server': - specifier: workspace:^ - version: link:../server - '@modelcontextprotocol/tsconfig': - specifier: workspace:^ - version: link:../../common/tsconfig - '@modelcontextprotocol/vitest-config': - specifier: workspace:^ - version: link:../../common/vitest-config - '@typescript/native-preview': - specifier: catalog:devTools - version: 7.0.0-dev.20260327.2 - eslint: - specifier: catalog:devTools - version: 9.39.4 - eslint-config-prettier: - specifier: catalog:devTools - version: 10.1.8(eslint@9.39.4) - eslint-plugin-n: - specifier: catalog:devTools - version: 17.24.0(eslint@9.39.4)(typescript@5.9.3) - prettier: - specifier: catalog:devTools - version: 3.6.2 - tsdown: - specifier: catalog:devTools - version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) - typescript: - specifier: catalog:devTools - version: 5.9.3 - typescript-eslint: - specifier: catalog:devTools - version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) - vitest: - specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) - test/conformance: devDependencies: '@modelcontextprotocol/client':