From 2ab9fd1576410e0ffa444774e7ec7b450f9267d3 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 1 Jul 2026 15:47:59 +0000 Subject: [PATCH 01/23] feat(examples): deploy todos-server to Cloudflare Workers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Workers entry to the reference server: worker.ts serves the landing page at / and MCP at /mcp, with one Durable Object per visitor (keyed by connecting address or an X-Todos-Board header, namespaced so the two can never collide) holding that board's memory, persisting it to durable storage on every change, and wiping it via a sliding ~2h alarm. Boards are capped (200 tasks by default; MAX_TASKS var). CORS is wide open by design (public, credential-free endpoint) and applied to every response, with preflights reflecting the requested headers. Two serving modes share each board. 2026-07-28 clients (and session- less legacy one-shots) ride createMcpHandler's per-request model. A 2025-era initialize opens a real session — a per-session WebStandardStreamableHTTPServerTransport connected to a server pinned to that session — so push-style elicitation/sampling work over HTTP for legacy clients too. Session ids embed the visitor key and the worker routes on that prefix, keeping a session on its board even when the client's egress IP rotates. Sessions are in-memory; on recycle the client gets the spec's 404 and re-initializes while the board stays durable. Each session has its own transport. todos.ts drops its module-global board for a createTodosApp() factory: per-module state leaks across co-located Durable Object instances, and its module-scope process.env/RNG reads don't run on workerd at all. server.ts constructs the app explicitly. snapshot()/restore() support the persistence, and the multi-round requestState key falls back to a per-board key persisted in DO storage, so input_required flows survive isolate recycling even without a deployment-wide secret. The worker bundles the built dist packages via tsconfig.worker.json (paths cleared so the workerd exports condition wins over the examples tsconfig's node-flavoured source mappings). --- .gitignore | 3 + docs/serving/legacy-clients.md | 65 + docs/serving/sessions-state-scaling.md | 3 + .../guides/serving/legacy-clients.examples.ts | 113 ++ examples/todos-server/README.md | 36 +- examples/todos-server/index.html | 107 ++ examples/todos-server/package.json | 7 +- examples/todos-server/server.ts | 30 +- examples/todos-server/todos.ts | 1099 +++++++++-------- examples/todos-server/tsconfig.worker.json | 12 + examples/todos-server/worker.ts | 341 +++++ examples/todos-server/workerEnv.d.ts | 5 + examples/todos-server/wrangler.toml | 28 + 13 files changed, 1344 insertions(+), 505 deletions(-) create mode 100644 examples/todos-server/index.html create mode 100644 examples/todos-server/tsconfig.worker.json create mode 100644 examples/todos-server/worker.ts create mode 100644 examples/todos-server/workerEnv.d.ts create mode 100644 examples/todos-server/wrangler.toml diff --git a/.gitignore b/.gitignore index 317c96abca..84772cd94b 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,6 @@ results/ # Ignore local lefthook configuration lefthook-local.yml + +# Wrangler local dev cache (examples/todos-server worker entry) +.wrangler/ diff --git a/docs/serving/legacy-clients.md b/docs/serving/legacy-clients.md index 62980e01cc..6c70fca707 100644 --- a/docs/serving/legacy-clients.md +++ b/docs/serving/legacy-clients.md @@ -1,6 +1,7 @@ --- shape: how-to --- + # Support legacy clients A **legacy client** speaks a 2025-era protocol revision: it opens with `initialize` and sends no per-request `_meta` envelope. Both serving entry points answer those clients from the same factory that serves modern ones; the `legacy` option decides whether they keep doing it. [Protocol versions](../protocol-versions.md) covers the era model itself. @@ -87,6 +88,69 @@ The `initialize` the strict handler rejected above now completes the 2025 handsh Behind an Express body parser the Node stream is already drained: build the `Request` the predicate takes with `toWebRequest(req, req.body)` from `@modelcontextprotocol/node`. ::: +## Serve elicitation to 2025-era HTTP clients + +Per-request legacy serving has no return path for server→client requests, so a tool that asks for input mid-call answers a **2025-era HTTP client** with an error result instead. The same tool serves its interactive rounds to 2026-07-28 clients as [`input_required` round trips](../servers/input-required.md), and to legacy clients over stdio, where the connection is the session. + +Stay on the default posture until the 2025-era HTTP clients you serve need elicitation — [sampling is deprecated](../servers/sampling.md) as of 2026-07-28. To serve those rounds, mint a session for the legacy `initialize`: one transport connected to one instance from your factory, with every request that carries its `Mcp-Session-Id` routed back to that pair. + +```ts source="../../examples/guides/serving/legacy-clients.examples.ts#isLegacyRequest_sessions" +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; + +const handler = createMcpHandler(buildServer); +const sessions = new Map(); + +async function serveWithSessions(request: Request): Promise { + // Session traffic goes to the transport that owns the session. + const sessionId = request.headers.get('mcp-session-id'); + if (sessionId !== null) { + const transport = sessions.get(sessionId); + if (!transport) return new Response('Unknown or expired session', { status: 404 }); + return transport.handleRequest(request); + } + + // A legacy `initialize` opens a session: its own transport, its own instance. + const body: unknown = + request.method === 'POST' + ? await request + .clone() + .json() + .catch(() => {}) + : undefined; + const looksLikeInitialize = typeof body === 'object' && body !== null && 'method' in body && body.method === 'initialize'; + if (looksLikeInitialize && (await isLegacyRequest(request, body))) { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + onsessioninitialized: id => { + sessions.set(id, transport); + } + }); + // Set before connect: the server chains an onclose that is already on the transport. + transport.onclose = () => { + if (transport.sessionId !== undefined) sessions.delete(transport.sessionId); + }; + await buildServer().connect(transport); + const response = await transport.handleRequest(request); + // A refused handshake mints no session: close, or the pair leaks until process exit. + if (transport.sessionId === undefined) await transport.close(); + return response; + } + + // Everything else — modern traffic and legacy one-shots — rides the entry. + return handler.fetch(request, body === undefined ? undefined : { parsedBody: body }); +} +``` + +`isLegacyRequest` decides whether the request is legacy at all; the `method` peek only narrows which legacy requests open a session. An `initialize` that carries the modern envelope (or names a modern revision in its `MCP-Protocol-Version` header) belongs to the modern path's validation ladder, and a bare method sniff would mint it a 2025 session instead. The already-parsed body goes to the predicate as its second argument, which skips the predicate's internal body clone. + +One transport is one session, and the transport's `onclose` — set before `connect`, which chains it — is where the registry entry dies. Never share a transport or a server instance across sessions. + +::: tip +Bound the registry: cap concurrent sessions and evict idle ones — an unauthenticated `initialize` is all it takes to allocate a transport and a server instance. [`todos-server/worker.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/todos-server/worker.ts) does both and runs this exact hybrid on Cloudflare Workers, with the registry in a per-visitor Durable Object. +::: + +Unknown session ids answer `404` and the client re-initializes; `DELETE` tears the session down through that same `onclose`. [Sessions, state, and scaling](./sessions-state-scaling.md) covers the lifecycle, resumability, and scaling; the [`cli-client`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/cli-client/README.md) e2e drives elicitation rounds over exactly this hybrid. + ## Know where SSE went The v2 server never serves the HTTP+SSE transport. An SSE server moving to v2 moves to Streamable HTTP — `createMcpHandler` above — as part of the [v2 upgrade](../migration/upgrade-to-v2.md). @@ -99,4 +163,5 @@ The client side keeps `SSEClientTransport`, so a v2 `Client` still reaches old S - The default HTTP posture is per request and stateless: legacy `GET` and `DELETE` session operations answer `405`. - `serveStdio` decides the era once per connection; its default is `'serve'`. - `isLegacyRequest` in front of a strict handler keeps an existing sessionful 2025 deployment serving its clients. +- The default posture answers 2025-era HTTP clients per request, with no channel for server-initiated messages; a session minted for the legacy `initialize` — classified by `isLegacyRequest`, never a method sniff — restores elicitation. - The v2 server never serves SSE; the frozen v1 transport is `@modelcontextprotocol/server-legacy/sse`, and the client keeps `SSEClientTransport`. diff --git a/docs/serving/sessions-state-scaling.md b/docs/serving/sessions-state-scaling.md index 0bbffe450a..1be411c6de 100644 --- a/docs/serving/sessions-state-scaling.md +++ b/docs/serving/sessions-state-scaling.md @@ -1,6 +1,7 @@ --- shape: how-to --- + # Sessions, state, and scaling `createMcpHandler` builds a fresh server instance from your factory for every HTTP request and holds nothing between requests, so a v2 server is stateless and scales horizontally by default — [Serve over HTTP](./http.md) is the whole setup. Read on if you run a sessionful 2025-era deployment, need a dropped stream to resume, or push change notifications across nodes. @@ -90,6 +91,8 @@ The stateless default is the scaling story: every node builds a fresh instance f Sessionful 2025-era nodes hold their sessions in process memory, so they scale two ways. **Persistent storage**: keep `sessionIdGenerator` and point every node at the same `eventStore`, so a dropped stream is resumable from any node that shares the store. **Local state with message routing**: keep per-node sessions and send each session's traffic to the node that owns it — load-balancer affinity, or pub/sub routing between nodes. +A serverless deployment keeps local state with an object per client instead of a node per client. [`todos-server/worker.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/todos-server/worker.ts) pins each visitor's board and sessions in one Cloudflare Durable Object and embeds the object's own id in the session ids it mints, so whichever edge isolate receives a request forwards it to the object that owns the session. + One thing still crosses nodes on a stateless deployment: `subscriptions/listen`. Its streams deliver the change events published on the handler's **`ServerEventBus`** ([Notifications](../servers/notifications.md)), and the default bus is in-process — `handler.notify.toolsChanged()` on node A never reaches a subscriber whose stream node B holds. Implement `ServerEventBus` over your pub/sub (`publish(event)` forwards to the broker; `subscribe(listener)` registers for events arriving from it) and hand one to every node's `createMcpHandler`. ```ts source="../../examples/guides/serving/sessions-state-scaling.examples.ts#multiNode_bus" diff --git a/examples/guides/serving/legacy-clients.examples.ts b/examples/guides/serving/legacy-clients.examples.ts index 1a66c470ff..07d1ee27de 100644 --- a/examples/guides/serving/legacy-clients.examples.ts +++ b/examples/guides/serving/legacy-clients.examples.ts @@ -56,6 +56,57 @@ async function serve(request: Request): Promise { } //#endregion isLegacyRequest_route +// --------------------------------------------------------------------------- +// "Serve elicitation to 2025-era HTTP clients" +// --------------------------------------------------------------------------- + +//#region isLegacyRequest_sessions +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; + +const handler = createMcpHandler(buildServer); +const sessions = new Map(); + +async function serveWithSessions(request: Request): Promise { + // Session traffic goes to the transport that owns the session. + const sessionId = request.headers.get('mcp-session-id'); + if (sessionId !== null) { + const transport = sessions.get(sessionId); + if (!transport) return new Response('Unknown or expired session', { status: 404 }); + return transport.handleRequest(request); + } + + // A legacy `initialize` opens a session: its own transport, its own instance. + const body: unknown = + request.method === 'POST' + ? await request + .clone() + .json() + .catch(() => {}) + : undefined; + const looksLikeInitialize = typeof body === 'object' && body !== null && 'method' in body && body.method === 'initialize'; + if (looksLikeInitialize && (await isLegacyRequest(request, body))) { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + onsessioninitialized: id => { + sessions.set(id, transport); + } + }); + // Set before connect: the server chains an onclose that is already on the transport. + transport.onclose = () => { + if (transport.sessionId !== undefined) sessions.delete(transport.sessionId); + }; + await buildServer().connect(transport); + const response = await transport.handleRequest(request); + // A refused handshake mints no session: close, or the pair leaks until process exit. + if (transport.sessionId === undefined) await transport.close(); + return response; + } + + // Everything else — modern traffic and legacy one-shots — rides the entry. + return handler.fetch(request, body === undefined ? undefined : { parsedBody: body }); +} +//#endregion isLegacyRequest_sessions + // --------------------------------------------------------------------------- // Harness (not shown on the page). A 2025-era client opens with a claim-less // `initialize` POST; build that request twice and send it to the strict @@ -104,4 +155,66 @@ if (served.status !== 200 || initialized.result.protocolVersion !== '2025-06-18' throw new Error(`expected the legacy leg to complete the 2025 handshake, got ${served.status} ${JSON.stringify(initialized)}`); } +// "Serve elicitation to 2025-era HTTP clients" — the hybrid mints a session for +// the legacy handshake, routes session traffic to the pinned instance, leaves +// everything else (including a modern-envelope `initialize`) to the entry, and +// tears the session down on DELETE. +const opened = await serveWithSessions(legacyInitialize()); +await opened.text(); +const sessionId = opened.headers.get('mcp-session-id'); +console.log(opened.status, sessionId === null ? 'no session' : 'Mcp-Session-Id minted'); + +const overSession = (init: RequestInit): Request => + new Request('http://localhost/mcp', { + ...init, + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId ?? '' + } + }); +const acked = await serveWithSessions( + overSession({ method: 'POST', body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) }) +); +await acked.text(); +const pinged = await serveWithSessions(overSession({ method: 'POST', body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'ping' }) })); +const pingedBody = await pinged.text(); +console.log(pinged.status); + +const modernInitialize = new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'mcp-protocol-version': '2026-07-28' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 3, + method: 'initialize', + params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'modern-host', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {} + } + } + }) +}); +const answeredByEntry = await serveWithSessions(modernInitialize); +await answeredByEntry.text(); +const closed = await serveWithSessions(overSession({ method: 'DELETE' })); +await closed.text(); + +// Self-verification for the sessions section. +if (opened.status !== 200 || sessionId === null) { + throw new Error(`expected the legacy initialize to mint a session, got ${opened.status} ${String(sessionId)}`); +} +if (acked.status !== 202 || pinged.status !== 200 || !pingedBody.includes('"result"')) { + throw new Error(`expected the session to serve the follow-up, got ${acked.status} then ${pinged.status} ${pingedBody.slice(0, 200)}`); +} +if (answeredByEntry.headers.get('mcp-session-id') !== null) { + throw new Error('expected a modern-envelope initialize to be answered by the entry, not given a 2025 session'); +} +if (sessions.size !== 0) { + throw new Error(`expected DELETE to tear the session down, ${sessions.size} left (DELETE answered ${closed.status})`); +} + await strict.close(); +await handler.close(); diff --git a/examples/todos-server/README.md b/examples/todos-server/README.md index 271ad20374..14a95a66c0 100644 --- a/examples/todos-server/README.md +++ b/examples/todos-server/README.md @@ -62,7 +62,7 @@ Any other `mcpServers`-style host can spawn it too: The two protocol eras differ in how interactive conversations travel: on 2025-era connections the wire carries _pushed_ `elicitation/create` / `sampling/createMessage` requests; on 2026-07-28 the server returns `input_required` results and the client retries the call with the answers. The interactive tools (`brainstorm_tasks`, `clear_done`, `prioritize`) are written **once** in the `input_required` style — on 2025-era connections the SDK's default-on legacy shim performs the push-style round trips for them, so there is no era branch in any handler. (For a side-by-side of the two wire styles written by hand, see `examples/elicitation`.) -One serving-mode caveat: over **HTTP with a 2025-era client**, `createMcpHandler`'s default stateless posture has no return path for push-style server→client requests, so the sampling/elicitation tools refuse cleanly on that leg (stdio is unaffected; 2026-07-28 HTTP is unaffected). +One serving-mode caveat: over **HTTP with a 2025-era client**, `createMcpHandler`'s default stateless posture has no return path for push-style server→client requests, so the sampling/elicitation tools refuse cleanly on that leg (stdio is unaffected; 2026-07-28 HTTP is unaffected). The **Workers entry lifts this**: `worker.ts` answers a 2025-era `initialize` with a real session — a per-session `WebStandardStreamableHTTPServerTransport` connected to a server pinned to that session — so the interactive tools work over HTTP for legacy clients too. Sessions are in-memory (each has its own transport); if the object recycles, the client gets the spec's 404 and re-initializes, and the board itself stays durable. ## Configuration @@ -74,8 +74,36 @@ One serving-mode caveat: over **HTTP with a 2025-era client**, `createMcpHandler ## Layout ```text -server.ts transport entry: serveStdio by default, createMcpHandler + node adapter behind --http -todos.ts the application: state, tools, resources, prompts, subscriptions — every feature above +server.ts transport entry (Node): serveStdio by default, createMcpHandler + node adapter behind --http +worker.ts transport entry (Cloudflare Workers): the hosted demo — createMcpHandler's web-standard + fetch behind a per-visitor Durable Object, plus the landing page (index.html) at / +todos.ts the application: state, tools, resources, prompts, subscriptions — every feature above. + createTodosApp() gives a host its own board: a buildServer factory, snapshot/restore for + persistence, and forwardServerEvent for hosts that pin long-lived instances to a bus ``` -This package is intentionally **server-only**; its end-to-end coverage comes from the [`cli-client`](../cli-client/README.md) scripted e2e, which drives it across stdio + HTTP on both protocol eras in CI. +## Deploy it (Cloudflare Workers) + +`worker.ts` + `wrangler.toml` deploy this server as a public demo: `/` serves the landing page, +`/mcp` serves MCP, and every visitor gets an isolated, capped board (keyed by connecting +address, or by an `X-Todos-Board` header when the client sends one) that expires after ~2 h idle. + +```bash +# from this directory +npx wrangler dev # local: http://127.0.0.1:8787/mcp +npx wrangler deploy # deploys to ..workers.dev +openssl rand -base64 48 | npx wrangler secret put REQUEST_STATE_SECRET +``` + +The secret is optional and the default is usually better here: each board mints a key of its +own and keeps it in durable storage, so multi-round `input_required` flows survive isolate +recycling, and a leaked key compromises one board instead of every board. Set the deployment-wide +secret only when rounds must verify across boards. Boards are +capped (200 tasks; `MAX_TASKS` var to change). Treat anything on a public board as untrusted +content: boards are shared with whoever shares the visitor key. + +The worker bundles the **built** packages (`dist/`, resolved through each package's exports map so +the `workerd` shims win — see `tsconfig.worker.json`); after editing `packages/*` sources, run +`pnpm build:all` before `wrangler dev`, or you'll be serving stale code. + +This package is intentionally **server-only**; the Node entry's end-to-end coverage comes from the [`cli-client`](../cli-client/README.md) scripted e2e, which drives `server.ts` across stdio + HTTP on both protocol eras in CI. `worker.ts` is a deploy target, not a CI leg — exercise it with `npx wrangler dev` and the same cli-client pointed at the local URL. diff --git a/examples/todos-server/index.html b/examples/todos-server/index.html new file mode 100644 index 0000000000..23ee1c4170 --- /dev/null +++ b/examples/todos-server/index.html @@ -0,0 +1,107 @@ + + + + + + todos — MCP TypeScript SDK reference server + + + +

todos

+

+ A live deployment of the MCP TypeScript SDK's reference server, + examples/todos-server + — a small todo board where every server-side MCP feature has a real job: tools, resources, prompts, sampling, elicitation, + progress, logging, and subscriptions. +

+ +

Connect

+

+ The MCP endpoint is __HOST__/mcp (Streamable HTTP). It serves both protocol revisions at once — + 2026-07-28 and 2025-11-25 are negotiated per connection, so both current hosts and 2.0 SDK clients connect to the same URL. In + an mcpServers-style host config: +

+
{ "mcpServers": { "todos": { "url": "__HOST__/mcp" } } }
+

Or raw, in the 2026-07-28 wire format (there is no initialize — try server/discover):

+
+curl -X POST __HOST__/mcp \
+  -H 'content-type: application/json' \
+  -H 'accept: application/json, text/event-stream' \
+  -H 'mcp-protocol-version: 2026-07-28' \
+  -H 'mcp-method: server/discover' \
+  -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{
+    "io.modelcontextprotocol/protocolVersion":"2026-07-28",
+    "io.modelcontextprotocol/clientInfo":{"name":"my-client","version":"1.0"},
+    "io.modelcontextprotocol/clientCapabilities":{}}}}'
+ +

What to try

+
    +
  • + add_task, list_tasks, complete_task — plain CRUD; add_task returns + structuredContent against an output schema. +
  • +
  • + brainstorm_tasks — multi-round input_required: a form, an optional follow-up round, then a + sampling round that borrows your model. +
  • +
  • clear_done — elicitation: the server asks you to confirm via a schema-driven form.
  • +
  • work_through_tasks — paced progress notifications; cancel mid-run and watch it stop early.
  • +
  • todos://board — a subscribable resource; every mutation announces itself.
  • +
  • plan-my-day — a prompt with completable arguments.
  • +
+

+ All of it works on both revisions: 2026-07-28 clients run the interactive tools as stateless + input_required round trips, while 2025-era clients get a real session at initialize + (Mcp-Session-Id) with push-style elicitation/sampling over its streams. Sessions are in-memory — if the server + recycles, a conformant client just re-initializes; your board is durable either way. Session-less 2025-era requests are still + answered statelessly — one-shot calls work, but the interactive tools need the session, so a client that connected before + sessions existed here should simply reconnect. +

+ +

Boards

+

+ Every visitor gets their own board, keyed by connecting address — or send an + X-Todos-Board: any-name header to use a board of your own naming (recommended if your network egress rotates IPs, + e.g. CGNAT — otherwise consecutive requests may land on different boards). Boards are capped at 200 tasks and expire about two + hours after the last change. Treat board content as untrusted: it was written by whoever shares your visitor + key. Don't give an agent connected here unattended write authority elsewhere, and don't store anything sensitive. +

+ +

+ No availability promises — this exists so you can kick the tires of a real 2026-07-28 server without running one. Source: + typescript-sdk · + SDK docs · + MCP spec +

+ + diff --git a/examples/todos-server/package.json b/examples/todos-server/package.json index f55c1f16dd..7f5ac0f35a 100644 --- a/examples/todos-server/package.json +++ b/examples/todos-server/package.json @@ -4,7 +4,10 @@ "type": "module", "scripts": { "start": "tsx server.ts", - "start:http": "tsx server.ts --http" + "start:http": "tsx server.ts --http", + "dev:worker": "npx --yes wrangler@4.100.0 dev", + "deploy:worker": "npx --yes wrangler@4.100.0 deploy", + "typecheck:worker": "tsc -p tsconfig.worker.json --noEmit" }, "dependencies": { "@hono/node-server": "catalog:runtimeServerOnly", @@ -17,7 +20,7 @@ "tsx": "catalog:devTools" }, "example": { - "excluded": "server-only package — exercised end-to-end by the examples/cli-client e2e legs", + "excluded": "server-only package — the Node entry (server.ts) is exercised end-to-end by the examples/cli-client e2e legs; worker.ts is the Cloudflare deploy entry, driven manually via wrangler dev", "shapeExempt": "Reference server, not a single-feature story: no client.ts of its own; the paired host lives in examples/cli-client." } } diff --git a/examples/todos-server/server.ts b/examples/todos-server/server.ts index 1c3271219d..0b080db623 100644 --- a/examples/todos-server/server.ts +++ b/examples/todos-server/server.ts @@ -5,28 +5,30 @@ */ import { serve } from '@hono/node-server'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { createMcpHonoApp } from '@modelcontextprotocol/hono'; -import { createMcpHandler } from '@modelcontextprotocol/server'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, InMemoryServerEventBus } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; -import { buildServer, onBoardChanged, onBoardUpdated } from './todos'; +import { createTodosApp } from './todos'; const { transport, port } = parseExampleArgs(); +// One process, one board. The key comes from the environment for real deployments and falls +// back to a per-process random one for the zero-setup demo (fine: one process serves every +// round of a multi-round flow). if (transport === 'stdio') { - void serveStdio(buildServer); + // Single connection, no bus: the app announces on the pinned instance and the entry + // routes that onto its open subscriptions/listen streams. + const app = createTodosApp({ requestStateKey: process.env.REQUEST_STATE_SECRET }); + void serveStdio(app.buildServer); console.error('[todos] serving over stdio'); } else { - const handler = createMcpHandler(buildServer); - // Per-request serving has no connection to push notifications down — cross-request - // events (the board changing) are published through the handler's notifier instead. - onBoardChanged(() => handler.notify.resourcesChanged()); - onBoardUpdated(uri => handler.notify.resourceUpdated(uri)); - // `createMcpHonoApp()` binds the endpoint behind localhost host/origin - // validation by default, matching the framework factories' defaults. - const app = createMcpHonoApp(); - app.all('/mcp', c => handler.fetch(c.req.raw)); - serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { + // Per-request serving has no connection to push notifications down — the app announces + // on the bus and the handler routes the events onto its subscriptions/listen streams. + const bus = new InMemoryServerEventBus(); + const app = createTodosApp({ requestStateKey: process.env.REQUEST_STATE_SECRET, bus }); + const handler = createMcpHandler(app.buildServer, { bus }); + createServer(toNodeHandler(handler)).listen(port, () => { console.error(`[todos] listening on http://127.0.0.1:${port}/mcp`); }); } diff --git a/examples/todos-server/todos.ts b/examples/todos-server/todos.ts index 1877c93eda..8fdd3d6e2b 100644 --- a/examples/todos-server/todos.ts +++ b/examples/todos-server/todos.ts @@ -5,26 +5,36 @@ * a job: CRUD tools the model calls from chat, the board and each task exposed as resources, * planning/seeding prompts, a sampling-backed `prioritize` tool that borrows the *host's* * model, elicitation-confirmed `clear_done` and `brainstorm_tasks`, and logging/progress while - * it works. State is in-memory and per-process; the point is the wiring, not the persistence. - * The transport entry point that serves this over stdio / Streamable HTTP is ../server.ts. + * it works. State is in-memory and per app instance — `createTodosApp()` returns one board plus + * the `buildServer` factory that serves it, so each transport entry owns exactly one board. The + * point is the wiring, not the persistence (though `snapshot`/`restore` exist so a hosted + * deployment can keep a board across process hops — see ./worker.ts). + * The transport entry points are ./server.ts (Node: stdio / Streamable HTTP) and ./worker.ts + * (Cloudflare Workers). */ import type { CallToolResult, ElicitRequestFormParams, InputRequiredResult, McpRequestContext, - ServerContext + ServerContext, + ServerEvent, + ServerEventBus } from '@modelcontextprotocol/server'; import { acceptedContent, completable, createRequestStateCodec, inputRequired, + inputResponse, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; +/** The one subscribable resource this application serves. */ +const BOARD_URI = 'todos://board'; + /** * The brainstorm_tasks flow as an explicit state machine: each variant names the round the * handler is waiting on and carries exactly the data the next round needs. The handler @@ -35,33 +45,27 @@ type BrainstormState = | { step: 'awaiting-custom-count'; topic: string } | { step: 'awaiting-ideas'; topic: string; count: number }; -/** - * HMAC-signs the `requestState` round-tripped through brainstorm_tasks' multi-round flow so a - * client cannot forge or mutate the carried step/theme/count. The seam runs `verify` before the - * handler (rejecting tampered state with -32602) and the handler reads the decoded payload via - * the typed `ctx.mcpReq.requestState()` accessor — no second decode. - * The key comes from the environment for real deployments and falls back to a per-process - * random one for the zero-setup demo (which is fine because one process serves every round). - */ -const stateCodec = createRequestStateCodec({ - key: process.env.REQUEST_STATE_SECRET ?? crypto.getRandomValues(new Uint8Array(32)) -}); - -/** Read the `action` from a raw elicitation `inputResponses` entry (decline/cancel detection). */ -function elicitAction(response: unknown): 'accept' | 'decline' | 'cancel' { - const action = typeof response === 'object' && response !== null && 'action' in response ? response.action : undefined; - return action === 'accept' || action === 'decline' ? action : 'cancel'; +/** Read the `action` from an elicitation round's response via the SDK's typed reader. */ +function elicitAction(responses: Parameters[0], key: string): 'accept' | 'decline' | 'cancel' { + const view = inputResponse(responses, key); + return view.kind === 'elicit' ? view.action : 'cancel'; } -/** Read the text content from a raw sampling (`createMessage`) `inputResponses` entry. */ -function sampledText(response: unknown): string { - const content = typeof response === 'object' && response !== null && 'content' in response ? response.content : undefined; +/** + * Read the text content from a sampling round's response. `inputResponse` types the round as + * a sampling result; extracting the text from its content is still by hand — the SDK has no + * `sampledText`-style reader yet. + */ +function sampledText(responses: Parameters[0], key: string): string { + const view = inputResponse(responses, key); + if (view.kind !== 'sampling') return ''; + const content: unknown = view.result.content; return typeof content === 'object' && content !== null && 'type' in content && content.type === 'text' && 'text' in content ? String(content.text) : ''; } -interface Task { +export interface Task { id: string; title: string; project: string; @@ -71,21 +75,52 @@ interface Task { status: 'open' | 'done'; } -let nextId = 1; -const tasks = new Map(); - -function addTask(task: Omit): Task { - const created: Task = { id: `t${nextId++}`, status: 'open', ...task }; - tasks.set(created.id, created); - return created; +/** A serializable copy of a board, for hosts that persist boards across process hops. */ +export interface BoardSnapshot { + nextId: number; + tasks: Task[]; } -function openTasks(): Task[] { - return [...tasks.values()].filter(task => task.status === 'open'); +export interface TodosAppOptions { + /** + * HMAC key for the signed `requestState` round-tripped through brainstorm_tasks' multi-round + * flow (≥ 32 bytes). Provide a stable key whenever more than one app instance may serve the + * same conversation (e.g. a redeployed or multi-instance host); unset, the app generates a + * per-instance random key — fine whenever a single instance serves the whole flow. + */ + requestStateKey?: string | Uint8Array; + /** + * Refuse task creation beyond this many tasks (the adding tool returns an error result). + * Unset means uncapped — fine for a local demo, not for a public deployment. + */ + maxTasks?: number; + /** + * The board's change bus. Every mutation announces on it once, and the serving entry + * fans the events out: `createMcpHandler({ bus })` routes them onto its + * `subscriptions/listen` streams, and a host that pins long-lived instances (sessions) + * forwards them with {@linkcode TodosApp.forwardServerEvent}. Unset (stdio, tests), the + * app announces only on the connection that made the change — the sole audience a + * single-connection transport has. + */ + bus?: ServerEventBus; } -function projects(): string[] { - return [...new Set([...tasks.values()].map(task => task.project))]; +/** One todo board plus the `buildServer` factory that serves it. */ +export interface TodosApp { + buildServer(reqCtx: McpRequestContext): McpServer; + /** + * Subscribe one long-lived instance to the board's change bus. A host that pins instances + * past a single request (./worker.ts's 2025-era sessions) calls this once per instance and + * runs the returned unsubscribe when the connection ends. The app applies the etiquette + * the host cannot see: the instance that announced a change is skipped (its client heard + * in-band), and `resources/updated` goes only to clients that subscribed to the URI. + * Without a configured bus this is a no-op. + */ + subscribeInstance(server: McpServer): () => void; + /** Copy the board out (deep enough to persist safely while handlers keep mutating). */ + snapshot(): BoardSnapshot; + /** Replace the board with a previously snapshotted one. */ + restore(snapshot: BoardSnapshot): void; } function describeTask(task: Task): string { @@ -93,42 +128,12 @@ function describeTask(task: Task): string { return `- [${task.status === 'done' ? 'x' : ' '}] ${task.title} (${task.id}, ${task.project}${details ? `; ${details}` : ''})`; } -function renderBoard(): string { - const done = [...tasks.values()].filter(task => task.status === 'done'); - return [ - '# Todo board', - '', - '## Open', - ...openTasks().map(task => describeTask(task)), - '', - '## Done', - ...done.map(task => describeTask(task)) - ].join('\n'); -} - async function logInfo(ctx: ServerContext, text: string): Promise { // Request-tied logging: honours the client's logging/setLevel threshold on 2025-era // connections and the per-request logLevel opt-in on 2026-07-28 connections. await ctx.mcpReq.log('info', text, 'todos'); } -/** - * Over per-request HTTP serving, instance-level notifications have nowhere to go — - * cross-request events are published through the handler's notifier instead. The transport - * entry (../server.ts) wires these up for the HTTP branch; over stdio they stay unset and the - * pinned instance's own notifications are routed by the serving entry. - */ -let publishBoardChanged: (() => void) | undefined; -let publishBoardUpdated: ((uri: string) => void) | undefined; - -export function onBoardChanged(publish: () => void): void { - publishBoardChanged = publish; -} - -export function onBoardUpdated(publish: (uri: string) => void): void { - publishBoardUpdated = publish; -} - async function reportProgress(ctx: ServerContext, progress: number, total: number, message: string): Promise { const progressToken = ctx.mcpReq._meta?.progressToken; if (progressToken === undefined) return; @@ -212,463 +217,587 @@ function priorityForRank(rank: number, total: number): Task['priority'] { return 'low'; } -export function buildServer(reqCtx: McpRequestContext): McpServer { - const server = new McpServer( - { name: 'todos', version: '1.0.0' }, - { - capabilities: { logging: {}, resources: { listChanged: true, subscribe: true } }, - requestState: { verify: stateCodec.verify }, - instructions: - 'todos is a small project todo board (it starts empty). Use list_tasks to see the board, add_task / add_tasks and complete_task to ' + - 'change it, prioritize to rank the open tasks, brainstorm_tasks to invent themed example tasks, work_through_tasks to finish every ' + - 'open task with progress updates, and clear_done to remove finished ones (it asks the user for confirmation). The full board is ' + - 'also available as the todos://board resource, and it can be watched/subscribed to for change notifications. ' + - 'When the user greets you or asks what to try, suggest this tour: 1) ask to brainstorm tasks (the server asks how many — ' + - 'elicitation — then borrows the host model — sampling), 2) ask to prioritize the open tasks (sampling), 3) run the plan-my-day ' + - 'prompt, 4) attach the todos://board resource as context and ask about it, 5) say "do all my tasks" and watch the progress and ' + - 'log notifications, 6) ask to clear completed tasks (an elicitation-confirmed bulk delete). Watching the board resource ' + - '(/watch in cli-client) shows live change notifications along the way.' - } - ); - - // Per-resource subscriptions: 2025-era clients call resources/subscribe (tracked here so - // updates only go to subscribers); 2026-07-28 clients use a subscriptions/listen filter and - // the serving entry routes the same notification onto it. - const subscribedUris = new Set(); - server.server.setRequestHandler('resources/subscribe', request => { - subscribedUris.add(request.params.uri); - return {}; - }); - server.server.setRequestHandler('resources/unsubscribe', request => { - subscribedUris.delete(request.params.uri); - return {}; +export function createTodosApp(options: TodosAppOptions = {}): TodosApp { + /** + * HMAC-signs the `requestState` round-tripped through brainstorm_tasks' multi-round flow so a + * client cannot forge or mutate the carried step/theme/count. The seam runs `verify` before the + * handler (rejecting tampered state with -32602) and the handler reads the decoded payload via + * the typed `ctx.mcpReq.requestState()` accessor — no second decode. + * The key comes from the options for real deployments and falls back to a per-instance + * random one for the zero-setup demo (which is fine because one instance serves every round). + */ + const stateCodec = createRequestStateCodec({ + key: options.requestStateKey ?? crypto.getRandomValues(new Uint8Array(32)) }); - /** Tell connected clients the board changed: the resource list, and the board resource for watchers. */ - const announceBoardChange = async (): Promise => { - await server.sendResourceListChanged(); - if (publishBoardUpdated) { - // Per-request HTTP serving: cross-request delivery goes through the entry's notifier. - publishBoardChanged?.(); - publishBoardUpdated('todos://board'); - } else if (reqCtx.era === 'modern' || subscribedUris.has('todos://board')) { - // stdio: the serving entry routes this onto open listen subscriptions (2026-07-28); - // on 2025-era connections it goes out unsolicited, so only send it to subscribers. - await server.server.sendResourceUpdated({ uri: 'todos://board' }).catch(() => {}); + let nextId = 1; + const tasks = new Map(); + + /** The error a caller gets when `count` more tasks would not fit; `undefined` when they do. */ + function boardFullError(count: number): CallToolResult | undefined { + if (options.maxTasks === undefined || tasks.size + count <= options.maxTasks) return undefined; + return { + content: [ + { + type: 'text', + text: `The board is full (${options.maxTasks} tasks) — complete_task and clear_done free up space.` + } + ], + isError: true + }; + } + + function addTask(task: Omit): Task { + // Batch callers pre-check with boardFullError so a batch never half-lands; a thrown + // overflow still surfaces as a tool error result for anything that skips the check. + if (options.maxTasks !== undefined && tasks.size >= options.maxTasks) { + throw new Error(`The board is full (${options.maxTasks} tasks) — complete_task and clear_done free up space.`); } - }; + const created: Task = { id: `t${nextId++}`, status: 'open', ...task }; + tasks.set(created.id, created); + return created; + } + + function openTasks(): Task[] { + return [...tasks.values()].filter(task => task.status === 'open'); + } + + function projects(): string[] { + return [...new Set([...tasks.values()].map(task => task.project))]; + } + + function renderBoard(): string { + const done = [...tasks.values()].filter(task => task.status === 'done'); + return [ + '# Todo board', + '', + '## Open', + ...openTasks().map(task => describeTask(task)), + '', + '## Done', + ...done.map(task => describeTask(task)) + ].join('\n'); + } + + // Cross-connection announcements go through the bus, once per change; per-connection + // delivery knowledge (that client's subscription set) stays with each instance. The + // announcing instance is remembered per EVENT OBJECT, not per moment: object identity + // survives asynchronous buses, so echo suppression does not depend on the in-memory + // bus's synchronous dispatch. + const bus = options.bus; + const connections = new WeakMap }>(); + const eventOrigin = new WeakMap(); + + function deliverToInstance(target: McpServer, event: ServerEvent): void { + if (eventOrigin.get(event) === target) return; // its client heard in-band + const connection = connections.get(target); + if (!connection) return; + if (event.kind === 'resources_list_changed') { + target.sendResourceListChanged(); + } else if (event.kind === 'resource_updated' && connection.subscribedUris.has(event.uri)) { + void target.server.sendResourceUpdated({ uri: event.uri }).catch(() => {}); + } + } - server.registerResource( - 'board', - 'todos://board', - { description: 'The whole todo board as markdown', mimeType: 'text/markdown' }, - async uri => ({ contents: [{ uri: uri.href, mimeType: 'text/markdown', text: renderBoard() }] }) - ); - - server.registerResource( - 'task', - new ResourceTemplate('todos://tasks/{id}', { - list: async () => ({ - resources: [...tasks.values()].map(task => ({ - uri: `todos://tasks/${task.id}`, - name: task.title, - mimeType: 'text/markdown' - })) + function subscribeInstance(server: McpServer): () => void { + if (!bus) return () => {}; + return bus.subscribe(event => deliverToInstance(server, event)); + } + + function buildServer(reqCtx: McpRequestContext): McpServer { + const server = new McpServer( + { name: 'todos', version: '1.0.0' }, + { + capabilities: { logging: {}, resources: { listChanged: true, subscribe: true } }, + requestState: { verify: stateCodec.verify }, + instructions: + 'todos is a small project todo board (it starts empty). Use list_tasks to see the board, add_task / add_tasks and complete_task to ' + + 'change it, prioritize to rank the open tasks, brainstorm_tasks to invent themed example tasks, work_through_tasks to finish every ' + + 'open task with progress updates, and clear_done to remove finished ones (it asks the user for confirmation). The full board is ' + + 'also available as the todos://board resource, and it can be watched/subscribed to for change notifications. ' + + 'When the user greets you or asks what to try, suggest this tour: 1) ask to brainstorm tasks (the server asks how many — ' + + 'elicitation — then borrows the host model — sampling), 2) ask to prioritize the open tasks (sampling), 3) run the plan-my-day ' + + 'prompt, 4) attach the todos://board resource as context and ask about it, 5) say "do all my tasks" and watch the progress and ' + + 'log notifications, 6) ask to clear completed tasks (an elicitation-confirmed bulk delete). Watching the board resource ' + + '(/watch in cli-client) shows live change notifications along the way.' + } + ); + + // Per-resource subscriptions: 2025-era clients call resources/subscribe (tracked per + // connection so updates only go to subscribers); 2026-07-28 clients use a + // subscriptions/listen filter and the serving entry routes bus events onto it. + const subscribedUris = new Set(); + connections.set(server, { subscribedUris }); + server.server.setRequestHandler('resources/subscribe', request => { + subscribedUris.add(request.params.uri); + return {}; + }); + server.server.setRequestHandler('resources/unsubscribe', request => { + subscribedUris.delete(request.params.uri); + return {}; + }); + + /** + * Tell every client the board changed. This connection hears it in-band: the resource + * list always, the board resource when this client subscribed to it (2025-era) or when + * no bus exists to carry it (stdio, where the entry lifts the instance's notification + * onto its open subscriptions/listen streams). Every other connection hears it through + * the bus — the entry's listen streams directly, pinned instances via + * {@linkcode TodosApp.forwardServerEvent} — so the announcement is made exactly once. + */ + const announceBoardChange = async (): Promise => { + server.sendResourceListChanged(); + if (reqCtx.era === 'legacy' ? subscribedUris.has(BOARD_URI) : bus === undefined) { + await server.server.sendResourceUpdated({ uri: BOARD_URI }).catch(() => {}); + } + if (bus) { + const listChanged: ServerEvent = { kind: 'resources_list_changed' }; + const updated: ServerEvent = { kind: 'resource_updated', uri: BOARD_URI }; + eventOrigin.set(listChanged, server); + eventOrigin.set(updated, server); + bus.publish(listChanged); + bus.publish(updated); + } + }; + + server.registerResource( + 'board', + 'todos://board', + { description: 'The whole todo board as markdown', mimeType: 'text/markdown' }, + async uri => ({ contents: [{ uri: uri.href, mimeType: 'text/markdown', text: renderBoard() }] }) + ); + + server.registerResource( + 'task', + new ResourceTemplate('todos://tasks/{id}', { + list: async () => ({ + resources: [...tasks.values()].map(task => ({ + uri: `todos://tasks/${task.id}`, + name: task.title, + mimeType: 'text/markdown' + })) + }), + complete: { id: value => [...tasks.keys()].filter(id => id.startsWith(value)) } }), - complete: { id: value => [...tasks.keys()].filter(id => id.startsWith(value)) } - }), - { description: 'A single task by id', mimeType: 'text/markdown' }, - async (uri, variables) => { - const task = tasks.get(String(variables.id)); - return { - contents: [ + { description: 'A single task by id', mimeType: 'text/markdown' }, + async (uri, variables) => { + const task = tasks.get(String(variables.id)); + return { + contents: [ + { + uri: uri.href, + mimeType: 'text/markdown', + text: task ? describeTask(task) : `No task with id ${String(variables.id)}` + } + ] + }; + } + ); + + server.registerPrompt( + 'seed-board', + { + description: 'Have the assistant invent themed example tasks and add them to the board (via add_tasks)', + argsSchema: z.object({ + theme: completable(z.string().describe('A theme for the invented tasks'), value => + [ + 'space-station maintenance', + 'wizard tower chores', + 'startup launch week', + "engineer's week in hell", + 'robot uprising prep' + ].filter(theme => theme.startsWith(value)) + ) + }) + }, + async ({ theme }) => ({ + messages: [ { - uri: uri.href, - mimeType: 'text/markdown', - text: task ? describeTask(task) : `No task with id ${String(variables.id)}` + role: 'user', + content: { + type: 'text', + text: `Invent five short, funny todo tasks for the theme "${theme}" and add them to my board with the add_tasks tool (use "${theme}" as the project). Then show me the board.` + } } ] - }; - } - ); - - server.registerPrompt( - 'seed-board', - { - description: 'Have the assistant invent themed example tasks and add them to the board (via add_tasks)', - argsSchema: z.object({ - theme: completable(z.string().describe('A theme for the invented tasks'), value => - [ - 'space-station maintenance', - 'wizard tower chores', - 'startup launch week', - "engineer's week in hell", - 'robot uprising prep' - ].filter(theme => theme.startsWith(value)) - ) - }) - }, - async ({ theme }) => ({ - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Invent five short, funny todo tasks for the theme "${theme}" and add them to my board with the add_tasks tool (use "${theme}" as the project). Then show me the board.` - } - } - ] - }) - ); - - server.registerPrompt( - 'plan-my-day', - { - description: 'Seed a planning conversation around the current board', - argsSchema: z.object({ - focus: completable(z.string().describe('Project to focus on'), value => - projects().filter(project => project.startsWith(value)) - ) }) - }, - async ({ focus }) => ({ - messages: [ - { role: 'user', content: { type: 'text', text: `Here is my current todo board:\n\n${renderBoard()}` } }, - { role: 'assistant', content: { type: 'text', text: 'Got it — I can see your board. What should today look like?' } }, - { - role: 'user', - content: { - type: 'text', - text: `Plan my day around the "${focus}" project: pick at most three tasks, in order, and say why each one is next.` - } - } - ] - }) - ); - - server.registerTool( - 'add_task', - { - description: 'Add a task to the board', - inputSchema: z.object({ - title: z.string().describe('What needs doing'), - project: z.string().optional().describe('Project bucket, e.g. "ops"'), - priority: z.enum(['high', 'medium', 'low']).optional(), - due: z.string().optional().describe('Free-form due date, e.g. "Friday"'), - notes: z.string().optional() - }), - outputSchema: z.object({ id: z.string(), title: z.string(), status: z.enum(['open', 'done']) }) - }, - async ({ title, project, priority, due, notes }, ctx) => { - const task = addTask({ title, project: project ?? 'inbox', priority, due, notes }); - await announceBoardChange(); - await logInfo(ctx, `added ${task.id}: ${task.title}`); - return { - content: [{ type: 'text', text: `Added ${task.id}: ${describeTask(task)}` }], - structuredContent: { id: task.id, title: task.title, status: task.status } - }; - } - ); - - server.registerTool( - 'add_tasks', - { - description: 'Add several tasks to the board at once', - inputSchema: z.object({ - tasks: z - .array( - z.object({ - title: z.string(), - project: z.string().optional(), - priority: z.enum(['high', 'medium', 'low']).optional(), - due: z.string().optional(), - notes: z.string().optional() - }) + ); + + server.registerPrompt( + 'plan-my-day', + { + description: 'Seed a planning conversation around the current board', + argsSchema: z.object({ + focus: completable(z.string().describe('Project to focus on'), value => + projects().filter(project => project.startsWith(value)) ) - .min(1) - .describe('Tasks to add') + }) + }, + async ({ focus }) => ({ + messages: [ + { role: 'user', content: { type: 'text', text: `Here is my current todo board:\n\n${renderBoard()}` } }, + { role: 'assistant', content: { type: 'text', text: 'Got it — I can see your board. What should today look like?' } }, + { + role: 'user', + content: { + type: 'text', + text: `Plan my day around the "${focus}" project: pick at most three tasks, in order, and say why each one is next.` + } + } + ] }) - }, - async ({ tasks: newTasks }, ctx) => { - const added: Task[] = []; - for (const [index, task] of newTasks.entries()) { - // Pretend each insert takes a moment so the host has in-flight progress to render. - await new Promise(resolve => setTimeout(resolve, 100)); - added.push(addTask({ ...task, project: task.project ?? 'inbox' })); - await reportProgress(ctx, index + 1, newTasks.length, `added "${task.title}"`); + ); + + server.registerTool( + 'add_task', + { + description: 'Add a task to the board', + inputSchema: z.object({ + title: z.string().describe('What needs doing'), + project: z.string().optional().describe('Project bucket, e.g. "ops"'), + priority: z.enum(['high', 'medium', 'low']).optional(), + due: z.string().optional().describe('Free-form due date, e.g. "Friday"'), + notes: z.string().optional() + }), + outputSchema: z.object({ id: z.string(), title: z.string(), status: z.enum(['open', 'done']) }) + }, + async ({ title, project, priority, due, notes }, ctx) => { + const task = addTask({ title, project: project ?? 'inbox', priority, due, notes }); + await announceBoardChange(); + await logInfo(ctx, `added ${task.id}: ${task.title}`); + return { + content: [{ type: 'text', text: `Added ${task.id}: ${describeTask(task)}` }], + structuredContent: { id: task.id, title: task.title, status: task.status } + }; } - await announceBoardChange(); - await logInfo(ctx, `added ${added.length} task(s)`); - return { - content: [{ type: 'text', text: `Added ${added.length} task(s):\n${added.map(task => describeTask(task)).join('\n')}` }] - }; - } - ); - - server.registerTool( - 'brainstorm_tasks', - { - description: - 'Invent short, funny example tasks for a theme and add them to the board — asks the user how many (elicitation), then has the LLM connected to the host invent them (sampling)', - inputSchema: z.object({ - theme: z.string().optional().describe('Theme for the invented tasks (default: "an engineer\'s week in hell")') - }) - }, - async ({ theme }, ctx): Promise => { - // The theme can come from the model (tool argument) or from the user (the elicitation - // form's theme field, pre-filled with a default); the user's answer wins. - const fallbackTopic = theme ?? "an engineer's week in hell"; - const resolveTopic = (raw: unknown): string => (typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : fallbackTopic); - const countMessage = 'Let me invent some tasks for the board.'; - - const finish = async (ideasText: string, wanted: number, topic: string): Promise => { - const titles = ideasText - .split('\n') - .map(line => line.replace(/^[-*\d.\s]+/, '').trim()) - .filter(line => line.length > 0) - .slice(0, wanted); - if (titles.length === 0) { - return { content: [{ type: 'text', text: 'The model did not return any task ideas.' }], isError: true }; + ); + + server.registerTool( + 'add_tasks', + { + description: 'Add several tasks to the board at once', + inputSchema: z.object({ + tasks: z + .array( + z.object({ + title: z.string(), + project: z.string().optional(), + priority: z.enum(['high', 'medium', 'low']).optional(), + due: z.string().optional(), + notes: z.string().optional() + }) + ) + .min(1) + .describe('Tasks to add') + }) + }, + async ({ tasks: newTasks }, ctx) => { + const full = boardFullError(newTasks.length); + if (full) return full; + const added: Task[] = []; + for (const [index, task] of newTasks.entries()) { + // Pretend each insert takes a moment so the host has in-flight progress to render. + await new Promise(resolve => setTimeout(resolve, 100)); + added.push(addTask({ ...task, project: task.project ?? 'inbox' })); + await reportProgress(ctx, index + 1, newTasks.length, `added "${task.title}"`); } - const added = titles.map(title => addTask({ title, project: topic })); await announceBoardChange(); - await logInfo(ctx, `brainstormed ${added.length} task(s) for "${topic}"`); + await logInfo(ctx, `added ${added.length} task(s)`); return { - content: [ - { - type: 'text', - text: `Added ${added.length} brainstormed task(s):\n${added.map(task => describeTask(task)).join('\n')}` - } - ] + content: [{ type: 'text', text: `Added ${added.length} task(s):\n${added.map(task => describeTask(task)).join('\n')}` }] }; - }; - const declined = (action: string): CallToolResult => ({ - content: [{ type: 'text', text: `Nothing added (user answered: ${action}).` }] - }); - - // The whole conversation as a multi-round input_required chain — written ONCE. - // The handler is a state machine over BrainstormState — it dispatches on - // `state.step` (not on which inputResponses key arrived), so each round knows - // exactly which answer to read and which data is in scope. State is HMAC-signed - // by stateCodec; the seam verified integrity AND decoded the payload before this - // handler ran — the typed accessor returns it. On a 2025-era session the SDK's - // legacy shim fulfils each round as real push-style requests; the handler never - // branches on the served era. - const state = ctx.mcpReq.requestState(); - const askForIdeas = async (count: number, topic: string): Promise => - inputRequired({ - inputRequests: { ideas: inputRequired.createMessage(buildBrainstormSampling(topic, count)) }, - requestState: await stateCodec.mint({ step: 'awaiting-ideas', topic, count }, ctx) + } + ); + + server.registerTool( + 'brainstorm_tasks', + { + description: + 'Invent short, funny example tasks for a theme and add them to the board — asks the user how many (elicitation), then has the LLM connected to the host invent them (sampling)', + inputSchema: z.object({ + theme: z.string().optional().describe('Theme for the invented tasks (default: "an engineer\'s week in hell")') + }) + }, + async ({ theme }, ctx): Promise => { + // The theme can come from the model (tool argument) or from the user (the elicitation + // form's theme field, pre-filled with a default); the user's answer wins. + const fallbackTopic = theme ?? "an engineer's week in hell"; + const resolveTopic = (raw: unknown): string => + typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : fallbackTopic; + const countMessage = 'Let me invent some tasks for the board.'; + + const finish = async (ideasText: string, wanted: number, topic: string): Promise => { + const titles = ideasText + .split('\n') + .map(line => line.replace(/^[-*\d.\s]+/, '').trim()) + .filter(line => line.length > 0) + .slice(0, wanted); + if (titles.length === 0) { + return { content: [{ type: 'text', text: 'The model did not return any task ideas.' }], isError: true }; + } + const full = boardFullError(titles.length); + if (full) return full; + const added = titles.map(title => addTask({ title, project: topic })); + await announceBoardChange(); + await logInfo(ctx, `brainstormed ${added.length} task(s) for "${topic}"`); + return { + content: [ + { + type: 'text', + text: `Added ${added.length} brainstormed task(s):\n${added.map(task => describeTask(task)).join('\n')}` + } + ] + }; + }; + const declined = (action: string): CallToolResult => ({ + content: [{ type: 'text', text: `Nothing added (user answered: ${action}).` }] }); - switch (state?.step) { - case undefined: { - // First call: ask for the theme and count. - return inputRequired({ - inputRequests: { count: inputRequired.elicit({ message: countMessage, requestedSchema: BRAINSTORM_COUNT_SCHEMA }) }, - requestState: await stateCodec.mint({ step: 'awaiting-count' }, ctx) + // The whole conversation as a multi-round input_required chain — written ONCE. + // The handler is a state machine over BrainstormState — it dispatches on + // `state.step` (not on which inputResponses key arrived), so each round knows + // exactly which answer to read and which data is in scope. State is HMAC-signed + // by stateCodec; the seam verified integrity AND decoded the payload before this + // handler ran — the typed accessor returns it. On a 2025-era session the SDK's + // legacy shim fulfils each round as real push-style requests; the handler never + // branches on the served era. + const state = ctx.mcpReq.requestState(); + const askForIdeas = async (count: number, topic: string): Promise => + inputRequired({ + inputRequests: { ideas: inputRequired.createMessage(buildBrainstormSampling(topic, count)) }, + requestState: await stateCodec.mint({ step: 'awaiting-ideas', topic, count }, ctx) }); - } - case 'awaiting-count': { - const response = ctx.mcpReq.inputResponses?.['count']; - const accepted = acceptedContent<{ count?: string; theme?: string }>(ctx.mcpReq.inputResponses, 'count'); - if (accepted === undefined) return declined(elicitAction(response)); - const topic = resolveTopic(accepted.theme); - if (accepted.count === 'custom') { + + switch (state?.step) { + case undefined: { + // First call: ask for the theme and count. return inputRequired({ inputRequests: { - customCount: inputRequired.elicit({ - message: 'How many exactly?', - requestedSchema: BRAINSTORM_CUSTOM_COUNT_SCHEMA - }) + count: inputRequired.elicit({ message: countMessage, requestedSchema: BRAINSTORM_COUNT_SCHEMA }) }, - requestState: await stateCodec.mint({ step: 'awaiting-custom-count', topic }, ctx) + requestState: await stateCodec.mint({ step: 'awaiting-count' }, ctx) }); } - const wanted = parseBrainstormCount(accepted.count); - if (wanted === undefined) return declined('cancel'); - return askForIdeas(wanted, topic); - } - case 'awaiting-custom-count': { - const response = ctx.mcpReq.inputResponses?.['customCount']; - const accepted = acceptedContent<{ customCount?: number }>(ctx.mcpReq.inputResponses, 'customCount'); - const wanted = parseBrainstormCount(accepted?.customCount); - if (wanted === undefined) return declined(elicitAction(response)); - return askForIdeas(wanted, state.topic); - } - case 'awaiting-ideas': { - return finish(sampledText(ctx.mcpReq.inputResponses?.['ideas']), state.count, state.topic); - } - } - } - ); - - server.registerTool( - 'list_tasks', - { - description: 'List tasks on the board', - inputSchema: z.object({ - status: z.enum(['open', 'done', 'all']).optional().describe('Which tasks to list (default: open)'), - project: z.string().optional().describe('Only tasks in this project') - }) - }, - async ({ status, project }) => { - const wanted = status ?? 'open'; - const matching = [...tasks.values()].filter( - task => (wanted === 'all' || task.status === wanted) && (!project || task.project === project) - ); - return { - content: [ - { - type: 'text', - text: matching.length === 0 ? 'No matching tasks.' : matching.map(task => describeTask(task)).join('\n') + case 'awaiting-count': { + const accepted = acceptedContent<{ count?: string; theme?: string }>(ctx.mcpReq.inputResponses, 'count'); + if (accepted === undefined) return declined(elicitAction(ctx.mcpReq.inputResponses, 'count')); + const topic = resolveTopic(accepted.theme); + if (accepted.count === 'custom') { + return inputRequired({ + inputRequests: { + customCount: inputRequired.elicit({ + message: 'How many exactly?', + requestedSchema: BRAINSTORM_CUSTOM_COUNT_SCHEMA + }) + }, + requestState: await stateCodec.mint({ step: 'awaiting-custom-count', topic }, ctx) + }); + } + const wanted = parseBrainstormCount(accepted.count); + if (wanted === undefined) return declined('cancel'); + return askForIdeas(wanted, topic); } - ] - }; - } - ); - - server.registerTool( - 'complete_task', - { - description: 'Mark a task as done', - inputSchema: z.object({ task: z.string().describe('Task id, or part of its title') }) - }, - async ({ task: query }, ctx) => { - const needle = query.toLowerCase(); - const task = tasks.get(query) ?? [...tasks.values()].find(candidate => candidate.title.toLowerCase().includes(needle)); - if (!task) { - return { content: [{ type: 'text', text: `No task matches "${query}".` }], isError: true }; + case 'awaiting-custom-count': { + const accepted = acceptedContent<{ customCount?: number }>(ctx.mcpReq.inputResponses, 'customCount'); + const wanted = parseBrainstormCount(accepted?.customCount); + if (wanted === undefined) return declined(elicitAction(ctx.mcpReq.inputResponses, 'customCount')); + return askForIdeas(wanted, state.topic); + } + case 'awaiting-ideas': { + return finish(sampledText(ctx.mcpReq.inputResponses, 'ideas'), state.count, state.topic); + } + } } - task.status = 'done'; - await announceBoardChange(); - await logInfo(ctx, `completed ${task.id}: ${task.title}`); - return { content: [{ type: 'text', text: `Marked "${task.title}" (${task.id}) as done.` }] }; - } - ); - - server.registerTool( - 'work_through_tasks', - { - description: - 'Work through every open task one by one (simulated, a few seconds each), logging what it is "doing", reporting progress, and marking each as done', - inputSchema: z.object({ - secondsPerTask: z.number().min(0).max(15).optional().describe('How long to pretend each task takes (default: 3 seconds)') - }) - }, - async ({ secondsPerTask }, ctx) => { - const queue = openTasks(); - if (queue.length === 0) { - return { content: [{ type: 'text', text: 'Nothing open — the board is already clear.' }] }; + ); + + server.registerTool( + 'list_tasks', + { + description: 'List tasks on the board', + inputSchema: z.object({ + status: z.enum(['open', 'done', 'all']).optional().describe('Which tasks to list (default: open)'), + project: z.string().optional().describe('Only tasks in this project') + }) + }, + async ({ status, project }) => { + const wanted = status ?? 'open'; + const matching = [...tasks.values()].filter( + task => (wanted === 'all' || task.status === wanted) && (!project || task.project === project) + ); + return { + content: [ + { + type: 'text', + text: matching.length === 0 ? 'No matching tasks.' : matching.map(task => describeTask(task)).join('\n') + } + ] + }; } - const paceMs = (secondsPerTask ?? 3) * 1000; - for (const [index, task] of queue.entries()) { - // Honour cancellation: if the client aborted the call (notifications/cancelled), - // stop early instead of ploughing through the rest of the queue. - if (ctx.mcpReq.signal.aborted) { - return { - content: [ - { type: 'text', text: `Stopped early — the request was cancelled after ${index} of ${queue.length} task(s).` } - ] - }; + ); + + server.registerTool( + 'complete_task', + { + description: 'Mark a task as done', + inputSchema: z.object({ task: z.string().describe('Task id, or part of its title') }) + }, + async ({ task: query }, ctx) => { + const needle = query.toLowerCase(); + const task = tasks.get(query) ?? [...tasks.values()].find(candidate => candidate.title.toLowerCase().includes(needle)); + if (!task) { + return { content: [{ type: 'text', text: `No task matches "${query}".` }], isError: true }; } - // Narrate the "work" (a log notification per task), pretend it takes a moment so the - // host has live progress to render, then announce the board change for watchers. - await logInfo(ctx, `working on "${task.title}" — ${WORK_QUIPS[index % WORK_QUIPS.length] ?? 'working'}…`); - await new Promise(resolve => setTimeout(resolve, paceMs)); task.status = 'done'; - await reportProgress(ctx, index + 1, queue.length, `finished "${task.title}"`); await announceBoardChange(); + await logInfo(ctx, `completed ${task.id}: ${task.title}`); + return { content: [{ type: 'text', text: `Marked "${task.title}" (${task.id}) as done.` }] }; } - await logInfo(ctx, `worked through ${queue.length} open task(s)`); - return { - content: [ - { type: 'text', text: `Worked through ${queue.length} task(s):\n${queue.map(task => `- ${task.title} ✔`).join('\n')}` } - ] - }; - } - ); - - server.registerTool( - 'clear_done', - { description: 'Delete every completed task (asks the user to confirm first)' }, - async (ctx): Promise => { - const done = [...tasks.values()].filter(task => task.status === 'done'); - if (done.length === 0) return { content: [{ type: 'text', text: 'No completed tasks to clear.' }] }; - const message = `Delete ${done.length} completed task(s) from the board?`; - - // A single input_required round, written once for both eras — the first call has - // no inputResponses and returns the question; the re-call carries the answer. - // (For multi-round flows, dispatch on a discriminated requestState instead — see - // brainstorm_tasks.) On 2025-era sessions the SDK's legacy shim asks the question - // as a real push-style elicitation. - const response = ctx.mcpReq.inputResponses?.['confirmation']; - if (response === undefined) { - return inputRequired({ - inputRequests: { confirmation: inputRequired.elicit({ message, requestedSchema: CLEAR_CONFIRM_SCHEMA }) } - }); + ); + + server.registerTool( + 'work_through_tasks', + { + description: + 'Work through every open task one by one (simulated, a few seconds each), logging what it is "doing", reporting progress, and marking each as done', + inputSchema: z.object({ + secondsPerTask: z + .number() + .min(0) + .max(15) + .optional() + .describe('How long to pretend each task takes (default: 3 seconds)') + }) + }, + async ({ secondsPerTask }, ctx) => { + const queue = openTasks(); + if (queue.length === 0) { + return { content: [{ type: 'text', text: 'Nothing open — the board is already clear.' }] }; + } + const paceMs = (secondsPerTask ?? 3) * 1000; + for (const [index, task] of queue.entries()) { + // Honour cancellation: if the client aborted the call (notifications/cancelled), + // stop early instead of ploughing through the rest of the queue. + if (ctx.mcpReq.signal.aborted) { + return { + content: [ + { + type: 'text', + text: `Stopped early — the request was cancelled after ${index} of ${queue.length} task(s).` + } + ] + }; + } + // Narrate the "work" (a log notification per task), pretend it takes a moment so the + // host has live progress to render, then announce the board change for watchers. + await logInfo(ctx, `working on "${task.title}" — ${WORK_QUIPS[index % WORK_QUIPS.length] ?? 'working'}…`); + await new Promise(resolve => setTimeout(resolve, paceMs)); + task.status = 'done'; + await reportProgress(ctx, index + 1, queue.length, `finished "${task.title}"`); + await announceBoardChange(); + } + await logInfo(ctx, `worked through ${queue.length} open task(s)`); + return { + content: [ + { + type: 'text', + text: `Worked through ${queue.length} task(s):\n${queue.map(task => `- ${task.title} ✔`).join('\n')}` + } + ] + }; } - const action = elicitAction(response); - const confirmation = acceptedContent<{ confirm?: boolean }>(ctx.mcpReq.inputResponses, 'confirmation'); + ); + + server.registerTool( + 'clear_done', + { description: 'Delete every completed task (asks the user to confirm first)' }, + async (ctx): Promise => { + const done = [...tasks.values()].filter(task => task.status === 'done'); + if (done.length === 0) return { content: [{ type: 'text', text: 'No completed tasks to clear.' }] }; + const message = `Delete ${done.length} completed task(s) from the board?`; + + // A single input_required round, written once for both eras — the first call has + // no inputResponses and returns the question; the re-call carries the answer. + // (For multi-round flows, dispatch on a discriminated requestState instead — see + // brainstorm_tasks.) On 2025-era sessions the SDK's legacy shim asks the question + // as a real push-style elicitation. + const confirmationView = inputResponse(ctx.mcpReq.inputResponses, 'confirmation'); + if (confirmationView.kind === 'missing') { + return inputRequired({ + inputRequests: { confirmation: inputRequired.elicit({ message, requestedSchema: CLEAR_CONFIRM_SCHEMA }) } + }); + } + const action = confirmationView.kind === 'elicit' ? confirmationView.action : 'cancel'; + const confirmation = acceptedContent<{ confirm?: boolean }>(ctx.mcpReq.inputResponses, 'confirmation'); - if (confirmation?.confirm !== true) { - // Decline and cancel are answers — report them and stop, never ask again. - return { content: [{ type: 'text', text: `Nothing deleted (user answered: ${action}).` }] }; + if (confirmation?.confirm !== true) { + // Decline and cancel are answers — report them and stop, never ask again. + return { content: [{ type: 'text', text: `Nothing deleted (user answered: ${action}).` }] }; + } + for (const task of done) tasks.delete(task.id); + await announceBoardChange(); + await logInfo(ctx, `cleared ${done.length} completed task(s)`); + return { content: [{ type: 'text', text: `Deleted ${done.length} completed task(s).` }] }; } - for (const task of done) tasks.delete(task.id); - await announceBoardChange(); - await logInfo(ctx, `cleared ${done.length} completed task(s)`); - return { content: [{ type: 'text', text: `Deleted ${done.length} completed task(s).` }] }; - } - ); - - server.registerTool( - 'prioritize', - { description: 'Rank the open tasks by importance using the LLM connected to the host, and update their priorities' }, - async (ctx): Promise => { - const candidates = openTasks(); - if (candidates.length === 0) return { content: [{ type: 'text', text: 'No open tasks to prioritize.' }] }; - const samplingRequest = { - systemPrompt: 'You prioritize todo lists. Reply with one task title per line, most important first. No commentary.', - messages: [ - { - role: 'user' as const, - content: { - type: 'text' as const, - text: `Rank these tasks:\n${candidates.map(task => `- ${task.title}`).join('\n')}` + ); + + server.registerTool( + 'prioritize', + { description: 'Rank the open tasks by importance using the LLM connected to the host, and update their priorities' }, + async (ctx): Promise => { + const candidates = openTasks(); + if (candidates.length === 0) return { content: [{ type: 'text', text: 'No open tasks to prioritize.' }] }; + const samplingRequest = { + systemPrompt: 'You prioritize todo lists. Reply with one task title per line, most important first. No commentary.', + messages: [ + { + role: 'user' as const, + content: { + type: 'text' as const, + text: `Rank these tasks:\n${candidates.map(task => `- ${task.title}`).join('\n')}` + } } - } - ], - maxTokens: 400 - }; - - // A single input_required round, written once for both eras (the ranking arrives - // on the retried call), so no requestState is needed. For multi-round flows, - // dispatch on a discriminated requestState instead — see brainstorm_tasks. On - // 2025-era sessions the SDK's legacy shim performs the sampling round trip. - const response = ctx.mcpReq.inputResponses?.['ranking']; - if (response === undefined) { - return inputRequired({ inputRequests: { ranking: inputRequired.createMessage(samplingRequest) } }); - } - const rankingText = sampledText(response); + ], + maxTokens: 400 + }; + + // A single input_required round, written once for both eras (the ranking arrives + // on the retried call), so no requestState is needed. For multi-round flows, + // dispatch on a discriminated requestState instead — see brainstorm_tasks. On + // 2025-era sessions the SDK's legacy shim performs the sampling round trip. + if (inputResponse(ctx.mcpReq.inputResponses, 'ranking').kind === 'missing') { + return inputRequired({ inputRequests: { ranking: inputRequired.createMessage(samplingRequest) } }); + } + const rankingText = sampledText(ctx.mcpReq.inputResponses, 'ranking'); - const ranked = applyRanking(rankingText, candidates); - for (const [index, task] of ranked.entries()) { - task.priority = priorityForRank(index, ranked.length); + const ranked = applyRanking(rankingText, candidates); + for (const [index, task] of ranked.entries()) { + task.priority = priorityForRank(index, ranked.length); + } + // Priorities are board-visible state — watchers and list caches must hear about it. + await announceBoardChange(); + await logInfo(ctx, `prioritize: ranked ${ranked.length} open task(s) via the host LLM`); + return { + content: [ + { + type: 'text', + text: `Re-prioritized ${ranked.length} task(s):\n${ranked.map(task => `- ${task.title} → ${task.priority}`).join('\n')}` + } + ] + }; } - // Priorities are board-visible state — watchers and list caches must hear about it. - await announceBoardChange(); - await logInfo(ctx, `prioritize: ranked ${ranked.length} open task(s) via the host LLM`); - return { - content: [ - { - type: 'text', - text: `Re-prioritized ${ranked.length} task(s):\n${ranked.map(task => `- ${task.title} → ${task.priority}`).join('\n')}` - } - ] - }; - } - ); + ); - return server; + return server; + } + + return { + buildServer, + subscribeInstance, + snapshot: () => structuredClone({ nextId, tasks: [...tasks.values()] }), + restore: snapshot => { + nextId = snapshot.nextId; + tasks.clear(); + for (const task of structuredClone(snapshot.tasks)) tasks.set(task.id, task); + } + }; } diff --git a/examples/todos-server/tsconfig.worker.json b/examples/todos-server/tsconfig.worker.json new file mode 100644 index 0000000000..737f4b64d9 --- /dev/null +++ b/examples/todos-server/tsconfig.worker.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + // Used only by wrangler's bundler (see wrangler.toml). The examples tsconfig maps the + // SDK packages to their Node-flavoured sources (src/shimsNode.ts) for in-repo DX; the + // Worker bundle must instead resolve through each package's exports map so the + // `workerd` condition picks the Workers shims. Clearing `paths` restores that. + "paths": {}, + "noEmit": true + }, + "include": ["worker.ts", "todos.ts", "workerEnv.d.ts"] +} diff --git a/examples/todos-server/worker.ts b/examples/todos-server/worker.ts new file mode 100644 index 0000000000..db35af57ad --- /dev/null +++ b/examples/todos-server/worker.ts @@ -0,0 +1,341 @@ +/** + * Cloudflare Workers entry point for the "todos" reference server (the application itself + * lives in todos.ts; the Node transport entry is ./server.ts). Serves the landing page at `/` + * and MCP over Streamable HTTP at `/mcp`. + * + * Boards are per visitor: each visitor key maps to one Durable Object instance, which owns + * that board's memory (hydrated from durable storage on wake, persisted on every change) and + * one `ServerEventBus` — the single announce channel every serving mode shares. An alarm + * wipes an idle board: this is a demo, boards are ephemeral by design. + * + * Two serving modes share each board: + * - 2026-07-28 (and session-less legacy one-shots) ride `createMcpHandler`'s per-request + * model, exactly like the Node entry; the handler routes bus events onto its + * `subscriptions/listen` streams. + * - 2025-era clients that POST `initialize` get a REAL session: a per-session + * `WebStandardStreamableHTTPServerTransport` connected to a server instance pinned to that + * session, so push-style server→client requests (elicitation/sampling — the interactive + * tools) work over HTTP. Each session subscribes its instance to the board's bus + * (`app.forwardServerEvent`), so `resources/subscribe` subscribers hear changes made on any + * connection; the subscription ends with the session. Session ids embed this object's own + * opaque id, and the worker routes session traffic by that prefix, so a session sticks to + * its board even when the client's egress IP rotates mid-session. Sessions are in-memory: + * if the object is evicted, the next request gets the spec's 404 and a conformant client + * re-initializes (the board itself is durable). + */ +import type { McpHttpHandler } from '@modelcontextprotocol/server'; +import { + classifyInboundRequest, + createMcpHandler, + InMemoryServerEventBus, + WebStandardStreamableHTTPServerTransport +} from '@modelcontextprotocol/server'; + +import indexHtml from './index.html'; +import type { BoardSnapshot, TodosApp } from './todos'; +import { createTodosApp } from './todos'; + +/** How long an untouched board survives before its alarm wipes it. */ +const BOARD_TTL_MS = 2 * 60 * 60 * 1000; +/** Task-count cap per board unless the MAX_TASKS var overrides it. */ +const DEFAULT_MAX_TASKS = 200; +/** Concurrent 2025-era sessions allowed per board. */ +const MAX_SESSIONS = 8; +/** At the cap, sessions idle longer than this are evicted to make room. */ +const SESSION_IDLE_MS = 30 * 60 * 1000; +/** Durable-storage keys: the board snapshot and the fallback requestState key. */ +const BOARD_KEY = 'board:v2'; +const CODEC_KEY_KEY = 'codecKey'; + +// Minimal structural types for the Workers runtime surface this file touches, so the example +// typechecks without adding @cloudflare/workers-types to the workspace. If this entry ever +// grows beyond them, switch to the real types package instead of extending these. +interface DurableObjectStub { + fetch(request: Request): Promise; +} +interface DurableObjectId { + toString(): string; +} +interface DurableObjectNamespace { + idFromName(name: string): DurableObjectId; + /** Throws on anything that is not an id minted by this namespace. */ + idFromString(id: string): DurableObjectId; + get(id: DurableObjectId): DurableObjectStub; +} +interface DurableObjectStorage { + get(key: string): Promise; + put(key: string, value: unknown): Promise; + delete(key: string): Promise; + getAlarm(): Promise; + setAlarm(scheduledTimeMs: number): Promise; +} +interface DurableObjectState { + id: DurableObjectId; + storage: DurableObjectStorage; + blockConcurrencyWhile(callback: () => Promise): Promise; +} + +interface Env { + BOARDS: DurableObjectNamespace; + /** + * HMAC key for the signed multi-round requestState (wrangler secret put REQUEST_STATE_SECRET). + * Optional: without it each board mints its own key and persists it in durable storage, which + * keeps multi-round flows verifiable across isolate recycling (rounds always route back to + * the same board). A deployment-wide secret still wins when set. + */ + REQUEST_STATE_SECRET?: string; + /** Optional override for the per-board task cap (a number as a string; wrangler [vars]). */ + MAX_TASKS?: string; +} + +/** A live 2025-era session: its transport, and when its client was last heard from. */ +interface LegacySession { + transport: WebStandardStreamableHTTPServerTransport; + lastSeenMs: number; +} + +/** + * One visitor's board. The DO is the unit of coherence: its in-memory app instance serves + * every request for this visitor, and the board's bus lives here too, so listen streams and + * pinned sessions all hear this board's changes no matter which edge isolate a request hit. + */ +export class TodosBoard { + private readonly state: DurableObjectState; + private readonly sessions = new Map(); + private readonly bus = new InMemoryServerEventBus(); + private app!: TodosApp; + private handler!: McpHttpHandler; + + constructor(state: DurableObjectState, env: Env) { + this.state = state; + // Everything the first request needs — the codec key, the app, the hydrated board — + // is set up before any event is delivered. + void state.blockConcurrencyWhile(async () => { + const requestStateKey = env.REQUEST_STATE_SECRET ?? (await this.loadOrCreateCodecKey()); + const parsedMaxTasks = Number(env.MAX_TASKS); + const maxTasks = Number.isFinite(parsedMaxTasks) && parsedMaxTasks > 0 ? parsedMaxTasks : DEFAULT_MAX_TASKS; + this.app = createTodosApp({ requestStateKey, maxTasks, bus: this.bus }); + this.handler = createMcpHandler(this.app.buildServer, { bus: this.bus }); + // The durable copy of the board follows the same announcements every client does. + this.bus.subscribe(event => { + if (event.kind === 'resource_updated') void this.persist(); + }); + const snapshot = await state.storage.get(BOARD_KEY); + if (snapshot) this.app.restore(snapshot); + // Every instantiated object carries a wipe alarm, so even a board that only ever + // reads (or a session that never mutates) is cleaned up. + if ((await state.storage.getAlarm()) === null) { + await state.storage.setAlarm(Date.now() + BOARD_TTL_MS); + } + }); + } + + /** + * Fallback requestState key, persisted next to the board: multi-round flows must survive + * this object being evicted between rounds (a per-instance random key would reject its own + * follow-up round with -32602 after any recycle). + */ + private async loadOrCreateCodecKey(): Promise { + const stored = await this.state.storage.get(CODEC_KEY_KEY); + if (stored) return stored; + const created = crypto.getRandomValues(new Uint8Array(32)); + await this.state.storage.put(CODEC_KEY_KEY, created); + return created; + } + + private async persist(): Promise { + await this.state.storage.put(BOARD_KEY, this.app.snapshot()); + // Sliding TTL: every change pushes the wipe out again. + await this.state.storage.setAlarm(Date.now() + BOARD_TTL_MS); + } + + /** + * The TTL alarm: drop the stored board (the codec key stays — in-flight multi-round + * requestState must not be invalidated by a wipe), reset the in-memory one in case we stay + * resident, and end any sessions still pinned to this object. + */ + async alarm(): Promise { + await this.state.storage.delete(BOARD_KEY); + this.app.restore({ nextId: 1, tasks: [] }); + // Live clients hear the wipe like any other change. + this.bus.publish({ kind: 'resources_list_changed' }); + this.bus.publish({ kind: 'resource_updated', uri: 'todos://board' }); + for (const session of this.sessions.values()) { + await session.transport.close().catch(() => {}); + } + // A resident object keeps its cleanup cycle: re-arm for the next window. + await this.state.storage.setAlarm(Date.now() + BOARD_TTL_MS); + } + + /** + * A 2025-era `initialize` opens a session: its own transport (which mints the session id) + * connected to a server instance pinned to this session, sharing this board. The SDK's + * default-on legacy shim then serves the interactive tools as real push-style rounds over + * the session's streams, and the bus subscription below delivers board changes made on any + * other connection to this session's `resources/subscribe` subscribers. + */ + private async createSession(request: Request): Promise { + if (this.sessions.size >= MAX_SESSIONS) { + // Make room by evicting idle sessions before refusing: an abandoned session + // (client gone without DELETE) must not wedge the board at the cap forever. + const cutoff = Date.now() - SESSION_IDLE_MS; + for (const session of this.sessions.values()) { + if (session.lastSeenMs < cutoff) await session.transport.close().catch(() => {}); + } + } + if (this.sessions.size >= MAX_SESSIONS) { + return Response.json( + { + jsonrpc: '2.0', + error: { code: -32_000, message: 'Too many concurrent sessions for this board — retry later.' }, + id: null + }, + { status: 429 } + ); + } + const prefix = this.state.id.toString(); + const server = this.app.buildServer({ era: 'legacy' }); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => `${prefix}.${crypto.randomUUID()}`, + onsessioninitialized: sessionId => { + this.sessions.set(sessionId, { transport, lastSeenMs: Date.now() }); + } + }); + const unsubscribe = this.app.subscribeInstance(server); + // Assigned BEFORE connect: the server chains a transport.onclose that is already set, + // so both this teardown and the server's own run when the transport closes. + transport.onclose = () => { + unsubscribe(); + if (transport.sessionId !== undefined) this.sessions.delete(transport.sessionId); + }; + await server.connect(transport); + const response = await transport.handleRequest(request); + if (transport.sessionId === undefined) { + // The transport refused the handshake (schema-invalid initialize, bad Accept, …): + // no session was minted, so nothing else will ever run the teardown. Close now — + // otherwise the bus subscription pins this server as a zombie for the DO's life. + await transport.close().catch(() => {}); + } + return response; + } + + async fetch(request: Request): Promise { + // Session traffic goes to that session's own transport. + const sessionId = request.headers.get('mcp-session-id'); + if (sessionId !== null) { + const session = this.sessions.get(sessionId); + if (!session) { + // Evicted or expired: the spec's 404 tells the client to re-initialize. + return Response.json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }, { status: 404 }); + } + session.lastSeenMs = Date.now(); + return session.transport.handleRequest(request); + } + + // A session-less legacy `initialize` opens a session; every other request rides the + // per-request handler unchanged. The routing decision comes from the entry's own + // exported classifier — `reason: 'initialize'` names exactly the legacy handshake, + // and never disagrees with createMcpHandler on the edge cells (an `initialize` + // carrying a modern envelope claim or a modern MCP-Protocol-Version header belongs + // to the modern path). Other legacy cells stay on the stateless handler by design. + if (request.method === 'POST') { + const body: unknown = await request + .clone() + .json() + .catch(() => {}); + const outcome = classifyInboundRequest({ + httpMethod: request.method, + protocolVersionHeader: request.headers.get('mcp-protocol-version') ?? undefined, + mcpMethodHeader: request.headers.get('mcp-method') ?? undefined, + mcpNameHeader: request.headers.get('mcp-name') ?? undefined, + body + }); + if (outcome.kind === 'legacy' && outcome.reason === 'initialize') { + return this.createSession(request); + } + return this.handler.fetch(request, body === undefined ? undefined : { parsedBody: body }); + } + return this.handler.fetch(request); + } +} + +// The endpoint is public and credential-free, so a wide-open CORS posture is deliberate: +// browser-based MCP clients (inspectors, playgrounds) can connect straight from the page. +// Headers go on EVERY response (including errors and the 404) — a browser client can only +// read a diagnostic that carries them. +function corsHeaders(request: Request): Record { + return { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET, POST, DELETE, OPTIONS', + // Reflect the requested headers: the '*' wildcard never covers Authorization. + 'access-control-allow-headers': request.headers.get('access-control-request-headers') ?? '*', + 'access-control-expose-headers': '*', + 'access-control-max-age': '86400' + }; +} + +function withCors(request: Request, response: Response): Response { + const headers = new Headers(response.headers); + for (const [name, value] of Object.entries(corsHeaders(request))) headers.set(name, value); + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); +} + +// The landing page is constant per deployment origin — substitute once per isolate. +let landingPage: { origin: string; html: string } | undefined; +function renderLandingPage(origin: string): string { + if (landingPage?.origin !== origin) landingPage = { origin, html: indexHtml.replaceAll('__HOST__', origin) }; + return landingPage.html; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + const response = await (async (): Promise => { + if (request.method === 'OPTIONS') return new Response(null, { status: 204 }); + + if (url.pathname === '/mcp') { + // Board routing. A session id starts with the id of the object that minted it + // (opaque, unguessable, validated by idFromString), so a session survives the + // client's egress IP rotating. Everything else routes by the connecting + // address, or by the X-Todos-Board header for a board of the client's own + // naming — the two namespaced so a header value can never collide with (and + // read) somebody's address-keyed board. + const sessionId = request.headers.get('mcp-session-id'); + let id: DurableObjectId | undefined; + if (sessionId === null) { + const named = request.headers.get('x-todos-board'); + const visitor = named ? `named:${named.slice(0, 128)}` : `ip:${request.headers.get('cf-connecting-ip') ?? 'anonymous'}`; + id = env.BOARDS.idFromName(visitor); + } else { + try { + id = env.BOARDS.idFromString(sessionId.split('.')[0] ?? ''); + } catch { + return Response.json( + { jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }, + { status: 404 } + ); + } + } + try { + return await env.BOARDS.get(id).fetch(request); + } catch (error) { + // Details stay server-side; clients get a stable, generic shape. + console.error('board fetch failed:', error); + return Response.json({ error: 'board unavailable' }, { status: 500 }); + } + } + + if (url.pathname === '/') { + return new Response(renderLandingPage(url.origin), { + headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'public, max-age=300' } + }); + } + + return new Response('Not found. The MCP endpoint is /mcp; see / for how to connect.\n', { + status: 404, + headers: { 'content-type': 'text/plain; charset=utf-8' } + }); + })(); + return withCors(request, response); + } +}; diff --git a/examples/todos-server/workerEnv.d.ts b/examples/todos-server/workerEnv.d.ts new file mode 100644 index 0000000000..40a9477c84 --- /dev/null +++ b/examples/todos-server/workerEnv.d.ts @@ -0,0 +1,5 @@ +/** Wrangler bundles .html imports as text modules (see the [[rules]] block in wrangler.toml). */ +declare module '*.html' { + const text: string; + export default text; +} diff --git a/examples/todos-server/wrangler.toml b/examples/todos-server/wrangler.toml new file mode 100644 index 0000000000..e8e37a4ee1 --- /dev/null +++ b/examples/todos-server/wrangler.toml @@ -0,0 +1,28 @@ +# Cloudflare Workers deployment of the todos reference server. +# Deploys to ..workers.dev; no custom domain is +# configured here — attaching a real hostname is a deliberate, separate step. +name = "todos-demo-ts" +main = "worker.ts" +compatibility_date = "2026-06-18" +tsconfig = "tsconfig.worker.json" + +# The landing page ships inside the bundle as a text module. +[[rules]] +type = "Text" +globs = ["**/*.html"] +fallthrough = true + +# One Durable Object per visitor: holds that visitor's board (memory, hydrated +# from storage on wake) so requests are coherent regardless of which edge +# isolate they land on. +[[durable_objects.bindings]] +name = "BOARDS" +class_name = "TodosBoard" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["TodosBoard"] + +# REQUEST_STATE_SECRET is a deployment secret (wrangler secret put), never in +# this file: it must be stable across isolates so multi-round input_required +# flows survive landing on different instances. From 2b1bbc7f0f50b9064765cac124853b8ee5f4b9d4 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 2 Jul 2026 20:11:14 +0000 Subject: [PATCH 02/23] feat(examples): OAuth via workers-oauth-provider and a live board view on the todos worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @cloudflare/workers-oauth-provider wraps the worker as the Authorization Server: authorize/token/registration endpoints (RFC 7591 dynamic registration and Client ID Metadata Documents behind the platform's strictly-public fetch), both discovery documents, PKCE, and KV-backed grants. The MCP handler stays a pure Resource Server consumer: propsToAuthInfo maps the grant's props into the SDK's AuthInfo, injected per request via handler.fetch. The demo has no user accounts — consent mints a fresh board into the grant, so the token is the board; /mcp keeps the anonymous tier unchanged. Sessions never cross tiers: an OAuth-minted 2025-era session is served only through the provider-verified route (every request re-verifies the token, so expiry and revocation cut live sessions off), a presented session id must belong to the presenting token's own grant, and a session id is never accepted as a credential by itself. Consent approval is bound to the rendered page by a double-submit nonce, authorization-request errors answer 400 instead of surfacing as worker exceptions, and a CORS-filling wrapper makes the provider's challenges readable by browser clients. /board?b= adds a read-only live view of named anonymous boards: the board's Durable Object streams snapshots over SSE from the same ServerEventBus every other consumer uses, so tasks appear and complete in real time as connected agents work. --- .prettierignore | 1 + examples/eslint.config.mjs | 8 +- examples/todos-server/README.md | 36 ++++ examples/todos-server/board.html | 82 +++++++++ examples/todos-server/index.html | 12 ++ examples/todos-server/oauth.ts | 143 +++++++++++++++ examples/todos-server/package.json | 2 +- examples/todos-server/todos.ts | 15 ++ examples/todos-server/worker.ts | 259 ++++++++++++++++++++++----- examples/todos-server/workerEnv.d.ts | 13 ++ examples/todos-server/wrangler.toml | 9 + 11 files changed, 536 insertions(+), 44 deletions(-) create mode 100644 examples/todos-server/board.html create mode 100644 examples/todos-server/oauth.ts diff --git a/.prettierignore b/.prettierignore index ad18072c52..f6fb32c5c4 100644 --- a/.prettierignore +++ b/.prettierignore @@ -28,3 +28,4 @@ packages/codemod/batch-test/results # Quickstart examples uses 2-space indent to match ecosystem conventions examples/client-quickstart/ examples/server-quickstart/ +.wrangler/ diff --git a/examples/eslint.config.mjs b/examples/eslint.config.mjs index b42478deee..00e4e5698b 100644 --- a/examples/eslint.config.mjs +++ b/examples/eslint.config.mjs @@ -8,7 +8,13 @@ export default [ // The nested workspace packages (shared, *-quickstart) are linted by their own configs. // The one-way "@mcp-examples/shared must not import from stories" rule lives in // shared/eslint.config.mjs so it fires under that package's own lint. - ignores: ['shared/**', 'server-quickstart/**', 'client-quickstart/**'] + ignores: ['shared/**', 'server-quickstart/**', 'client-quickstart/**', '**/.wrangler/**'] + }, + { + // The Workers runtime module exists only inside workerd; wrangler resolves it at + // deploy time and workerEnv.d.ts declares it for the typechecker. + files: ['todos-server/worker.ts'], + rules: { 'import/no-unresolved': ['error', { ignore: ['^cloudflare:'] }] } }, { files: ['**/*.{ts,tsx,js,jsx,mts,cts}'], diff --git a/examples/todos-server/README.md b/examples/todos-server/README.md index 14a95a66c0..f94d94846f 100644 --- a/examples/todos-server/README.md +++ b/examples/todos-server/README.md @@ -82,6 +82,42 @@ todos.ts the application: state, tools, resources, prompts, subscriptions persistence, and forwardServerEvent for hosts that pin long-lived instances to a bus ``` +## Live board view (Cloudflare Workers deployment) + +`/board?b=` is a read-only live view of a named anonymous board: the page holds an +SSE stream (`/board/events`) that the board's Durable Object feeds from the same +`ServerEventBus` every other consumer uses, so tasks appear and complete in real time as +connected agents work. Without `?b=` it shows your own address-keyed board. OAuth boards +are not viewable this way — their identity lives only inside the grant. + +## OAuth (Cloudflare Workers deployment) + +The worker wraps everything in [`@cloudflare/workers-oauth-provider`](https://github.com/cloudflare/workers-oauth-provider): +the provider is the Authorization Server (authorize/token endpoints, RFC 7591 dynamic client +registration, Client ID Metadata Documents, and both discovery documents), while the MCP +handler stays a pure Resource Server consumer. `oauth.ts` holds the whole integration: + +- `propsToAuthInfo` — the canonical mapping from the provider's grant `props` to the SDK's + `AuthInfo`. The provider attaches only `props` after verifying a token, so `clientId` and + `scopes` are embedded at grant time; the raw token comes from the request header; + `expiresAt` is omitted because the provider verifies the token on every request that + reaches the API route, including session traffic — so expiry and revocation take effect + immediately without the app tracking timestamps. +- `handleAuthorize` — the consent step. This demo has no user accounts: the principal is the + board, and approving mints a fresh board id into the grant. A real deployment replaces + exactly this step with its own sign-in. + +Two tiers share the same board machinery: `/mcp` stays anonymous (address- or header-keyed +boards), `/oauth/mcp` serves token-authorized boards keyed by the grant (`whoami` shows which +tier a connection is on). Sessions never cross tiers: an OAuth-minted 2025-era session is +served only through the provider-verified route — every request re-verifies the token, so +expiry and revocation cut live sessions off — and a session id is never accepted as a +credential by itself. Requires the `OAUTH_KV` namespace binding (create your own: +`wrangler kv namespace create OAUTH_KV`, then replace the id in wrangler.toml) and the +`global_fetch_strictly_public` compatibility flag, which makes the platform itself guarantee +CIMD metadata fetches only reach public addresses. Setting the `TODOS_AUTO_CONSENT=1` var +auto-approves consent for scripted end-to-end runs — never set it on a real deployment. + ## Deploy it (Cloudflare Workers) `worker.ts` + `wrangler.toml` deploy this server as a public demo: `/` serves the landing page, diff --git a/examples/todos-server/board.html b/examples/todos-server/board.html new file mode 100644 index 0000000000..049e63f1b4 --- /dev/null +++ b/examples/todos-server/board.html @@ -0,0 +1,82 @@ + + + + + + todos — live board + + + +

Live board:

+

+ Updates appear the moment any MCP client changes this board. Point a client at __HOST__/mcp with the + X-Todos-Board header set to this board's name (add ?b=<name> to this page's URL), then watch + tasks land here as the agent works. Boards wipe after ~2 hours idle. +

+
    + +

    connecting…

    + + + diff --git a/examples/todos-server/index.html b/examples/todos-server/index.html index 23ee1c4170..9c4d6bce9e 100644 --- a/examples/todos-server/index.html +++ b/examples/todos-server/index.html @@ -51,6 +51,18 @@

    Connect

    an mcpServers-style host config:

    { "mcpServers": { "todos": { "url": "__HOST__/mcp" } } }
    +

    + Boards on /mcp are anonymous: keyed by your network address, or by an X-Todos-Board header you choose. + For a private board, connect to __HOST__/oauth/mcp instead — your client discovers the OAuth + endpoints automatically (RFC 9728), registers itself (dynamic registration or a URL-format client id), and walks the + authorization-code flow with PKCE. Approving consent creates a fresh board bound to that grant: the token is the board. No + accounts; boards still expire after ~2 hours idle. +

    +
    { "mcpServers": { "todos": { "url": "__HOST__/oauth/mcp" } } }
    +

    + Watch a board update live at __HOST__/board?b=<name> while a client connected with + X-Todos-Board: <name> works on it. +

    Or raw, in the 2026-07-28 wire format (there is no initialize — try server/discover):

     curl -X POST __HOST__/mcp \
    diff --git a/examples/todos-server/oauth.ts b/examples/todos-server/oauth.ts
    new file mode 100644
    index 0000000000..30e6d79c06
    --- /dev/null
    +++ b/examples/todos-server/oauth.ts
    @@ -0,0 +1,143 @@
    +/**
    + * OAuth glue for the todos worker: `@cloudflare/workers-oauth-provider` is the
    + * Authorization Server (endpoints, token issuance, client registration — both
    + * RFC 7591 dynamic registration and Client ID Metadata Documents), and the
    + * MCP handler stays a pure Resource Server consumer: the provider verifies
    + * tokens on the API route and this module maps the grant's `props` into the
    + * SDK's `AuthInfo`.
    + *
    + * The demo has no user accounts. The principal is the board: approving the
    + * consent screen mints a fresh board id into the grant's props, so the token
    + * IS the board — private, portable, and immune to egress-IP rotation. Real
    + * deployments replace exactly one thing: the consent step authenticates a
    + * user (their IdP) instead of minting an anonymous board.
    + */
    +import type { AuthInfo } from '@modelcontextprotocol/server';
    +
    +/** What `completeAuthorization` stores and every authorized request gets back. */
    +export interface TodosGrantProps {
    +    boardId: string;
    +    clientId: string;
    +    scopes: string[];
    +}
    +
    +/**
    + * The canonical mapping from the provider's grant props to the SDK's
    + * `AuthInfo`. The provider attaches only `props` after verifying a token, so
    + * everything `AuthInfo` needs must be embedded at grant time (clientId,
    + * scopes); the raw token comes from the request header. `expiresAt` is omitted
    + * because the provider verifies the token on every request that reaches the
    + * API route — including every request of a 2025-era session, which the worker
    + * refuses unless it arrives through the provider-verified route — so expiry
    + * and revocation cut access without the app tracking timestamps.
    + */
    +export function propsToAuthInfo(props: TodosGrantProps, request: Request): AuthInfo {
    +    return {
    +        token: request.headers.get('authorization')?.replace(/^Bearer /i, '') ?? '',
    +        clientId: props.clientId,
    +        scopes: props.scopes
    +    };
    +}
    +
    +/** The provider helpers the worker uses (subset of OAuthHelpers we touch). */
    +export interface OAuthHelpers {
    +    parseAuthRequest(request: Request): Promise<{ clientId: string; scope: string[]; state: string; redirectUri: string }>;
    +    lookupClient(clientId: string): Promise<{ clientId: string; clientName?: string; redirectUris: string[] } | null>;
    +    completeAuthorization(options: {
    +        request: unknown;
    +        userId: string;
    +        metadata: Record;
    +        scope: string[];
    +        props: TodosGrantProps;
    +    }): Promise<{ redirectTo: string }>;
    +}
    +
    +function escapeHtml(value: string): string {
    +    return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
    +}
    +
    +function consentPage(clientName: string, scopes: string[], approveAction: string, nonce: string): string {
    +    return `
    +
    +
    +Authorize — todos demo
    +
    +

    Authorize ${escapeHtml(clientName)}

    +

    ${escapeHtml(clientName)} is asking for access with scope +${escapeHtml(scopes.join(' '))}.

    +

    This demo has no accounts: approving creates a fresh private todo board +bound to this authorization. Whoever holds the resulting token holds the board. +A real deployment would sign you in here instead.

    +
    + + +
    +`; +} + +/** + * GET /authorize — parse the request, show consent (or auto-approve when the + * TODOS_AUTO_CONSENT var is set, which the scripted end-to-end dance uses). + * POST /authorize/approve — mint the board and complete the grant. + * + * The original OAuth query parameters ride along to the approve action so the + * provider re-parses them there; the provider itself validates client and + * redirect URI on completion. Approval is bound to the rendered consent page + * by a double-submit nonce (HttpOnly cookie + hidden field), so a bare POST + * can never mint a grant. + */ +export async function handleAuthorize(request: Request, provider: OAuthHelpers, autoConsent: boolean): Promise { + const url = new URL(request.url); + // Routine client mistakes (unknown client_id, mismatched redirect_uri, disabled + // flows) must answer 400, not surface as worker exceptions. + let oauthRequest: Awaited>; + let client: Awaited>; + try { + oauthRequest = await provider.parseAuthRequest(request); + client = await provider.lookupClient(oauthRequest.clientId); + } catch (error) { + console.warn('authorize request rejected:', error); + return new Response('Invalid authorization request.\n', { status: 400 }); + } + if (!client) { + return new Response('Unknown client.\n', { status: 400 }); + } + + const approve = async (): Promise => { + const boardId = crypto.randomUUID(); + const scopes = oauthRequest.scope.length > 0 ? oauthRequest.scope : ['todos']; + const { redirectTo } = await provider.completeAuthorization({ + request: oauthRequest, + userId: boardId, + metadata: { minted: new Date().toISOString() }, + scope: scopes, + props: { boardId, clientId: oauthRequest.clientId, scopes } + }); + return Response.redirect(redirectTo, 302); + }; + + // Approval happens ONLY on the dedicated approve action, bound to the consent + // page by a double-submit nonce (cookie + hidden field). A POST to /authorize + // itself (spec-permitted for the request) renders consent like a GET does. + if (url.pathname === '/authorize/approve' && request.method === 'POST') { + const form = await request.formData().catch(() => {}); + const submitted = form?.get('nonce'); + const cookieNonce = /(?:^|;\s*)todos_consent=([^;]+)/.exec(request.headers.get('cookie') ?? '')?.[1]; + if (typeof submitted !== 'string' || submitted.length === 0 || submitted !== cookieNonce) { + return new Response('Consent expired — reopen the authorization link.\n', { status: 400 }); + } + return approve(); + } + if (autoConsent) { + return approve(); + } + const nonce = crypto.randomUUID(); + const clientName = client.clientName ?? client.clientId; + return new Response(consentPage(clientName, oauthRequest.scope, `/authorize/approve?${url.searchParams.toString()}`, nonce), { + headers: { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + 'set-cookie': `todos_consent=${nonce}; Path=/authorize; Max-Age=600; HttpOnly; Secure; SameSite=Lax` + } + }); +} diff --git a/examples/todos-server/package.json b/examples/todos-server/package.json index 7f5ac0f35a..ab48ea1f26 100644 --- a/examples/todos-server/package.json +++ b/examples/todos-server/package.json @@ -10,7 +10,7 @@ "typecheck:worker": "tsc -p tsconfig.worker.json --noEmit" }, "dependencies": { - "@hono/node-server": "catalog:runtimeServerOnly", + "@cloudflare/workers-oauth-provider": "0.8.0", "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/hono": "workspace:*", "@modelcontextprotocol/server": "workspace:*", diff --git a/examples/todos-server/todos.ts b/examples/todos-server/todos.ts index 8fdd3d6e2b..4b2efca861 100644 --- a/examples/todos-server/todos.ts +++ b/examples/todos-server/todos.ts @@ -740,6 +740,21 @@ export function createTodosApp(options: TodosAppOptions = {}): TodosApp { } ); + server.registerTool( + 'whoami', + { description: 'Show the identity this connection serves: the verified OAuth grant, or the anonymous tier.' }, + async (): Promise => ({ + content: [ + { + type: 'text', + text: reqCtx.authInfo + ? `Authenticated via OAuth: client ${reqCtx.authInfo.clientId ?? 'unknown'}, scopes [${(reqCtx.authInfo.scopes ?? []).join(' ')}]. This board belongs to your grant.` + : 'Anonymous tier: this board is keyed by your network address or the X-Todos-Board header. Authorize via OAuth for a private board.' + } + ] + }) + ); + server.registerTool( 'prioritize', { description: 'Rank the open tasks by importance using the LLM connected to the host, and update their priorities' }, diff --git a/examples/todos-server/worker.ts b/examples/todos-server/worker.ts index db35af57ad..a1ac30bc3c 100644 --- a/examples/todos-server/worker.ts +++ b/examples/todos-server/worker.ts @@ -1,7 +1,10 @@ /** * Cloudflare Workers entry point for the "todos" reference server (the application itself - * lives in todos.ts; the Node transport entry is ./server.ts). Serves the landing page at `/` - * and MCP over Streamable HTTP at `/mcp`. + * lives in todos.ts; the Node transport entry is ./server.ts; the OAuth glue in ./oauth.ts). + * `@cloudflare/workers-oauth-provider` wraps the worker as the Authorization Server: the + * landing page and anonymous `/mcp` ride its defaultHandler unchanged, while `/oauth/mcp` + * serves token-authorized boards — the provider verifies, `propsToAuthInfo` maps the grant + * into the SDK's `AuthInfo`, and the board IS the grant (no user accounts). * * Boards are per visitor: each visitor key maps to one Durable Object instance, which owns * that board's memory (hydrated from durable storage on wake, persisted on every change) and @@ -23,15 +26,20 @@ * if the object is evicted, the next request gets the spec's 404 and a conformant client * re-initializes (the board itself is durable). */ -import type { McpHttpHandler } from '@modelcontextprotocol/server'; +import { OAuthProvider } from '@cloudflare/workers-oauth-provider'; +import type { AuthInfo, McpHttpHandler } from '@modelcontextprotocol/server'; import { classifyInboundRequest, createMcpHandler, InMemoryServerEventBus, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { WorkerEntrypoint } from 'cloudflare:workers'; +import boardHtml from './board.html'; import indexHtml from './index.html'; +import type { OAuthHelpers, TodosGrantProps } from './oauth'; +import { handleAuthorize, propsToAuthInfo } from './oauth'; import type { BoardSnapshot, TodosApp } from './todos'; import { createTodosApp } from './todos'; @@ -86,11 +94,22 @@ interface Env { REQUEST_STATE_SECRET?: string; /** Optional override for the per-board task cap (a number as a string; wrangler [vars]). */ MAX_TASKS?: string; + /** Grant/token storage for the OAuth provider (wrangler kv namespace). */ + OAUTH_KV: unknown; + /** Injected by the provider: parseAuthRequest/lookupClient/completeAuthorization. */ + OAUTH_PROVIDER: OAuthHelpers; + /** Set to '1' to auto-approve consent — used by the scripted end-to-end dance. */ + TODOS_AUTO_CONSENT?: string; } -/** A live 2025-era session: its transport, and when its client was last heard from. */ +/** Internal relay: the API handler forwards verified auth into the board object. */ +const AUTH_RELAY_HEADER = 'x-todos-authinfo'; + +/** A live 2025-era session: its transport, tier, and when its client was last heard from. */ interface LegacySession { transport: WebStandardStreamableHTTPServerTransport; + /** Minted through the provider-verified route: every request must arrive the same way. */ + authed: boolean; lastSeenMs: number; } @@ -174,7 +193,7 @@ export class TodosBoard { * the session's streams, and the bus subscription below delivers board changes made on any * other connection to this session's `resources/subscribe` subscribers. */ - private async createSession(request: Request): Promise { + private async createSession(request: Request, authInfo?: AuthInfo): Promise { if (this.sessions.size >= MAX_SESSIONS) { // Make room by evicting idle sessions before refusing: an abandoned session // (client gone without DELETE) must not wedge the board at the cap forever. @@ -194,11 +213,11 @@ export class TodosBoard { ); } const prefix = this.state.id.toString(); - const server = this.app.buildServer({ era: 'legacy' }); + const server = this.app.buildServer({ era: 'legacy', ...(authInfo === undefined ? {} : { authInfo }) }); const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => `${prefix}.${crypto.randomUUID()}`, onsessioninitialized: sessionId => { - this.sessions.set(sessionId, { transport, lastSeenMs: Date.now() }); + this.sessions.set(sessionId, { transport, authed: authInfo !== undefined, lastSeenMs: Date.now() }); } }); const unsubscribe = this.app.subscribeInstance(server); @@ -219,8 +238,58 @@ export class TodosBoard { return response; } + /** + * A live read-only view of this board: the current snapshot immediately, then a fresh + * snapshot on every change, as server-sent events. Just another bus subscriber — the + * same seam the durable copy and the pinned sessions use. + */ + private boardEventStream(): Response { + const encoder = new TextEncoder(); + let unsubscribe: (() => void) | undefined; + let heartbeat: ReturnType | undefined; + const stream = new ReadableStream({ + start: controller => { + const send = (): void => { + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(this.app.snapshot())}\n\n`)); + } catch { + // Stream already closed; the cancel() teardown handles the rest. + } + }; + send(); + unsubscribe = this.bus.subscribe(event => { + if (event.kind === 'resource_updated') send(); + }); + heartbeat = setInterval(() => { + try { + controller.enqueue(encoder.encode(': keepalive\n\n')); + } catch { + /* closed */ + } + }, 25_000); + }, + cancel: () => { + unsubscribe?.(); + if (heartbeat !== undefined) clearInterval(heartbeat); + } + }); + return new Response(stream, { + headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-store' } + }); + } + async fetch(request: Request): Promise { - // Session traffic goes to that session's own transport. + if (new URL(request.url).pathname === '/board/events') { + return this.boardEventStream(); + } + // Verified auth relayed by the API handler (never trusted from clients: the + // anonymous route strips this header before the request reaches any board). + const relayed = request.headers.get(AUTH_RELAY_HEADER); + const authInfo = relayed === null ? undefined : (JSON.parse(relayed) as AuthInfo); + // Session traffic goes to that session's own transport — but never across tiers. + // An OAuth-minted session must arrive through the provider-verified route on every + // request (so token expiry and revocation cut it off), and an anonymous session id + // is worthless on the authed route: a session id alone is never a credential. const sessionId = request.headers.get('mcp-session-id'); if (sessionId !== null) { const session = this.sessions.get(sessionId); @@ -228,6 +297,12 @@ export class TodosBoard { // Evicted or expired: the spec's 404 tells the client to re-initialize. return Response.json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }, { status: 404 }); } + if (session.authed !== (authInfo !== undefined)) { + return Response.json( + { jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found on this endpoint' }, id: null }, + { status: session.authed ? 401 : 404 } + ); + } session.lastSeenMs = Date.now(); return session.transport.handleRequest(request); } @@ -251,11 +326,11 @@ export class TodosBoard { body }); if (outcome.kind === 'legacy' && outcome.reason === 'initialize') { - return this.createSession(request); + return this.createSession(request, authInfo); } - return this.handler.fetch(request, body === undefined ? undefined : { parsedBody: body }); + return this.handler.fetch(request, { ...(body === undefined ? {} : { parsedBody: body }), authInfo }); } - return this.handler.fetch(request); + return this.handler.fetch(request, { authInfo }); } } @@ -287,42 +362,106 @@ function renderLandingPage(origin: string): string { return landingPage.html; } -export default { +let boardPage: { origin: string; html: string } | undefined; +function renderBoardPage(origin: string): string { + if (boardPage?.origin !== origin) boardPage = { origin, html: boardHtml.replaceAll('__HOST__', origin) }; + return boardPage.html; +} + +/** + * Route one MCP request to its board. Session ids carry the id of the object that + * minted them (opaque, unguessable, validated by idFromString), so a session survives + * the client's egress IP rotating; session-less requests route by `fallbackBoardName`. + */ +async function serveBoard(request: Request, env: Env, fallbackBoardName: string): Promise { + const sessionId = request.headers.get('mcp-session-id'); + let id: DurableObjectId | undefined; + if (sessionId === null) { + id = env.BOARDS.idFromName(fallbackBoardName); + } else { + try { + id = env.BOARDS.idFromString(sessionId.split('.')[0] ?? ''); + } catch { + return Response.json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }, { status: 404 }); + } + } + try { + return await env.BOARDS.get(id).fetch(request); + } catch (error) { + // Details stay server-side; clients get a stable, generic shape. + console.error('board fetch failed:', error); + return Response.json({ error: 'board unavailable' }, { status: 500 }); + } +} + +/** + * Token-authorized MCP: the provider has already verified the access token and + * decrypted the grant's props. The board IS the grant (`auth:`), and the + * verified identity rides an internal header into the board object, where it + * surfaces to tool handlers as `ctx.authInfo`. + */ +export class TodosApi extends WorkerEntrypoint { + override async fetch(request: Request): Promise { + const props = (this.ctx as { props?: TodosGrantProps }).props; + if (!props?.boardId) { + return Response.json({ error: 'invalid grant' }, { status: 403 }); + } + // A presented session id must belong to this grant's own board: the token decides + // the board, never the session id (which could otherwise route into another grant). + const sessionId = request.headers.get('mcp-session-id'); + if (sessionId !== null && sessionId.split('.')[0] !== this.env.BOARDS.idFromName(`auth:${props.boardId}`).toString()) { + return withCors( + request, + Response.json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }, { status: 404 }) + ); + } + const headers = new Headers(request.headers); + headers.set(AUTH_RELAY_HEADER, JSON.stringify(propsToAuthInfo(props, request))); + const relayed = new Request(request, { headers }); + const response = await serveBoard(relayed, this.env, `auth:${props.boardId}`); + return withCors(request, response); + } +} + +const defaultHandler = { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); const response = await (async (): Promise => { if (request.method === 'OPTIONS') return new Response(null, { status: 204 }); if (url.pathname === '/mcp') { - // Board routing. A session id starts with the id of the object that minted it - // (opaque, unguessable, validated by idFromString), so a session survives the - // client's egress IP rotating. Everything else routes by the connecting - // address, or by the X-Todos-Board header for a board of the client's own - // naming — the two namespaced so a header value can never collide with (and - // read) somebody's address-keyed board. - const sessionId = request.headers.get('mcp-session-id'); - let id: DurableObjectId | undefined; - if (sessionId === null) { - const named = request.headers.get('x-todos-board'); - const visitor = named ? `named:${named.slice(0, 128)}` : `ip:${request.headers.get('cf-connecting-ip') ?? 'anonymous'}`; - id = env.BOARDS.idFromName(visitor); - } else { - try { - id = env.BOARDS.idFromString(sessionId.split('.')[0] ?? ''); - } catch { - return Response.json( - { jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }, - { status: 404 } - ); - } - } - try { - return await env.BOARDS.get(id).fetch(request); - } catch (error) { - // Details stay server-side; clients get a stable, generic shape. - console.error('board fetch failed:', error); - return Response.json({ error: 'board unavailable' }, { status: 500 }); - } + // Anonymous tier, unchanged: boards keyed by the X-Todos-Board header (a + // board of the client's own naming) or the connecting address — namespaced + // so a header value can never collide with an address-keyed board. The + // internal auth-relay header is stripped: only the provider-verified API + // route may assert an identity. + const headers = new Headers(request.headers); + headers.delete(AUTH_RELAY_HEADER); + const named = request.headers.get('x-todos-board'); + const visitor = named ? `named:${named.slice(0, 128)}` : `ip:${request.headers.get('cf-connecting-ip') ?? 'anonymous'}`; + return serveBoard(new Request(request, { headers }), env, visitor); + } + + if (url.pathname === '/authorize' || url.pathname === '/authorize/approve') { + return handleAuthorize(request, env.OAUTH_PROVIDER, env.TODOS_AUTO_CONSENT === '1'); + } + + if (url.pathname === '/board') { + return new Response(renderBoardPage(url.origin), { + headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'public, max-age=300' } + }); + } + + if (url.pathname === '/board/events') { + // Read-only live view of anonymous boards: ?b= for a named board, + // otherwise the viewer's own address-keyed board. OAuth boards are not + // reachable here — their id lives only inside the grant. + if (request.method !== 'GET') return new Response(null, { status: 405 }); + const named = url.searchParams.get('b'); + const viewer = named ? `named:${named.slice(0, 128)}` : `ip:${request.headers.get('cf-connecting-ip') ?? 'anonymous'}`; + const headers = new Headers(request.headers); + headers.delete(AUTH_RELAY_HEADER); + return serveBoard(new Request(request, { headers }), env, viewer); } if (url.pathname === '/') { @@ -331,7 +470,7 @@ export default { }); } - return new Response('Not found. The MCP endpoint is /mcp; see / for how to connect.\n', { + return new Response('Not found. The MCP endpoint is /mcp (anonymous) or /oauth/mcp (OAuth); see / for how to connect.\n', { status: 404, headers: { 'content-type': 'text/plain; charset=utf-8' } }); @@ -339,3 +478,39 @@ export default { return withCors(request, response); } }; + +// The provider owns the OAuth endpoints (authorize/token/register + both discovery +// documents), verifies tokens on the API route, and passes everything else through +// to the default handler. CIMD needs the global_fetch_strictly_public compatibility +// flag (wrangler.toml): the platform itself guarantees metadata fetches only reach +// public addresses. +const provider = new OAuthProvider({ + apiRoute: '/oauth/mcp', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + apiHandler: TodosApi as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + defaultHandler: defaultHandler as any, + authorizeEndpoint: '/authorize', + tokenEndpoint: '/oauth/token', + clientRegistrationEndpoint: '/oauth/register', + scopesSupported: ['todos'], + clientIdMetadataDocumentEnabled: true +}); + +export default { + // Browser-based MCP clients must be able to READ the provider's 401 challenge + // (WWW-Authenticate) and discovery documents cross-origin. The provider sets its + // own CORS on some routes; ours fills in only what is missing. + async fetch(request: Request, env: Env, ctx: unknown): Promise { + const response = await (provider as unknown as { fetch(request: Request, env: Env, ctx: unknown): Promise }).fetch( + request, + env, + ctx + ); + const headers = new Headers(response.headers); + for (const [name, value] of Object.entries(corsHeaders(request))) { + if (!headers.has(name)) headers.set(name, value); + } + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); + } +}; diff --git a/examples/todos-server/workerEnv.d.ts b/examples/todos-server/workerEnv.d.ts index 40a9477c84..fb1fee79f8 100644 --- a/examples/todos-server/workerEnv.d.ts +++ b/examples/todos-server/workerEnv.d.ts @@ -3,3 +3,16 @@ declare module '*.html' { const text: string; export default text; } + +/** + * Minimal structural declaration for the Workers runtime module, matching what + * this example uses (the provider dispatches API requests to a WorkerEntrypoint + * subclass). Swap for @cloudflare/workers-types if the surface grows. + */ +declare module 'cloudflare:workers' { + export class WorkerEntrypoint { + protected readonly env: Env; + protected readonly ctx: { props?: unknown; waitUntil(promise: Promise): void }; + fetch?(request: Request): Response | Promise; + } +} diff --git a/examples/todos-server/wrangler.toml b/examples/todos-server/wrangler.toml index e8e37a4ee1..a5a4d451ce 100644 --- a/examples/todos-server/wrangler.toml +++ b/examples/todos-server/wrangler.toml @@ -4,6 +4,9 @@ name = "todos-demo-ts" main = "worker.ts" compatibility_date = "2026-06-18" +# CIMD (URL-formatted client ids): the platform guarantees metadata fetches +# only ever reach public addresses — SSRF protection at the runtime layer. +compatibility_flags = ["global_fetch_strictly_public"] tsconfig = "tsconfig.worker.json" # The landing page ships inside the bundle as a text module. @@ -26,3 +29,9 @@ new_sqlite_classes = ["TodosBoard"] # REQUEST_STATE_SECRET is a deployment secret (wrangler secret put), never in # this file: it must be stable across isolates so multi-round input_required # flows survive landing on different instances. + +# Grant/token storage for the OAuth provider. The id below is account-specific: +# create your own with `wrangler kv namespace create OAUTH_KV` and replace it. +[[kv_namespaces]] +binding = "OAUTH_KV" +id = "736937bd5b404370a37eb548ef0311de" From ba7b01eda62b48bf7db7f975511547549e22c736 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 2 Jul 2026 20:14:11 +0000 Subject: [PATCH 03/23] feat(examples): live view of OAuth boards, claimed at consent The one moment the human is in the browser is consent, so approval doubles as claiming the live view: the approve response sets an opaque KV-backed viewer cookie (HttpOnly, Path=/board, 2h TTL), and /board without ?b= resolves it to the grant's own board, falling back to the address-keyed board. No token or board id ever appears in a URL; an unknown or expired viewer session falls back cleanly. --- examples/todos-server/README.md | 5 +++-- examples/todos-server/board.html | 3 ++- examples/todos-server/oauth.ts | 28 ++++++++++++++++++++++++++-- examples/todos-server/worker.ts | 30 +++++++++++++++++++++--------- 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/examples/todos-server/README.md b/examples/todos-server/README.md index f94d94846f..571da029ea 100644 --- a/examples/todos-server/README.md +++ b/examples/todos-server/README.md @@ -87,8 +87,9 @@ todos.ts the application: state, tools, resources, prompts, subscriptions `/board?b=` is a read-only live view of a named anonymous board: the page holds an SSE stream (`/board/events`) that the board's Durable Object feeds from the same `ServerEventBus` every other consumer uses, so tasks appear and complete in real time as -connected agents work. Without `?b=` it shows your own address-keyed board. OAuth boards -are not viewable this way — their identity lives only inside the grant. +connected agents work. Without `?b=` it shows your grant's board if you approved an OAuth +consent in this browser (approval sets an opaque, KV-backed viewer cookie — no token or +board id ever appears in a URL), falling back to your address-keyed board. ## OAuth (Cloudflare Workers deployment) diff --git a/examples/todos-server/board.html b/examples/todos-server/board.html index 049e63f1b4..586c94cd47 100644 --- a/examples/todos-server/board.html +++ b/examples/todos-server/board.html @@ -53,7 +53,8 @@

    Live board:

    connecting…

    diff --git a/examples/todos-server/oauth.ts b/examples/todos-server/oauth.ts index 12983d56dd..28d7da3091 100644 --- a/examples/todos-server/oauth.ts +++ b/examples/todos-server/oauth.ts @@ -76,10 +76,12 @@ bound to this authorization. Whoever holds the resulting token holds the board. A real deployment would sign you in here instead.

    After approving, open /board in this browser to watch the board update live while your client works.

    -
    + - +
    +

    Approving finishes the sign-in in this tab and opens your board's +live view in a new one (or open /board yourself afterwards).

    `; } From e437779bf77ca876a4ef61bc14a69f60698b5a53 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 2 Jul 2026 20:43:38 +0000 Subject: [PATCH 08/23] fix(examples): rewrite the board page script; a stale element reference killed it The script still assigned to the removed board-name element, so the first statement threw and nothing ran: no identity line, status stuck on connecting. The page script is now a single clean connect() with the consent-race retry folded in. --- examples/todos-server/board.html | 94 +++++++++++++++++--------------- 1 file changed, 49 insertions(+), 45 deletions(-) diff --git a/examples/todos-server/board.html b/examples/todos-server/board.html index 9bd7cc0282..9785afaeee 100644 --- a/examples/todos-server/board.html +++ b/examples/todos-server/board.html @@ -26,6 +26,9 @@ color: #888; text-decoration: line-through; } + #identity { + font-weight: 600; + } #status { color: #888; font-size: 0.85rem; @@ -42,58 +45,35 @@ -

    Live board

    -

    +

    Live board

    +

    resolving whose board this is…

    - Updates appear the moment any MCP client changes this board. Point a client at __HOST__/mcp with the - X-Todos-Board header set to this board's name (add ?b=<name> to this page's URL), then watch - tasks land here as the agent works. Boards wipe after ~2 hours idle. + Updates appear the moment any MCP client changes this board. Anonymous boards: connect a client to + __HOST__/mcp with the X-Todos-Board header and add ?b=<name> here. OAuth boards: + this page shows your grant's board in the browser where you approved consent. Boards wipe after ~2 hours idle.

      connecting…

      From 23eaba9d4bacc172b85565739df6311af76b9fb0 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 2 Jul 2026 20:47:58 +0000 Subject: [PATCH 09/23] fix(examples): consent-opened board tab waits for the approval to land window.open fires on the submit gesture, before the approve POST sets the new viewer cookie, so the tab could resolve to a previous grant's board. The consent link now carries #claim, and the page defers its first connection under it (on top of the existing no-cookie retry). --- examples/todos-server/board.html | 13 ++++++++++++- examples/todos-server/oauth.ts | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/examples/todos-server/board.html b/examples/todos-server/board.html index 9785afaeee..0d59185f91 100644 --- a/examples/todos-server/board.html +++ b/examples/todos-server/board.html @@ -61,6 +61,11 @@

      Live board

      const empty = document.getElementById('empty'); const status = document.getElementById('status'); const namedBoard = new URLSearchParams(location.search).get('b'); + // Opened from the consent click, this tab races the approval POST: the + // viewer cookie for the NEW grant lands with the approve response, which + // may arrive after this page. #claim defers the first connection so the + // cookie is in place, instead of resolving to a previous grant's board. + const claiming = location.hash === '#claim' && !namedBoard; let source; let retried = false; @@ -111,7 +116,13 @@

      Live board

      renderInfo(info); }); } - connect(); + if (claiming) { + status.textContent = 'claiming your board…'; + identity.textContent = 'finishing authorization…'; + setTimeout(connect, 1500); + } else { + connect(); + } diff --git a/examples/todos-server/oauth.ts b/examples/todos-server/oauth.ts index 28d7da3091..1071cc7879 100644 --- a/examples/todos-server/oauth.ts +++ b/examples/todos-server/oauth.ts @@ -76,7 +76,7 @@ bound to this authorization. Whoever holds the resulting token holds the board. A real deployment would sign you in here instead.

      After approving, open /board in this browser to watch the board update live while your client works.

      -
      +
      From 805a124025bbb3d99214530b4c920884a0659378 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 2 Jul 2026 21:07:25 +0000 Subject: [PATCH 10/23] fix(examples): the board view route never routes by session id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stale session id on /board/events overrode the resolved board name and streamed a private board's snapshots with no token, surviving revocation — the third instance of the session-id-as-credential class. The view branch now strips mcp-session-id before dispatch. --- examples/todos-server/worker.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/examples/todos-server/worker.ts b/examples/todos-server/worker.ts index a40553bcc4..3289942fd3 100644 --- a/examples/todos-server/worker.ts +++ b/examples/todos-server/worker.ts @@ -488,6 +488,9 @@ const defaultHandler = { viewInfo ??= { mode: 'address', label: 'your network address' }; const headers = new Headers(request.headers); headers.delete(AUTH_RELAY_HEADER); + // The view route must NEVER route by session id: a stale session id would + // override the resolved board and stream a private board without a token. + headers.delete('mcp-session-id'); headers.set(VIEW_INFO_HEADER, JSON.stringify(viewInfo)); return serveBoard(new Request(request, { headers }), env, boardName); } From f29f238827d392e40ac3fe7ac6247a52e447ac85 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 3 Jul 2026 04:39:35 +0000 Subject: [PATCH 11/23] =?UTF-8?q?refactor(examples):=20one=20door=20to=20a?= =?UTF-8?q?=20board=20=E2=80=94=20serveBoard=20owns=20the=20whole=20tier?= =?UTF-8?q?=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tier/identity rules were enforced at nine coordinated sites across five functions, and the coordination kept failing (three session-id-as-credential bypasses, each at a newly added ingress). serveBoard now takes a BoardRoute — mcp traffic with optional verified identity, or the read-only view — and is the only code that touches the internal relay headers, honors or refuses session routing, and binds sessions to the token's own board. TodosApi and the /mcp and /board/events routes each collapse to a route description. The viewer-session contract (cookie name, KV key, record shape, TTL) moves whole into oauth.ts next to its mint; the worker calls resolveViewerBoard. The provider's own OAuthHelpers type replaces the hand-rolled structural copy. The board page script becomes board.client.js — a Text module the lint and format gates actually see (the shipped-dead-script class dies here) — inlined at render. The PUBLIC_ORIGIN var is gone: the board object learns its origin from the first request and boardViewPath accepts a thunk. Duplicate page-render and CORS helpers folded. --- examples/eslint.config.mjs | 13 ++ examples/todos-server/README.md | 2 +- examples/todos-server/board.client.js | 73 +++++++ examples/todos-server/board.html | 68 +----- examples/todos-server/e2e/verify.ts | 293 ++++++++++++++++++++++++++ examples/todos-server/oauth.ts | 63 +++--- examples/todos-server/package.json | 3 +- examples/todos-server/todos.ts | 24 ++- examples/todos-server/worker.ts | 191 ++++++++++------- examples/todos-server/workerEnv.d.ts | 6 + examples/todos-server/wrangler.toml | 7 +- 11 files changed, 552 insertions(+), 191 deletions(-) create mode 100644 examples/todos-server/board.client.js create mode 100644 examples/todos-server/e2e/verify.ts diff --git a/examples/eslint.config.mjs b/examples/eslint.config.mjs index 00e4e5698b..27ee4eecde 100644 --- a/examples/eslint.config.mjs +++ b/examples/eslint.config.mjs @@ -16,6 +16,19 @@ export default [ files: ['todos-server/worker.ts'], rules: { 'import/no-unresolved': ['error', { ignore: ['^cloudflare:'] }] } }, + { + // Browser page scripts shipped as Text modules (inlined into HTML at render). + files: ['**/*.client.js'], + languageOptions: { + globals: { + document: 'readonly', + location: 'readonly', + EventSource: 'readonly', + URLSearchParams: 'readonly', + setTimeout: 'readonly' + } + } + }, { files: ['**/*.{ts,tsx,js,jsx,mts,cts}'], rules: { diff --git a/examples/todos-server/README.md b/examples/todos-server/README.md index 571da029ea..3c81c8c55b 100644 --- a/examples/todos-server/README.md +++ b/examples/todos-server/README.md @@ -79,7 +79,7 @@ worker.ts transport entry (Cloudflare Workers): the hosted demo — createMcp fetch behind a per-visitor Durable Object, plus the landing page (index.html) at / todos.ts the application: state, tools, resources, prompts, subscriptions — every feature above. createTodosApp() gives a host its own board: a buildServer factory, snapshot/restore for - persistence, and forwardServerEvent for hosts that pin long-lived instances to a bus + persistence, and subscribeInstance for hosts that pin long-lived instances to a bus ``` ## Live board view (Cloudflare Workers deployment) diff --git a/examples/todos-server/board.client.js b/examples/todos-server/board.client.js new file mode 100644 index 0000000000..b01213de8a --- /dev/null +++ b/examples/todos-server/board.client.js @@ -0,0 +1,73 @@ +// @ts-check +// The live board page's script, shipped as its own Text module so the repo's +// lint/format gates see it (an inline diff --git a/examples/todos-server/e2e/verify.ts b/examples/todos-server/e2e/verify.ts new file mode 100644 index 0000000000..65378c2fa9 --- /dev/null +++ b/examples/todos-server/e2e/verify.ts @@ -0,0 +1,293 @@ +/** + * Self-verifying probe suite for a running todos-server worker (wrangler dev or a + * live deployment). Covers what the unit-less worker cannot: the OAuth dance, + * the tier-security invariants (session ids are never credentials), the live + * board view, and the pages. + * + * pnpm --filter @mcp-examples/todos-server verify # against http://127.0.0.1:8850 + * pnpm --filter @mcp-examples/todos-server verify -- --base https://… # against a deployment + * + * Consent handling auto-detects: a 302 from /authorize means TODOS_AUTO_CONSENT + * is set (dev); a 200 renders the real consent form, which the suite completes + * with the double-submit nonce like a browser would. + */ +import { check } from '@mcp-examples/shared'; + +const baseIndex = process.argv.indexOf('--base'); +const BASE = baseIndex === -1 ? 'http://127.0.0.1:8850' : (process.argv[baseIndex + 1] ?? ''); +// workers.dev bot protection 403s default non-browser UAs; real MCP clients are unaffected. +const UA = { 'user-agent': 'todos-e2e-verify/1.0' }; +const ENVELOPE = { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'verify', version: '1.0' }, + 'io.modelcontextprotocol/clientCapabilities': {} +}; + +function b64url(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64url'); +} + +async function sha256(text: string): Promise { + return b64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))); +} + +interface Grant { + token: string; + clientId: string; + viewerCookie?: string; +} + +/** The full authorization-code dance: register, authorize (either consent mode), exchange. */ +async function dance(): Promise { + const registerResponse = await fetch(`${BASE}/oauth/register`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...UA }, + body: JSON.stringify({ + client_name: 'e2e-verify', + redirect_uris: ['http://127.0.0.1:9999/callback'], + grant_types: ['authorization_code'], + response_types: ['code'], + token_endpoint_auth_method: 'none' + }) + }); + const registered = (await registerResponse.json()) as { client_id: string }; + + const verifier = b64url(crypto.getRandomValues(new Uint8Array(32))); + const query = new URLSearchParams({ + response_type: 'code', + client_id: registered.client_id, + redirect_uri: 'http://127.0.0.1:9999/callback', + scope: 'todos', + state: 'verify-state', + code_challenge: await sha256(verifier), + code_challenge_method: 'S256' + }); + let authorize = await fetch(`${BASE}/authorize?${query.toString()}`, { redirect: 'manual', headers: UA }); + if (authorize.status === 200) { + // Real consent page: complete it the way a browser would. + const page = await authorize.text(); + const nonce = /name="nonce" value="([^"]+)"/.exec(page)?.[1] ?? ''; + const action = (/action="([^"]+)"/.exec(page)?.[1] ?? '').replaceAll('&', '&'); + const consentCookie = (authorize.headers.get('set-cookie') ?? '').split(';')[0]; + check.ok(nonce.length > 0); + authorize = await fetch(`${BASE}${action}`, { + method: 'POST', + redirect: 'manual', + headers: { 'content-type': 'application/x-www-form-urlencoded', cookie: consentCookie, ...UA }, + body: new URLSearchParams({ nonce }).toString() + }); + } + check.equal(authorize.status, 302); + const viewerCookie = (authorize.headers.get('set-cookie') ?? '').split(';')[0] || undefined; + const location = new URL(authorize.headers.get('location') ?? ''); + check.equal(location.searchParams.get('state'), 'verify-state'); + const code = location.searchParams.get('code') ?? ''; + + const tokenResponse = await fetch(`${BASE}/oauth/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', ...UA }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + client_id: registered.client_id, + redirect_uri: 'http://127.0.0.1:9999/callback', + code_verifier: verifier + }).toString() + }); + const token = (await tokenResponse.json()) as { access_token: string }; + check.ok(token.access_token.length > 0); + return { token: token.access_token, clientId: registered.client_id, viewerCookie }; +} + +async function mcp( + path: string, + method: string, + name: string | undefined, + args: unknown, + headers: Record +): Promise { + const params = + name === undefined + ? { _meta: ENVELOPE } + : method === 'tools/call' + ? { name, arguments: args, _meta: ENVELOPE } + : { _meta: ENVELOPE }; + return fetch(`${BASE}${path}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': method, + ...(name === undefined ? {} : { 'mcp-name': name }), + ...UA, + ...headers + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) + }); +} + +async function toolText(response: Response): Promise { + const raw = await response.text(); + for (const line of raw.split('\n')) { + if (line.startsWith('data: ')) return JSON.stringify(JSON.parse(line.slice(6))); + } + return raw; +} + +/** Read SSE frames from a stream until the predicate is satisfied or the timeout hits. */ +async function readSse( + path: string, + headers: Record, + until: (frames: string[]) => boolean, + timeoutMs = 12_000 +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const frames: string[] = []; + try { + const response = await fetch(`${BASE}${path}`, { headers: { ...UA, ...headers }, signal: controller.signal }); + check.equal(response.status, 200); + const reader = response.body?.getReader(); + check.ok(reader !== undefined); + const decoder = new TextDecoder(); + let buffer = ''; + for (;;) { + const { value, done } = await reader!.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let index: number; + while ((index = buffer.indexOf('\n\n')) !== -1) { + frames.push(buffer.slice(0, index)); + buffer = buffer.slice(index + 2); + } + if (until(frames)) return frames; + } + } catch (error) { + if (!(error instanceof Error && error.name === 'AbortError')) throw error; + } finally { + clearTimeout(timer); + controller.abort(); + } + return frames; +} + +// --------------------------------------------------------------------------- + +console.error(`[verify] target: ${BASE}`); + +// 1. Discovery documents are spec-shaped. +const asResponse = await fetch(`${BASE}/.well-known/oauth-authorization-server`, { headers: UA }); +const asMetadata = (await asResponse.json()) as Record; +check.ok((asMetadata.code_challenge_methods_supported as string[]).includes('S256')); +check.equal(asMetadata.client_id_metadata_document_supported, true); +const prmResponse = await fetch(`${BASE}/.well-known/oauth-protected-resource/oauth/mcp`, { headers: UA }); +const prm = (await prmResponse.json()) as Record; +check.equal(prm.resource, `${BASE}/oauth/mcp`); +console.error('[verify] 1. discovery ok'); + +// 2. Unauthenticated → 401 with a resource_metadata challenge. +const unauth = await mcp('/oauth/mcp', 'tools/list', undefined, {}, {}); +check.equal(unauth.status, 401); +check.match(unauth.headers.get('www-authenticate') ?? '', /resource_metadata=/); +console.error('[verify] 2. challenge ok'); + +// 3. Two grants; boards are isolated; whoami reports the grant. +const grantA = await dance(); +const grantB = await dance(); +const whoami = await toolText(await mcp('/oauth/mcp', 'tools/call', 'whoami', {}, { authorization: `Bearer ${grantA.token}` })); +check.match(whoami, /Authenticated via OAuth/); +await mcp('/oauth/mcp', 'tools/call', 'add_task', { title: 'canary-A' }, { authorization: `Bearer ${grantA.token}` }); +const listB = await toolText(await mcp('/oauth/mcp', 'tools/call', 'list_tasks', {}, { authorization: `Bearer ${grantB.token}` })); +check.ok(!listB.includes('canary-A')); +console.error('[verify] 3. grants + isolation ok'); + +// 4. Tier security: a session id is never a credential. +const init = await fetch(`${BASE}/oauth/mcp`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + authorization: `Bearer ${grantA.token}`, + ...UA + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'verify', version: '1.0' } } + }) +}); +const sessionId = init.headers.get('mcp-session-id') ?? ''; +check.ok(sessionId.length > 0); +// Session requests speak the session's negotiated protocol (2025-06-18): a +// 2026-envelope request on a legacy session is correctly refused. +const onSession = (path: string, headers: Record): Promise => + fetch(`${BASE}${path}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId, + ...UA, + ...headers + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }) + }); +const bare = await onSession('/mcp', {}); +check.equal(bare.status, 401); +const crossGrant = await onSession('/oauth/mcp', { authorization: `Bearer ${grantB.token}` }); +check.equal(crossGrant.status, 404); +const legit = await onSession('/oauth/mcp', { authorization: `Bearer ${grantA.token}` }); +check.equal(legit.status, 200); +const viewLeak = await readSse('/board/events', { 'mcp-session-id': sessionId }, frames => frames.some(f => f.startsWith('data: ')), 6000); +check.ok(!viewLeak.join('').includes('canary-A')); +console.error('[verify] 4. tier security ok'); + +// 5. Live board view: named board streams its own changes; identity frame is present. +const boardId = `verify-${Math.random().toString(36).slice(2, 10)}`; +// eslint-disable-next-line unicorn/prefer-top-level-await -- deliberately concurrent: the watcher must be listening before the mutation +const watch = readSse(`/board/events?b=${boardId}`, {}, frames => frames.filter(f => f.startsWith('data: ')).length >= 2); +await new Promise(resolve => setTimeout(resolve, 1000)); +await mcp('/mcp', 'tools/call', 'add_task', { title: 'seen-live' }, { 'x-todos-board': boardId }); +const frames = await watch; +check.ok(frames.some(f => f.includes('"mode":"named"'))); +check.ok(frames.some(f => f.includes('seen-live'))); +console.error('[verify] 5. board view ok'); + +// 6. Viewer cookie from consent resolves to the grant board (skipped without a cookie — +// the auto-consent 302 carries it too, so it is present in both modes). +if (grantA.viewerCookie) { + const viewer = await readSse('/board/events', { cookie: grantA.viewerCookie }, frames => frames.some(f => f.includes('"mode"')), 6000); + check.ok(viewer.some(f => f.includes('"mode":"oauth"'))); + console.error('[verify] 6. viewer cookie ok'); +} else { + console.error('[verify] 6. viewer cookie skipped (no cookie in this consent mode)'); +} + +// 7. Pages serve and the board script parses. +const landing = await fetch(`${BASE}/`, { headers: UA }); +check.equal(landing.status, 200); +const board = await fetch(`${BASE}/board`, { headers: UA }); +const boardPage = await board.text(); +check.equal(board.status, 200); +const script = /