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/.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/docs/.vitepress/nav.ts b/docs/.vitepress/nav.ts index 02ab4d2ff6..e86af8d778 100644 --- a/docs/.vitepress/nav.ts +++ b/docs/.vitepress/nav.ts @@ -42,6 +42,7 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [ { text: 'Web-standard runtimes', link: '/serving/web-standard' }, { text: 'Sessions, state, scaling', link: '/serving/sessions-state-scaling' }, { text: 'Authorization', link: '/serving/authorization' }, + { text: 'External authorization servers', link: '/serving/external-authorization-servers' }, { text: 'Legacy clients', link: '/serving/legacy-clients' } ] }, diff --git a/docs/serving/authorization.md b/docs/serving/authorization.md index 7a0389c77c..dfe641f612 100644 --- a/docs/serving/authorization.md +++ b/docs/serving/authorization.md @@ -5,7 +5,7 @@ description: 'Require a bearer token on a server you run: verification, protecte # Require authorization -Protecting a server you run → this page. Signing a user in from a client you build → [Authenticate a user with OAuth](../clients/oauth.md). No user present → [Authenticate without a user](../clients/machine-auth.md). +Protecting a server you run → this page. Where the Authorization Server itself comes from → [Bring your own Authorization Server](./external-authorization-servers.md). Signing a user in from a client you build → [Authenticate a user with OAuth](../clients/oauth.md). No user present → [Authenticate without a user](../clients/machine-auth.md). ## Require a bearer token @@ -40,7 +40,7 @@ app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); A request with a missing, malformed, or expired token gets `401` with the OAuth error code `invalid_token`. A valid token missing one of `requiredScopes` gets `403` with `insufficient_scope`. Both responses carry a `WWW-Authenticate: Bearer …` challenge whose `resource_metadata` parameter is the URL you passed — that challenge is what starts a client's OAuth flow. ::: info Coming from v1? -The Authorization Server helpers (`mcpAuthRouter`, `ProxyOAuthServerProvider`, …) are frozen in `@modelcontextprotocol/server-legacy/auth`. Use a dedicated identity provider for new servers; this page only covers the resource-server half. +The Authorization Server helpers (`mcpAuthRouter`, `ProxyOAuthServerProvider`, …) are frozen in `@modelcontextprotocol/server-legacy/auth`. Use a dedicated identity provider for new servers — [Bring your own Authorization Server](./external-authorization-servers.md) covers that half; this page covers the resource-server half. ::: ## Require a bearer token on a web-standard host diff --git a/docs/serving/external-authorization-servers.md b/docs/serving/external-authorization-servers.md new file mode 100644 index 0000000000..b3a88b7928 --- /dev/null +++ b/docs/serving/external-authorization-servers.md @@ -0,0 +1,76 @@ +--- +shape: how-to +description: Integrate an MCP server with an Authorization Server you bring — a platform provider, an auth framework, or your IdP. +--- + +# Bring your own Authorization Server + +Protecting a server with a token gate → [Require authorization](./authorization.md). Signing a user in from a client → [Authenticate a user with OAuth](../clients/oauth.md). This page covers the other half of the deployment: where the Authorization Server itself comes from, and how the SDK connects to it. + +The SDK ships the Resource Server pieces only — verification, challenges, discovery — and no Authorization Server, deliberately: token issuance, consent, and client registration belong to dedicated systems. The AS you bring is typically your platform's (Cloudflare's [`workers-oauth-provider`](https://github.com/cloudflare/workers-oauth-provider)), an auth framework's (better-auth, in the [`oauth` example](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/oauth)), or your organization's IdP. Two integration styles cover all of them. + +## Style A: the Authorization Server fronts your handler + +The AS wraps your deployment and verifies every API request before your code runs — `createMcpHandler` performs no verification of its own, so the integration is one option: map the identity the AS hands you into an `AuthInfo` and pass it in. + +```ts source="../../examples/guides/serving/external-authorization-servers.examples.ts#styleA_injectAuthInfo" +// The fronting provider verified the token and hands your handler the identity +// it stored at grant time (workers-oauth-provider: `ctx.props`). Map it. +interface GrantProps { + clientId: string; + scopes: string[]; +} + +async function serveVerified(request: Request, props: GrantProps): Promise { + const authInfo: AuthInfo = { + token: request.headers.get('authorization')?.replace(/^Bearer /i, '') ?? '', + clientId: props.clientId, + scopes: props.scopes + // No expiresAt: the provider enforces validity on every request. + }; + return handler.fetch(request, { authInfo }); +} +``` + +Every tool and resource handler now reads the identity as `ctx.http.authInfo`, and token expiry and revocation are enforced by the provider on each request — your code never sees an invalid token. One contract to know: fronting providers typically hand your handler only what was stored when the grant was approved, so embed everything `AuthInfo` needs (client id, scopes) into the grant at consent time. + +::: info Running reference +The [`todos-server` example](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/todos-server) deploys this style on Cloudflare Workers: `workers-oauth-provider` owns the endpoints, both discovery documents, dynamic client registration and Client ID Metadata Documents, and KV-backed grants — the app supplies a consent step and the `propsToAuthInfo` mapping (`oauth.ts`), and serves anonymous and token-authorized tiers side by side. +::: + +## Style B: the SDK verifies tokens from an external AS + +When the AS is elsewhere — an IdP issuing JWTs, or any issuer reachable for introspection — your server verifies each request itself: implement `OAuthTokenVerifier` against the issuer and put `requireBearerAuth` in front of the handler. + +```ts source="../../examples/guides/serving/external-authorization-servers.examples.ts#styleB_externalVerifier" +// The AS is external (an IdP issuing JWTs): verify each request yourself. +const verifier: OAuthTokenVerifier = { + async verifyAccessToken(token): Promise { + const payload = await verifyJwtAgainstIssuer(token).catch(() => { + throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token'); + }); + return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp }; + } +}; +const gate = requireBearerAuth({ verifier, requiredScopes: ['mcp'] }); + +async function fetchHandler(request: Request): Promise { + const auth = await gate(request); + if (auth instanceof Response) return auth; + return handler.fetch(request, { authInfo: auth }); +} +``` + +A request with a missing, malformed, or expired token gets the `401` challenge; a valid one reaches handlers with `ctx.http.authInfo` populated from your verifier. [Require authorization](./authorization.md) covers the rest of this style's surface: the challenge contents, publishing protected resource metadata that names your external issuer, and per-tool scopes. + +## Choosing a style + +Style A fits when the AS can own your HTTP edge — a platform provider wrapping the worker, or an auth framework mounted in the same app. Style B fits when the AS is a remote system and your server is the edge: nothing fronts you, so you verify. They compose with the same application code either way — the factory receives `authInfo` identically, so switching styles later does not touch your tools. + +## Recap + +- The SDK is Resource-Server-only by design: bring the Authorization Server from your platform, framework, or IdP. +- Style A: the AS fronts you and verifies; inject its identity with `handler.fetch(request, { authInfo })`. +- Style B: the AS is external; verify per request with `OAuthTokenVerifier` + `requireBearerAuth`. +- Embed what `AuthInfo` needs into the grant at consent time — fronting providers replay only what was stored. +- The `todos-server` example runs Style A live; the `oauth` example runs an in-process better-auth AS. 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/docs/serving/web-standard.md b/docs/serving/web-standard.md index 87d7439eb3..1f8bf18662 100644 --- a/docs/serving/web-standard.md +++ b/docs/serving/web-standard.md @@ -1,6 +1,7 @@ --- shape: how-to --- + # Serve on web-standard runtimes ```sh @@ -28,6 +29,16 @@ export default handler; The deployed worker answers MCP requests on every path, with no Node adapter and no body middleware. The factory runs once per request, so a fresh `McpServer` serves every call: [Serve over HTTP](./http.md#understand-the-per-request-factory) covers that model. +::: warning Cloudflare Workers: cancellation needs a compatibility flag +On Workers, `request.signal` never fires unless the deployment sets the `enable_request_signal` compatibility flag — client disconnects are invisible to the isolate, so 2026-07-28 cancellation (a client cancels by closing the request's response stream) silently does nothing and long-running tools plough on. Add the flag in `wrangler.toml`: + +```toml +compatibility_flags = ["enable_request_signal"] +``` + +With it set, the SDK's per-request serving aborts the handler's `ctx.mcpReq.signal` when the client goes away — including across a Durable Object hop, as long as the forwarded `Request` derives from the original. Node deployments need nothing: the adapter wires socket close to the same signal. +::: + ## Protect against DNS rebinding The handler performs no `Host` or `Origin` validation, and on a bare fetch-native runtime there is no app factory to arm it for you. Put the framework-agnostic response helpers in front of `fetch`. diff --git a/examples/cli-client/README.md b/examples/cli-client/README.md index c75d086e12..bf2fc1caef 100644 --- a/examples/cli-client/README.md +++ b/examples/cli-client/README.md @@ -48,7 +48,7 @@ pnpm --filter @mcp-examples/todos-server start:http ANTHROPIC_API_KEY=sk-… pnpm --filter @mcp-examples/cli-client start -- --server http://127.0.0.1:3000/mcp --provider anthropic ``` -The status line shows what was negotiated — `connected to "todos" (2026-07-28, 8 tools, 2 resources, 2 prompts)`. Add `--legacy` in terminal B to force the 2025-era handshake against the same server and watch the legacy arms of every feature run instead (`connected to "todos" (2025-11-25, …)`). To hold the connection to one exact revision, use `--protocol-version 2025-06-18` (or any supported revision) — the connection fails rather than settle on anything else. +The status line shows what was negotiated — `connected to "todos" (2026-07-28, 9 tools, 2 resources, 2 prompts)`. Add `--legacy` in terminal B to force the 2025-era handshake against the same server and watch the legacy arms of every feature run instead (`connected to "todos" (2025-11-25, …)`). To hold the connection to one exact revision, use `--protocol-version 2025-06-18` (or any supported revision) — the connection fails rather than settle on anything else. A tour that touches everything, in one sitting: diff --git a/examples/cli-client/host/host.ts b/examples/cli-client/host/host.ts index 49165c9554..c77c15b1f5 100644 --- a/examples/cli-client/host/host.ts +++ b/examples/cli-client/host/host.ts @@ -158,6 +158,13 @@ export class McpHost { this.ui.status( `connected to "${name}" (${server.protocolVersion}, ${server.tools.length} tools, ${server.resources.length + server.resourceTemplates.length} resources, ${server.prompts.length} prompts)` ); + // Instructions are the server's own onboarding text — surface them so the + // human sees links and hints without having to ask the model for them. + // note() wraps; status() clips to one line and would swallow long URLs. + const instructions = server.client.getInstructions(); + if (instructions) { + this.ui.note(`"${name}" says: ${instructions.length > 600 ? `${instructions.slice(0, 600)}…` : instructions}`); + } } catch (error) { this.ui.status(`failed to connect to "${name}": ${error instanceof Error ? error.message : String(error)}`); } diff --git a/examples/cli-client/host/ui.ts b/examples/cli-client/host/ui.ts index 4f925f9da7..b245feb661 100644 --- a/examples/cli-client/host/ui.ts +++ b/examples/cli-client/host/ui.ts @@ -162,10 +162,12 @@ export class ReadlineUI implements HostUI { } note(text: string): void { - // Things that became part of the conversation but aren't prose (attached resources). + // Things that became part of the conversation but aren't prose (attached + // resources, server onboarding text). Unlike status chatter these may carry + // links the user needs whole, so they soft-wrap instead of clipping. this.clearSpinnerLine(); this.closeAttentionBlock(); - console.log(paint('36', this.clipToWidth(` ▍ ${stripAnsi(text)}`))); + console.log(paint('36', ` ▍ ${stripAnsi(text)}`)); this.afterMeta = true; } 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/guides/serving/external-authorization-servers.examples.ts b/examples/guides/serving/external-authorization-servers.examples.ts new file mode 100644 index 0000000000..6769483a1f --- /dev/null +++ b/examples/guides/serving/external-authorization-servers.examples.ts @@ -0,0 +1,60 @@ +// docs: typecheck-only +/** + * Companion example for `docs/serving/external-authorization-servers.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). Both styles need a live Authorization + * Server to exercise, so this file only typechecks: + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * + * @module + */ +import type { AuthInfo, McpHttpHandler, OAuthTokenVerifier } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer, OAuthError, OAuthErrorCode, requireBearerAuth } from '@modelcontextprotocol/server'; + +declare function buildServer(): McpServer; +declare function verifyJwtAgainstIssuer(token: string): Promise<{ sub: string; scopes: string[]; exp: number }>; + +const handler: McpHttpHandler = createMcpHandler(buildServer); + +//#region styleA_injectAuthInfo +// The fronting provider verified the token and hands your handler the identity +// it stored at grant time (workers-oauth-provider: `ctx.props`). Map it. +interface GrantProps { + clientId: string; + scopes: string[]; +} + +async function serveVerified(request: Request, props: GrantProps): Promise { + const authInfo: AuthInfo = { + token: request.headers.get('authorization')?.replace(/^Bearer /i, '') ?? '', + clientId: props.clientId, + scopes: props.scopes + // No expiresAt: the provider enforces validity on every request. + }; + return handler.fetch(request, { authInfo }); +} +//#endregion styleA_injectAuthInfo + +//#region styleB_externalVerifier +// The AS is external (an IdP issuing JWTs): verify each request yourself. +const verifier: OAuthTokenVerifier = { + async verifyAccessToken(token): Promise { + const payload = await verifyJwtAgainstIssuer(token).catch(() => { + throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token'); + }); + return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp }; + } +}; +const gate = requireBearerAuth({ verifier, requiredScopes: ['mcp'] }); + +async function fetchHandler(request: Request): Promise { + const auth = await gate(request); + if (auth instanceof Response) return auth; + return handler.fetch(request, { authInfo: auth }); +} +//#endregion styleB_externalVerifier + +void serveVerified; +void fetchHandler; 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..872b7f9aeb 100644 --- a/examples/todos-server/README.md +++ b/examples/todos-server/README.md @@ -31,7 +31,7 @@ pnpm --filter @mcp-examples/cli-client start -- --server http://127.0.0.1:3000/m pnpm --filter @mcp-examples/cli-client start -- --server http://127.0.0.1:3000/mcp --legacy ``` -The client's status line shows what was negotiated: `connected to "todos" (2026-07-28, 8 tools, …)` vs `(2025-11-25, …)`. +The client's status line shows what was negotiated: `connected to "todos" (2026-07-28, 9 tools, …)` vs `(2025-11-25, …)`. You don't need the HTTP step for a quick look — running `cli-client` with no arguments spawns this server over stdio automatically. @@ -59,10 +59,11 @@ Any other `mcpServers`-style host can spawn it too: | Subscriptions | the board | `resources/subscribe`/`unsubscribe` handlers for 2025-era clients; `subscriptions/listen` routing for 2026-07-28; every mutation notifies | | list_changed | every mutation | resource list + resource updated notifications, delivered correctly over stdio and per-request HTTP | | Prompts + completions | `plan-my-day`, `seed-board` | `completable()` argument values (project names, themes) wired to `completion/complete` | +| OAuth tiers | `whoami` (todos.ts) | Reports `ctx.authInfo`: the verified grant (client + scopes) or the anonymous tier, plus where to watch the board live. | 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 +75,98 @@ 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 — one door to a per-visitor board + Durable Object (serveBoard), the OAuth provider wrapper, and the pages +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 subscribeInstance for hosts that pin long-lived instances to a bus +oauth.ts the OAuth glue: propsToAuthInfo (grant props → AuthInfo), the no-account consent page, + and the viewer-session lifecycle for the live board view +board.html the /board live view page; its script ships as board.client.js so the lint gates see it +index.html the landing page at / +e2e/verify.ts probes a running worker: the dance, tier security, the live view, the pages ``` -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. +## 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 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. + +## Verifying a deployment + +`e2e/verify.ts` probes a running worker end to end — the OAuth dance (both consent +modes), the tier-security invariants (a session id is never a credential), board +isolation between grants, the live view's SSE stream, the pages (including that the +board script parses), and the authorization error paths: + +```sh +pnpm --filter @mcp-examples/todos-server verify # wrangler dev on :8850 +pnpm --filter @mcp-examples/todos-server verify -- --base https://your.deploy # a live deployment +``` + +Section 9 needs `enable_request_signal` in `wrangler.toml` (see the comment there): +without it Workers never surfaces client disconnects and cancellation is silently dead. + +Stays manual on purpose: the real-browser consent click, a real client's OAuth dance +(Claude Code / cli-client), and anything involving a third-party platform quirk +(workers.dev bot protection 403s non-browser user agents; deploys propagate for +~30s — re-run rather than chase ghosts). + +## 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, +`/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:8850/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/board.client.js b/examples/todos-server/board.client.js new file mode 100644 index 0000000000..4ed73717c9 --- /dev/null +++ b/examples/todos-server/board.client.js @@ -0,0 +1,95 @@ +// @ts-check +/* global document, location, EventSource, URLSearchParams, setTimeout */ +// The live board page's script, shipped as its own Text module so the repo's +// lint/format gates see it (an inline + + diff --git a/examples/todos-server/e2e/verify.ts b/examples/todos-server/e2e/verify.ts new file mode 100644 index 0000000000..6ee69143c9 --- /dev/null +++ b/examples/todos-server/e2e/verify.ts @@ -0,0 +1,341 @@ +/** + * Self-verifying probe suite for a running todos-server worker (wrangler dev or a + * live deployment). Covers what the unit-less worker cannot: the OAuth dance, + * the tier-security invariants (session ids are never credentials), the live + * board view, and the pages. + * + * pnpm --filter @mcp-examples/todos-server verify # against http://127.0.0.1:8850 + * pnpm --filter @mcp-examples/todos-server verify -- --base https://… # against a deployment + * + * Consent handling auto-detects: a 302 from /authorize means TODOS_AUTO_CONSENT + * is set (dev); a 200 renders the real consent form, which the suite completes + * with the double-submit nonce like a browser would. + */ +import { check } from '@mcp-examples/shared'; + +const baseIndex = process.argv.indexOf('--base'); +const BASE = baseIndex === -1 ? 'http://127.0.0.1:8850' : (process.argv[baseIndex + 1] ?? ''); +// workers.dev bot protection 403s default non-browser UAs; real MCP clients are unaffected. +const UA = { 'user-agent': 'todos-e2e-verify/1.0' }; +const ENVELOPE = { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'verify', version: '1.0' }, + 'io.modelcontextprotocol/clientCapabilities': {} +}; + +function b64url(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64url'); +} + +async function sha256(text: string): Promise { + return b64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))); +} + +interface Grant { + token: string; + clientId: string; + viewerCookie?: string; +} + +/** The full authorization-code dance: register, authorize (either consent mode), exchange. */ +async function dance(): Promise { + const registerResponse = await fetch(`${BASE}/oauth/register`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...UA }, + body: JSON.stringify({ + client_name: 'e2e-verify', + redirect_uris: ['http://127.0.0.1:9999/callback'], + grant_types: ['authorization_code'], + response_types: ['code'], + token_endpoint_auth_method: 'none' + }) + }); + const registered = (await registerResponse.json()) as { client_id: string }; + + const verifier = b64url(crypto.getRandomValues(new Uint8Array(32))); + const query = new URLSearchParams({ + response_type: 'code', + client_id: registered.client_id, + redirect_uri: 'http://127.0.0.1:9999/callback', + scope: 'todos', + state: 'verify-state', + code_challenge: await sha256(verifier), + code_challenge_method: 'S256' + }); + let authorize = await fetch(`${BASE}/authorize?${query.toString()}`, { redirect: 'manual', headers: UA }); + if (authorize.status === 200) { + // Real consent page: complete it the way a browser would. + const page = await authorize.text(); + const nonce = /name="nonce" value="([^"]+)"/.exec(page)?.[1] ?? ''; + const action = (/action="([^"]+)"/.exec(page)?.[1] ?? '').replaceAll('&', '&'); + const consentCookie = (authorize.headers.get('set-cookie') ?? '').split(';')[0]; + check.ok(nonce.length > 0); + authorize = await fetch(`${BASE}${action}`, { + method: 'POST', + redirect: 'manual', + headers: { 'content-type': 'application/x-www-form-urlencoded', cookie: consentCookie, ...UA }, + body: new URLSearchParams({ nonce }).toString() + }); + } + check.equal(authorize.status, 302); + const viewerCookie = (authorize.headers.get('set-cookie') ?? '').split(';')[0] || undefined; + const location = new URL(authorize.headers.get('location') ?? ''); + check.equal(location.searchParams.get('state'), 'verify-state'); + const code = location.searchParams.get('code') ?? ''; + + const tokenResponse = await fetch(`${BASE}/oauth/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', ...UA }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + client_id: registered.client_id, + redirect_uri: 'http://127.0.0.1:9999/callback', + code_verifier: verifier + }).toString() + }); + const token = (await tokenResponse.json()) as { access_token: string }; + check.ok(token.access_token.length > 0); + return { token: token.access_token, clientId: registered.client_id, viewerCookie }; +} + +async function mcp( + path: string, + method: string, + name: string | undefined, + args: unknown, + headers: Record +): Promise { + const params = + name === undefined + ? { _meta: ENVELOPE } + : method === 'tools/call' + ? { name, arguments: args, _meta: ENVELOPE } + : { _meta: ENVELOPE }; + return fetch(`${BASE}${path}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': method, + ...(name === undefined ? {} : { 'mcp-name': name }), + ...UA, + ...headers + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) + }); +} + +async function toolText(response: Response): Promise { + const raw = await response.text(); + for (const line of raw.split('\n')) { + if (line.startsWith('data: ')) return JSON.stringify(JSON.parse(line.slice(6))); + } + return raw; +} + +/** Read SSE frames from a stream until the predicate is satisfied or the timeout hits. */ +async function readSse( + path: string, + headers: Record, + until: (frames: string[]) => boolean, + timeoutMs = 12_000 +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const frames: string[] = []; + try { + const response = await fetch(`${BASE}${path}`, { headers: { ...UA, ...headers }, signal: controller.signal }); + check.equal(response.status, 200); + const reader = response.body?.getReader(); + check.ok(reader !== undefined); + const decoder = new TextDecoder(); + let buffer = ''; + for (;;) { + const { value, done } = await reader!.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let index: number; + while ((index = buffer.indexOf('\n\n')) !== -1) { + frames.push(buffer.slice(0, index)); + buffer = buffer.slice(index + 2); + } + if (until(frames)) return frames; + } + } catch (error) { + if (!(error instanceof Error && error.name === 'AbortError')) throw error; + } finally { + clearTimeout(timer); + controller.abort(); + } + return frames; +} + +// --------------------------------------------------------------------------- + +console.error(`[verify] target: ${BASE}`); + +// 1. Discovery documents are spec-shaped. +const asResponse = await fetch(`${BASE}/.well-known/oauth-authorization-server`, { headers: UA }); +const asMetadata = (await asResponse.json()) as Record; +check.ok((asMetadata.code_challenge_methods_supported as string[]).includes('S256')); +check.equal(asMetadata.client_id_metadata_document_supported, true); +const prmResponse = await fetch(`${BASE}/.well-known/oauth-protected-resource/oauth/mcp`, { headers: UA }); +const prm = (await prmResponse.json()) as Record; +check.equal(prm.resource, `${BASE}/oauth/mcp`); +console.error('[verify] 1. discovery ok'); + +// 2. Unauthenticated → 401 with a resource_metadata challenge. +const unauth = await mcp('/oauth/mcp', 'tools/list', undefined, {}, {}); +check.equal(unauth.status, 401); +check.match(unauth.headers.get('www-authenticate') ?? '', /resource_metadata=/); +console.error('[verify] 2. challenge ok'); + +// 3. Two grants; boards are isolated; whoami reports the grant. +const grantA = await dance(); +const grantB = await dance(); +const whoami = await toolText(await mcp('/oauth/mcp', 'tools/call', 'whoami', {}, { authorization: `Bearer ${grantA.token}` })); +check.match(whoami, /Authenticated via OAuth/); +const canaryAdd = await toolText( + await mcp('/oauth/mcp', 'tools/call', 'add_task', { title: 'canary-A' }, { authorization: `Bearer ${grantA.token}` }) +); +check.match(canaryAdd, /canary-A/); +const listB = await toolText(await mcp('/oauth/mcp', 'tools/call', 'list_tasks', {}, { authorization: `Bearer ${grantB.token}` })); +check.ok(!listB.includes('canary-A')); +console.error('[verify] 3. grants + isolation ok'); + +// 4. Tier security: a session id is never a credential. +const init = await fetch(`${BASE}/oauth/mcp`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + authorization: `Bearer ${grantA.token}`, + ...UA + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'verify', version: '1.0' } } + }) +}); +const sessionId = init.headers.get('mcp-session-id') ?? ''; +check.ok(sessionId.length > 0); +// Session requests speak the session's negotiated protocol (2025-06-18): a +// 2026-envelope request on a legacy session is correctly refused. +const onSession = (path: string, headers: Record): Promise => + fetch(`${BASE}${path}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId, + ...UA, + ...headers + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }) + }); +const bare = await onSession('/mcp', {}); +check.equal(bare.status, 401); +const crossGrant = await onSession('/oauth/mcp', { authorization: `Bearer ${grantB.token}` }); +check.equal(crossGrant.status, 404); +const legit = await onSession('/oauth/mcp', { authorization: `Bearer ${grantA.token}` }); +check.equal(legit.status, 200); +const viewLeak = await readSse('/board/events', { 'mcp-session-id': sessionId }, frames => frames.some(f => f.startsWith('data: ')), 6000); +// The absence of the canary only proves anything over a stream that produced a snapshot. +check.ok(viewLeak.some(f => f.startsWith('data: '))); +check.ok(!viewLeak.join('').includes('canary-A')); +console.error('[verify] 4. tier security ok'); + +// 5. Live board view: named board streams its own changes; identity frame is present. +const boardId = `verify-${Math.random().toString(36).slice(2, 10)}`; +// eslint-disable-next-line unicorn/prefer-top-level-await -- deliberately concurrent: the watcher must be listening before the mutation +const watch = readSse(`/board/events?b=${boardId}`, {}, frames => frames.filter(f => f.startsWith('data: ')).length >= 2); +await new Promise(resolve => setTimeout(resolve, 1000)); +await mcp('/mcp', 'tools/call', 'add_task', { title: 'seen-live' }, { 'x-todos-board': boardId }); +const frames = await watch; +check.ok(frames.some(f => f.includes('"mode":"named"'))); +check.ok(frames.some(f => f.includes('seen-live'))); +console.error('[verify] 5. board view ok'); + +// 6. The approve 302 carries the viewer cookie in both consent modes; it must resolve +// to the grant board. +check.ok(grantA.viewerCookie !== undefined); +const viewer = await readSse( + '/board/events', + { cookie: grantA.viewerCookie ?? '' }, + frames => frames.some(f => f.includes('"mode"')), + 6000 +); +check.ok(viewer.some(f => f.includes('"mode":"oauth"'))); +console.error('[verify] 6. viewer cookie ok'); + +// 7. Pages serve and the board script parses. +const landing = await fetch(`${BASE}/`, { headers: UA }); +check.equal(landing.status, 200); +const board = await fetch(`${BASE}/board`, { headers: UA }); +const boardPage = await board.text(); +check.equal(board.status, 200); +const script = /