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.
+
+
+ The board is empty — add a task from a connected client.
+ 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…