Skip to content

feat(examples): deploy todos-server to Cloudflare Workers with OAuth and a live board#2446

Closed
felixweinberger wants to merge 23 commits into
mainfrom
fweinberger/todos-demo-worker
Closed

feat(examples): deploy todos-server to Cloudflare Workers with OAuth and a live board#2446
felixweinberger wants to merge 23 commits into
mainfrom
fweinberger/todos-demo-worker

Conversation

@felixweinberger

Copy link
Copy Markdown
Contributor

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-provider as 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.tsserveBoard + 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_signal for 2026 cancellation).

No SDK changes on this branch. Adoptions from #2439/#2440 follow if those merge.

Types of changes

  • New feature (non-breaking change which adds functionality)
  • Documentation update

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.
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.
@changeset-bot

changeset-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: c3a1900

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jul 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2446

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2446

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2446

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2446

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2446

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2446

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2446

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2446

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2446

commit: c3a1900

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we have a middleware pattern still? this one works well as a middleware, and is a bit awkward to do manually.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Primary OAuth flow here. @pcarleton

@felixweinberger

Copy link
Copy Markdown
Contributor Author

Closing — taking a different direction with the deployment (will live outside the SDK repo).

@felixweinberger felixweinberger deleted the fweinberger/todos-demo-worker branch July 7, 2026 11:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants