diff --git a/examples/README.md b/examples/README.md index fc57f2de7d..f421a5b773 100644 --- a/examples/README.md +++ b/examples/README.md @@ -31,29 +31,30 @@ The one exception to the generic commands is the reference pair: [`cli-client/`] ## Feature stories -| Story | What it teaches | Transports | Era | -| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | -------------- | -| [`mrtr/`](./mrtr/README.md) | Multi-round-trip write-once tool, secure `requestState` | stdio + http | modern | -| [`subscriptions/`](./subscriptions/README.md) | `subscriptions/listen`: `client.listen()` + auto-open, `handler.notify` / `ServerEventBus` | stdio + http | modern | -| [`streaming/`](./streaming/README.md) | In-flight progress, logging, cancellation | stdio + http | dual | -| [`elicitation/`](./elicitation/README.md) | Elicitation (form + URL mode), both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual | -| [`sampling/`](./sampling/README.md) | Tool that requests LLM sampling from the client, both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual | -| [`stickynotes/`](./stickynotes/README.md) | "Real app" capstone: tools mutate state, a resource per note, listChanged, elicitation-confirmed clear | stdio + http | dual | -| [`cli-client/`](./cli-client/README.md) | **Reference host**: LLM chat CLI with provider seam — tool loop, @-mention resources, prompt commands, sampling, elicitation, roots, OAuth, cancellation | stdio + http | dual | -| [`todos-server/`](./todos-server/README.md) | **Reference server** (pairs with cli-client): every server feature with a real job — CRUD tools, sampling, multi-round elicitation, subscriptions, progress | stdio + http | dual | -| [`caching/`](./caching/README.md) | `cacheHints` stamping on cacheable results (2026-07-28) | stdio + http | modern | -| [`gateway/`](./gateway/README.md) | `connect({ prior })` — probe once, zero-round-trip connect for every worker (gateway pattern) | http | modern | -| [`custom-methods/`](./custom-methods/README.md) | Vendor-prefixed methods + custom notifications | stdio + http | dual | -| [`extension-capabilities/`](./extension-capabilities/README.md) | Declaring `capabilities.extensions` and reading the negotiated map | stdio + http | dual | -| [`schema-validators/`](./schema-validators/README.md) | ArkType, Valibot, Zod, and `outputSchema` | stdio + http | dual | -| [`custom-version/`](./custom-version/README.md) | `supportedProtocolVersions` / version negotiation | stdio + http | legacy | -| [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | stdio + http | dual | -| [`legacy-routing/`](./legacy-routing/README.md) | `isLegacyRequest` in front of an existing sessionful 1.x deployment + a strict modern entry on one port | http | dual (in-body) | -| [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | dual | -| [`bearer-auth-web/`](./bearer-auth-web/README.md) | Web-standard twin: host/origin guards + `requireBearerAuth` + `createMcpHandler` as one fetch handler | http | dual | -| [`oauth/`](./oauth/README.md) | OAuth `authorization_code`: in-repo AS (auto-consent) + headless redirect-following client | http | dual | -| [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | dual | -| [`scoped-tools/`](./scoped-tools/README.md) | Per-tool scope on `createMcpHandler` — bearer-verify gate + handler-level `ctx.http?.authInfo` checks | http | modern | +| Story | What it teaches | Transports | Era | +| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | -------------- | +| [`mrtr/`](./mrtr/README.md) | Multi-round-trip write-once tool, secure `requestState` | stdio + http | modern | +| [`subscriptions/`](./subscriptions/README.md) | `subscriptions/listen`: `client.listen()` + auto-open, `handler.notify` / `ServerEventBus` | stdio + http | modern | +| [`streaming/`](./streaming/README.md) | In-flight progress, logging, cancellation | stdio + http | dual | +| [`elicitation/`](./elicitation/README.md) | Elicitation (form + URL mode), both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual | +| [`sampling/`](./sampling/README.md) | Tool that requests LLM sampling from the client, both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual | +| [`stickynotes/`](./stickynotes/README.md) | "Real app" capstone: tools mutate state, a resource per note, listChanged, elicitation-confirmed clear | stdio + http | dual | +| [`cli-client/`](./cli-client/README.md) | **Reference host**: LLM chat CLI with provider seam — tool loop, @-mention resources, prompt commands, sampling, elicitation, roots, OAuth, cancellation | stdio + http | dual | +| [`todos-server/`](./todos-server/README.md) | **Reference server** (pairs with cli-client): every server feature with a real job — CRUD tools, sampling, multi-round elicitation, subscriptions, progress | stdio + http | dual | +| [`caching/`](./caching/README.md) | `cacheHints` stamping on cacheable results (2026-07-28) | stdio + http | modern | +| [`gateway/`](./gateway/README.md) | `connect({ prior })` — probe once, zero-round-trip connect for every worker (gateway pattern) | http | modern | +| [`custom-methods/`](./custom-methods/README.md) | Vendor-prefixed methods + custom notifications | stdio + http | dual | +| [`extension-capabilities/`](./extension-capabilities/README.md) | Declaring `capabilities.extensions` and reading the negotiated map | stdio + http | dual | +| [`schema-validators/`](./schema-validators/README.md) | ArkType, Valibot, Zod, and `outputSchema` | stdio + http | dual | +| [`custom-version/`](./custom-version/README.md) | `supportedProtocolVersions` / version negotiation | stdio + http | legacy | +| [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | stdio + http | dual | +| [`legacy-routing/`](./legacy-routing/README.md) | `isLegacyRequest` in front of an existing sessionful 1.x deployment + a strict modern entry on one port | http | dual (in-body) | +| [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | dual | +| [`bearer-auth-web/`](./bearer-auth-web/README.md) | Web-standard twin: host/origin guards + `requireBearerAuth` + `createMcpHandler` as one fetch handler | http | dual | +| [`oauth/`](./oauth/README.md) | OAuth `authorization_code`: in-repo AS (auto-consent) + headless redirect-following client | http | dual | +| [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | dual | +| [`scoped-tools/`](./scoped-tools/README.md) | Per-tool scope on `createMcpHandler` — bearer-verify gate + handler-level `ctx.http?.authInfo` checks | http | modern | +| [`ext-tasks/`](./ext-tasks/README.md) | Tasks extension: `TasksExtension` + `InMemoryTaskStore` on the server, `TasksClientExtension` + `waitFor` on the client, in-process work with `input_required` and cancel | stdio + http | modern | ## HTTP hosting variants diff --git a/examples/ext-tasks/README.md b/examples/ext-tasks/README.md new file mode 100644 index 0000000000..46019a1125 --- /dev/null +++ b/examples/ext-tasks/README.md @@ -0,0 +1,13 @@ +# ext-tasks + +A minimal server for the [MCP Tasks extension](https://github.com/modelcontextprotocol/ext-tasks) (`io.modelcontextprotocol/tasks`), everything in-process. `TasksExtension` over an `InMemoryTaskStore` serves the wire; the work behind the one tool, `bake_cake`, is a plain async function driven by timers that reports progress, asks the client which frosting to use (`input_required`), and honours cancellation between steps. + +The client installs `TasksClientExtension`, calls the tool, follows the task with `waitFor` (answering the frosting question through `tasks/update` on the way), then starts a second task and cancels it while it waits for input. + +```bash +pnpm tsx examples/ext-tasks/client.ts # stdio, spawns the server +pnpm tsx examples/ext-tasks/server.ts --http --port 3000 # or serve over HTTP … +pnpm tsx examples/ext-tasks/client.ts --http http://127.0.0.1:3000/mcp +``` + +Modern era only: task handles and the `tasks/*` methods ride the 2026-07-28 per-request capabilities envelope. diff --git a/examples/ext-tasks/client.ts b/examples/ext-tasks/client.ts new file mode 100644 index 0000000000..f9d3981f98 --- /dev/null +++ b/examples/ext-tasks/client.ts @@ -0,0 +1,75 @@ +/** + * Connects to `./server.ts` with the Tasks client extension, calls `bake_cake` + * twice, and asserts both outcomes: one task followed to completion through + * `waitFor` (answering the frosting question on the way), one cancelled + * mid-bake. + * + * Spawns the sibling `server.ts` over stdio by default, or connects to a + * running endpoint under `--http `. See `examples/CONTRIBUTING.md` for + * the canonical shape. + */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { TasksClientExtension } from '@modelcontextprotocol/client/ext/tasks'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +const { transport, url } = parseExampleArgs(); + +const tasks = new TasksClientExtension(); +const client = new Client( + { name: 'tasks-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' }, extensions: [tasks] } +); + +await client.connect( + transport === 'stdio' + ? new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] }) + : new StreamableHTTPClientTransport(new URL(url)) +); + +check.ok('io.modelcontextprotocol/tasks' in (client.getServerCapabilities()?.extensions ?? {})); + +// 1. A task followed to the end. `tools/call` answers a handle at once. +const started = await tasks.callTool({ name: 'bake_cake', arguments: { layers: 3 } }); +check.equal(started.kind, 'task'); +if (started.kind !== 'task') throw new Error('unreachable'); +console.log(`[client] task ${started.task.taskId} created: ${started.task.status}`); + +const seen: string[] = []; +const done = await tasks.waitFor(started.task.taskId, { + onUpdate: async task => { + seen.push(`${task.status}${task.statusMessage ? ` (${task.statusMessage})` : ''}`); + if (task.status === 'input_required') { + // The server asked which frosting; answer through tasks/update. + check.deepEqual(Object.keys(task.inputRequests), ['frosting']); + await tasks.update(task.taskId, { frosting: { action: 'accept', content: { frosting: 'chocolate' } } }); + } + } +}); +console.log(`[client] saw: ${[...new Set(seen)].join(' -> ')}`); +check.equal(done.status, 'completed'); +if (done.status !== 'completed') throw new Error('unreachable'); +check.deepEqual(done.result['structuredContent'], { layers: 3, frosting: 'chocolate' }); +check.ok(seen.some(s => s.startsWith('input_required'))); + +// 2. A task cancelled while it waits for the frosting answer. +const second = await tasks.callTool({ name: 'bake_cake', arguments: { layers: 1 } }); +if (second.kind !== 'task') throw new Error('expected a task handle'); +await tasks + .waitFor(second.task.taskId, { + onUpdate: async task => { + if (task.status === 'input_required') await tasks.cancel(task.taskId); + } + }) + .then(task => { + console.log(`[client] task ${task.taskId} ended: ${task.status}`); + check.equal(task.status, 'cancelled'); + }); + +// 3. Unknown task ids are -32602. +await tasks.get('no-such-task').then( + () => check.fail('expected tasks/get to reject'), + (error: unknown) => check.equal((error as { code?: number }).code, -32_602) +); + +await client.close(); diff --git a/examples/ext-tasks/package.json b/examples/ext-tasks/package.json new file mode 100644 index 0000000000..0f852399ed --- /dev/null +++ b/examples/ext-tasks/package.json @@ -0,0 +1,24 @@ +{ + "name": "@mcp-examples/ext-tasks", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "modern", + "//": "The Tasks extension (io.modelcontextprotocol/tasks) is a 2026-07-28 extension: task handles and tasks/* ride the per-request capabilities envelope." + } +} diff --git a/examples/ext-tasks/server.ts b/examples/ext-tasks/server.ts new file mode 100644 index 0000000000..f41d172d36 --- /dev/null +++ b/examples/ext-tasks/server.ts @@ -0,0 +1,107 @@ +/** + * A minimal Tasks server (`io.modelcontextprotocol/tasks`), everything + * in-process: `InMemoryTaskStore` holds the task records, and the work behind + * a task is a plain async function driven by timers. The SDK owns the wire; + * how the work runs is this file's own business. + * + * One tool, `bake_cake`: answers with a task handle at once, then mixes, asks + * the client which frosting to use (`input_required`), bakes, and completes — + * or stops at the next step when the client cancels. + * + * One binary, either transport — selected by `--http --port ` (defaults to + * stdio). See `examples/CONTRIBUTING.md` for the canonical shape. + */ +import { serve } from '@hono/node-server'; +import { parseExampleArgs } from '@mcp-examples/shared'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import type { TaskHandle } from '@modelcontextprotocol/server/ext/tasks'; +import { InMemoryTaskStore, TasksExtension } from '@modelcontextprotocol/server/ext/tasks'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +// One store for the process: tasks outlive the request (and, under +// `createMcpHandler`, the McpServer instance) that created them. +const store = new InMemoryTaskStore(); +const tasks = new TasksExtension(store, { defaultPollIntervalMs: 50 }); + +const sleep = (ms: number, signal: AbortSignal) => + new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal.addEventListener( + 'abort', + () => { + clearTimeout(timer); + reject(new Error('cancelled')); + }, + { once: true } + ); + }); + +/** The work: reports progress, asks one question, honours cancellation between steps. */ +async function bakeCake(handle: TaskHandle, layers: number): Promise { + try { + await handle.status(`mixing ${layers} layers`); + await sleep(30, handle.signal); + + const answers = await handle.requireInput({ + frosting: { + method: 'elicitation/create', + params: { + message: 'Which frosting?', + mode: 'form', + requestedSchema: { type: 'object', properties: { frosting: { type: 'string', enum: ['vanilla', 'chocolate'] } } } + } + } + }); + const frosting = (answers['frosting'] as { content?: { frosting?: string } } | undefined)?.content?.frosting ?? 'plain'; + + await handle.status(`baking with ${frosting} frosting`); + await sleep(30, handle.signal); + + await handle.complete({ + content: [{ type: 'text', text: `${layers}-layer cake, ${frosting} frosting` }], + structuredContent: { layers, frosting } + }); + } catch (error) { + // A cancelled task ignores later writes; anything else is a real failure. + if (!handle.signal.aborted) await handle.fail({ code: -32_000, message: String(error) }); + } +} + +function buildServer(): McpServer { + const mcp = new McpServer({ name: 'tasks-example-server', version: '1.0.0' }, { extensions: [tasks] }); + + mcp.registerTool( + 'bake_cake', + { + description: 'Bake a cake as a task: mixes, asks for a frosting, bakes.', + inputSchema: z.object({ layers: z.number().int().min(1).max(5) }) + }, + async ({ layers }, ctx) => { + // Create the task (bound to the caller's principal, if any) and + // answer with its handle; the work starts in the background. + const task = await tasks.create(ctx, { ttlMs: 60_000 }); + void bakeCake(store.handle(task.taskId), layers); + return task; + } + ); + + return mcp; +} + +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + // `createMcpHonoApp()` arms localhost host/origin validation by default; + // bind loopback explicitly to match. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/tsconfig.json b/examples/tsconfig.json index 9405854c1f..5d5567d215 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -27,7 +27,12 @@ "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], - "@mcp-examples/shared": ["./node_modules/@mcp-examples/shared/src/index.ts"] + "@mcp-examples/shared": ["./node_modules/@mcp-examples/shared/src/index.ts"], + "@modelcontextprotocol/server/ext/tasks": ["./node_modules/@modelcontextprotocol/server/src/ext/tasks/index.ts"], + "@modelcontextprotocol/client/ext/tasks": ["./node_modules/@modelcontextprotocol/client/src/ext/tasks/index.ts"], + "@modelcontextprotocol/core-internal/ext/tasks": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/ext/tasks/index.ts" + ] } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b05427df1..d73237666b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -594,6 +594,31 @@ importers: specifier: catalog:devTools version: 4.21.0 + examples/ext-tasks: + dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/hono': + specifier: workspace:* + version: link:../../packages/middleware/hono + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + examples/extension-capabilities: dependencies: '@hono/node-server':