feat(examples): deploy todos-server to Cloudflare Workers with OAuth and a live board#2446
feat(examples): deploy todos-server to Cloudflare Workers with OAuth and a live board#2446felixweinberger wants to merge 23 commits into
Conversation
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).
…w on the todos worker @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=<name> 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.
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.
The client is the messenger: the server instructions and the whoami tool now tell connected agents where their user can watch the board update in real time (via the boardViewPath option the worker sets; stdio hosts leave it unset), and the landing page gains a Watch it live section covering both the named-anonymous and OAuth-consent flows.
The stream's first frame is now an info event describing how the board was resolved, and the page renders it: an OAuth viewer sees the client the grant was issued to plus a short board id, a named board says who else can see it, and the address-keyed fallback names itself. The viewer record stores the client name at consent for this.
The server instructions now lead with the full live-view URL (PUBLIC_ORIGIN wrangler var; hosts without one fall back to a relative path), and cli-client surfaces a server's instructions as a note at connect time so the user sees links and hints without asking the model. Notes soft-wrap instead of clipping so URLs arrive whole.
The approve button (now labeled Approve & open live board) opens /board in a new tab from the submit gesture while the original tab completes the OAuth redirect, and the board page nudges one reconnect if its stream resolved before the viewer cookie landed.
…ce killed it The script still assigned to the removed board-name element, so the first statement threw and nothing ran: no identity line, status stuck on connecting. The page script is now a single clean connect() with the consent-race retry folded in.
window.open fires on the submit gesture, before the approve POST sets the new viewer cookie, so the tab could resolve to a previous grant's board. The consent link now carries #claim, and the page defers its first connection under it (on top of the existing no-cookie retry).
A stale session id on /board/events overrode the resolved board name and streamed a private board's snapshots with no token, surviving revocation — the third instance of the session-id-as-credential class. The view branch now strips mcp-session-id before dispatch.
…ier model The tier/identity rules were enforced at nine coordinated sites across five functions, and the coordination kept failing (three session-id-as-credential bypasses, each at a newly added ingress). serveBoard now takes a BoardRoute — mcp traffic with optional verified identity, or the read-only view — and is the only code that touches the internal relay headers, honors or refuses session routing, and binds sessions to the token's own board. TodosApi and the /mcp and /board/events routes each collapse to a route description. The viewer-session contract (cookie name, KV key, record shape, TTL) moves whole into oauth.ts next to its mint; the worker calls resolveViewerBoard. The provider's own OAuthHelpers type replaces the hand-rolled structural copy. The board page script becomes board.client.js — a Text module the lint and format gates actually see (the shipped-dead-script class dies here) — inlined at render. The PUBLIC_ORIGIN var is gone: the board object learns its origin from the first request and boardViewPath accepts a thunk. Duplicate page-render and CORS helpers folded.
Vestiges out: sessionNotFound's dead status parameter, boardEventStream's unreachable null tolerance, waitUntil from the WorkerEntrypoint declaration, the boardViewPath string arm (only the thunk was ever passed), the eslint globals block (a one-line global comment in board.client.js does it), and the double CORS application (one fill-missing pass at the outermost wrapper). The board page's address-fallback retry now fires only in the consent claim flow instead of delaying every ordinary visitor. The verify harness loses its vacuous slack: the canary must land before its absence means anything, the view-leak stream must produce a snapshot, the unknown-client probe accepts only 400, and the viewer-cookie probe is unconditional. wrangler.toml gains the harness's default port — placed after the top-level keys, because the first attempt silently swallowed compatibility_flags into the [dev] table and the harness caught CIMD going dark before the commit did. Prose drift fixed: 9 tools not 8, whoami in the feature table and honest per entry, the Layout block covers all the files, and the REQUEST_STATE_SECRET comments describe the per-board key reality.
The SDK ships Resource Server pieces only, deliberately; this page covers
where the AS comes from and the two integration styles: the AS fronting the
handler (verification outside the SDK, identity injected via
handler.fetch(request, { authInfo }) — the todos-server example runs this
live with workers-oauth-provider) and the SDK verifying tokens from an
external issuer (OAuthTokenVerifier + requireBearerAuth). Type-checked
companion fences; registered in the serving nav.
…ME truth, cross-links
Each task is a bordered row with a non-interactive checkbox for todo/done and self-labeling chips (project · inbox, priority · high) plus the muted task id, replacing the bare bulleted line whose (t8, inbox) suffix explained nothing.
A 2026-era client cancels by closing the request's response stream, and the SDK's per-request transport aborts the in-flight handler when the request signal fires — but on Cloudflare Workers request.signal NEVER fires without the enable_request_signal compatibility flag: disconnects are invisible to the isolate, stream cancel() is never invoked, and long tools run to completion while the client shows cancelled. Verified live in both directions (without the flag: 0/6 tasks spared; with it: the signal fires about 1s after the drop and rides the relayed Request into the board object, 4/6 spared). Node needs nothing — the adapter wires socket close to the same signal, verified against a real FIN. The harness gains section 9 (drop the stream mid work_through_tasks, assert open tasks survive) and the web-standard docs page warns every Workers deployment about the flag.
The worker previously hand-declared minimal structural types for the runtime surface (DO namespace/state/storage, WorkerEntrypoint, KV) to keep the workspace free of the types package — at the cost of four any/unknown casts around the OAuth provider and a shim that drifted (no storage.list). The types package is now a dev dependency scoped to tsconfig.worker.json (its globals must not leak into the Node-flavoured compile, so the worker-only files leave the root examples tsconfig, which typecheck:worker covers). Every cast is gone: TodosApi extends WorkerEntrypoint<Env, TodosGrantProps> with typed ctx.props, the provider takes apiHandler/defaultHandler unchanged (its d.ts imports the same cloudflare:workers types), and the default export satisfies ExportedHandler<Env> with a typed provider.fetch call.
Every sibling SDK (go, csharp, python, java) delivers resources/updated to the mutating session too and tracks no origin anywhere: the notification is an idempotent invalidation hint, not a receipt. Our example instead self-sent in-band AND published to the bus, then needed an eventOrigin WeakMap so the bus would not double-deliver — an identity side-channel that would silently break on any bus that serializes events. Now there is one delivery path: with a bus, publish only (the bus reaches every connection, including the originator); without one, notify the lone connection in-band. Verified: a subscribed legacy session receives exactly one list_changed and one resources/updated per change.
Carries the intent of the upstream examples hardening through this branch's bus-wired handler: createMcpHonoApp's localhost host/origin validation in front, loopback bind, same endpoint.
|
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
| const board = await fetch(`${BASE}/board`, { headers: UA }); | ||
| const boardPage = await board.text(); | ||
| check.equal(board.status, 200); | ||
| const script = /<script>\n?([\s\S]*?)<\/script>/.exec(boardPage)?.[1] ?? ''; |
… client Any CIMD-capable client can use /client-metadata.json as its client_id; the authorize path resolves it like any external document, so the consent page renders for URL-identified clients too.
There was a problem hiding this comment.
@pcarleton this guide to using external-authorization-servers is probably worth checking if it matches your expectations?
| }; | ||
| const gate = requireBearerAuth({ verifier, requiredScopes: ['mcp'] }); | ||
|
|
||
| async function fetchHandler(request: Request): Promise<Response> { |
There was a problem hiding this comment.
do we have a middleware pattern still? this one works well as a middleware, and is a bit awkward to do manually.
|
Closing — taking a different direction with the deployment (will live outside the SDK repo). |
Draft for design review — the auth integration is what wants eyes. Live at https://todos-demo-ts.fweinberger-test.workers.dev.
Deploys the todos example to Workers with
@cloudflare/workers-oauth-provideras the Authorization Server (SDK stays RS-only), serving both protocol eras over anonymous and OAuth tiers, plus a live board view (/board) and an e2e probe harness (e2e/verify.ts, 9 sections, all green against the live deployment). Companion docs page: Bring your own Authorization Server.Auth review focus:
oauth.ts— the whole provider integration (~190 lines): grant props →AuthInfo, consent + nonce, viewer sessions. The token IS the board; no accounts.worker.ts—serveBoard+BoardRoute: all tier/identity rules in one place; a session id is never a credential.wrangler.toml— two load-bearing compat flags (CIMD;enable_request_signalfor 2026 cancellation).No SDK changes on this branch. Adoptions from #2439/#2440 follow if those merge.
Types of changes