Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2ab9fd1
feat(examples): deploy todos-server to Cloudflare Workers
felixweinberger Jul 1, 2026
2b1bbc7
feat(examples): OAuth via workers-oauth-provider and a live board vie…
felixweinberger Jul 2, 2026
ba7b01e
feat(examples): live view of OAuth boards, claimed at consent
felixweinberger Jul 2, 2026
376ce7a
examples: point clients at the live board view
felixweinberger Jul 2, 2026
4fab4f4
examples: the live board says who you are
felixweinberger Jul 2, 2026
722f491
examples: show the live-board link the moment a client connects
felixweinberger Jul 2, 2026
a557141
examples: consent click opens the live board
felixweinberger Jul 2, 2026
e437779
fix(examples): rewrite the board page script; a stale element referen…
felixweinberger Jul 2, 2026
23eaba9
fix(examples): consent-opened board tab waits for the approval to land
felixweinberger Jul 2, 2026
805a124
fix(examples): the board view route never routes by session id
felixweinberger Jul 2, 2026
f29f238
refactor(examples): one door to a board — serveBoard owns the whole t…
felixweinberger Jul 3, 2026
2f69a02
docs(examples): how to verify a deployment
felixweinberger Jul 3, 2026
4b4738b
refactor(examples): iteration-2 deletions and truth fixes
felixweinberger Jul 3, 2026
72f8b76
chore(examples): lockfile entry for the workers-oauth-provider depend…
felixweinberger Jul 3, 2026
f408798
docs(serving): bring your own Authorization Server
felixweinberger Jul 3, 2026
f49dec5
docs: iteration-3 prose fixes — handlers read ctx.http.authInfo, READ…
felixweinberger Jul 3, 2026
7afd80b
examples: render board tasks as minimal cards
felixweinberger Jul 6, 2026
3512aae
fix(examples): cancellation on Workers needs enable_request_signal
felixweinberger Jul 6, 2026
4252d5e
examples: real @cloudflare/workers-types instead of structural shims
felixweinberger Jul 6, 2026
9d7afb4
examples: deliver the echo — drop origin tracking from the bus fan-out
felixweinberger Jul 6, 2026
80909ca
examples: adopt the protected Hono wiring for the todos Node entry
felixweinberger Jul 7, 2026
f6ae355
docs: re-sync web-standard fences after rebase
felixweinberger Jul 7, 2026
c3a1900
examples: serve a Client ID Metadata Document for the demo's own test…
felixweinberger Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,6 @@ results/

# Ignore local lefthook configuration
lefthook-local.yml

# Wrangler local dev cache (examples/todos-server worker entry)
.wrangler/
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
1 change: 1 addition & 0 deletions docs/.vitepress/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
]
},
Expand Down
4 changes: 2 additions & 2 deletions docs/serving/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions docs/serving/external-authorization-servers.md

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?

Original file line number Diff line number Diff line change
@@ -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<Response> {
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<AuthInfo> {
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<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.

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.
65 changes: 65 additions & 0 deletions docs/serving/legacy-clients.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<string, WebStandardStreamableHTTPServerTransport>();

async function serveWithSessions(request: Request): Promise<Response> {
// 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).
Expand All @@ -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`.
3 changes: 3 additions & 0 deletions docs/serving/sessions-state-scaling.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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"
Expand Down
11 changes: 11 additions & 0 deletions docs/serving/web-standard.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
shape: how-to
---

# Serve on web-standard runtimes

```sh
Expand Down Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion examples/cli-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
7 changes: 7 additions & 0 deletions examples/cli-client/host/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`);
}
Expand Down
Loading
Loading