Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions .changeset/create-mcp-handler-legacy-revision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@modelcontextprotocol/server': minor
---

Revise `createMcpHandler`'s legacy handling (a behavior change to the unreleased entry). The entry now serves 2025-era (non-envelope) traffic **by default** through per-request stateless serving from the same factory — `legacy: 'stateless'` is the default rather than an
opt-in — and the strict, modern-only posture is selected with the new `legacy: 'reject'` value (the earlier alpha's default). The handler-valued `legacy` option (bring-your-own legacy serving) is removed: existing legacy deployments (for example a sessionful streamable
HTTP wiring) keep serving 2025 traffic by routing in user land with the new `isLegacyRequest(request, parsedBody?)` export, which runs the entry's own classification step — it returns `true` only for requests with no per-request `_meta` envelope claim, while malformed or
incomplete modern claims are NOT legacy and must be routed to the modern handler, which answers them with the documented validation errors. The predicate classifies a clone, so the routed request body stays readable. `legacyStatelessFallback` remains exported as a
standalone fetch-shaped handler with the same stateless serving as the default.
10 changes: 5 additions & 5 deletions .changeset/create-mcp-handler.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
---

Add `createMcpHandler(factory, { legacy?, onerror?, responseMode? })`, an HTTP entry point that serves the 2026-07-28 draft revision per request: each envelope-carrying request is classified once, served on a fresh instance from the factory bound to the claimed revision,
and answered with a JSON body or a lazily-upgraded SSE stream. 2025-era serving is opt-in through the `legacy` slot (`'stateless'` for per-request stateless serving via the existing streamable HTTP transport, or any fetch-shaped handler for bring-your-own wiring); without
the slot the endpoint is modern-only and rejects 2025-era requests with the unsupported-protocol-version error naming its supported revisions. The handler exposes a web-standard `fetch(request, { authInfo?, parsedBody? })` face and a duck-typed `node(req, res, parsedBody?)`
face, plus `close()` for tearing down in-flight modern exchanges. Also exported: `legacyStatelessFallback` (the canonical slot value), the `PerRequestHTTPServerTransport` single-exchange transport and the `classifyInboundRequest` classifier for hand-wired compositions, and
the supporting types. `responseMode: 'json'` never streams and drops mid-call notifications (progress, logging and other related messages emitted before the result); listen-class subscription streams are always served over SSE. The entry performs no Origin/Host validation
(use the middleware packages) and no token verification — `authInfo` is pass-through and never derived from request headers.
and answered with a JSON body or a lazily-upgraded SSE stream. 2025-era serving is selected with the `legacy` option (`'stateless'` — the default — for per-request stateless serving via the existing streamable HTTP transport, `'reject'` for a modern-only strict endpoint
that answers 2025-era requests with the unsupported-protocol-version error naming its supported revisions). The handler exposes a web-standard `fetch(request, { authInfo?, parsedBody? })` face and a duck-typed `node(req, res, parsedBody?)`
face, plus `close()` for tearing down in-flight modern exchanges. Also exported: `legacyStatelessFallback` (the same stateless legacy serving as a standalone fetch-shaped handler), the `PerRequestHTTPServerTransport` single-exchange transport and the
`classifyInboundRequest` classifier for hand-wired compositions, and the supporting types. `responseMode: 'json'` never streams and drops mid-call notifications (progress, logging and other related messages emitted before the result); listen-class subscription streams are
always served over SSE. The entry performs no Origin/Host validation (use the middleware packages) and no token verification — `authInfo` is pass-through and never derived from request headers.
10 changes: 10 additions & 0 deletions docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,16 @@ These can require code changes:
- `Server.getClientCapabilities()`, `getClientVersion()` and `getNegotiatedProtocolVersion()` are deprecated but functional: prefer the per-request context (`ctx.mcpReq.envelope`) on 2026-07-28 requests. No mechanical change required yet; plan the move before the deprecations are removed.
- `createMcpExpressApp()` / `createMcpHonoApp()` / `createMcpFastifyApp()` with a localhost-class `host` now also validate the `Origin` header by default (requests without an `Origin` header are unaffected). Browser-served clients on a non-localhost origin need `allowedOrigins: [...]`, which replaces the default localhost allowlist — Origin validation cannot be disabled for localhost-class binds.

### Server (HTTP entry: createMcpHandler — serving the 2026-07-28 draft revision)

New in 2.0 — v1 has no equivalent API. How v1 Streamable HTTP hosting maps onto the entry:

- `createMcpHandler(factory)` from `@modelcontextprotocol/server` serves the 2026-07-28 draft revision per request and, out of the box, also serves 2025-era (non-envelope) traffic through per-request stateless serving (`legacy: 'stateless'`, the default) — one factory, one endpoint, both eras. A v1 stateless `StreamableHTTPServerTransport` hosting (`sessionIdGenerator: undefined`, fresh transport per request) maps directly onto the default entry.
- Pass `legacy: 'reject'` for a strict, modern-only endpoint: 2025-era requests are rejected with the unsupported-protocol-version error naming the supported revisions, and 2025-era notifications are acknowledged with `202` and dropped. The option type is `legacy?: 'stateless' | 'reject'`.
- An existing sessionful v1 Streamable HTTP setup (a `StreamableHTTPServerTransport` wiring with session IDs) keeps serving 2025 clients by routing in user land in front of a strict entry: `if (await isLegacyRequest(request)) return myExistingLegacyHandler(request); return strictHandler.fetch(request)` where `strictHandler = createMcpHandler(factory, { legacy: 'reject' })`.
- `isLegacyRequest(request: Request, parsedBody?: unknown): Promise<boolean>` from `@modelcontextprotocol/server` is the entry's own classification step. Returns `true` only for requests with no per-request `_meta` envelope claim (claim-less POSTs including `initialize`, GET/DELETE session operations, all-legacy batches, posted responses, non-JSON bodies). Returns `false` for envelope-claiming requests AND for malformed/incomplete modern claims (the modern path answers those with `-32602`/`-32001`) — route `false` traffic to the modern handler, never to a legacy handler. The predicate classifies a clone (the body stays readable); pass the parsed body as the second argument when the stream was already consumed.
- `legacyStatelessFallback(factory)` is exported as a standalone fetch-shaped handler producing the same stateless legacy serving as the default.

### Server (stdio / long-lived connections)

- A hand-constructed `Server`/`McpServer` connected to a `StdioServerTransport` serves only the 2025-era protocol it was written for: today's behavior, byte-identical — no change required during a mechanical migration.
Expand Down
57 changes: 39 additions & 18 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1032,41 +1032,62 @@ already does; automatic envelope emission for every request is a client-side fol

### Serving the 2026-07-28 draft revision over HTTP: `createMcpHandler`

The server package now ships an HTTP entry point that serves the 2026-07-28 draft revision per request, with 2025-era serving available as an **opt-in** slot:
The server package now ships an HTTP entry point that serves the 2026-07-28 draft revision per request and, **by default, also serves 2025-era traffic** per request through the established stateless idiom — one factory, one endpoint, both eras:

```typescript
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';

const handler = createMcpHandler(
ctx => {
const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } });
// register tools/resources/prompts once — the same factory backs both eras
return server;
},
{ legacy: 'stateless' }
);
const handler = createMcpHandler(ctx => {
const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } });
// register tools/resources/prompts once — the same factory backs both eras
return server;
});

// Web-standard runtimes (Cloudflare Workers, Deno, Bun, Hono):
// handler.fetch(request)
// Node frameworks (Express, Fastify, plain node:http):
// handler.node(req, res, req.body)
```

How the `legacy` slot behaves:

- **omitted** — modern-only strict. 2026-07-28 (per-request `_meta` envelope) requests are served; 2025-era requests are rejected with `-32004` naming the supported revisions, and 2025-era notifications are acknowledged with `202` and dropped. **There is no silent 2025
serving without the slot.**
- **`legacy: 'stateless'`** — 2025-era traffic is additionally served per request through the established stateless idiom: a fresh instance from the same factory and a streamable HTTP transport constructed with only `sessionIdGenerator: undefined`. The exported
`legacyStatelessFallback(factory)` is the same handler as a standalone value.
- **`legacy: <handler>`** — bring your own legacy serving (for example an existing sessionful `WebStandardStreamableHTTPServerTransport` wiring). Requests are handed to it untouched and its lifecycle stays yours.
How the `legacy` option behaves:

- **omitted / `legacy: 'stateless'`** (the default) — 2025-era (non-envelope) traffic is served per request through the established stateless idiom: a fresh instance from the same factory and a streamable HTTP transport constructed with only
`sessionIdGenerator: undefined`. Because this serving is per-request and stateless, GET and DELETE (2025 session operations) are answered `405` / `Method not allowed.`, exactly like the canonical stateless example. The exported `legacyStatelessFallback(factory)` is the
same serving as a standalone fetch-shaped handler for hand-wired compositions.
- **`legacy: 'reject'`** — modern-only strict. 2026-07-28 (per-request `_meta` envelope) requests are served; 2025-era requests are rejected with `-32004` naming the supported revisions, and 2025-era notifications are acknowledged with `202` and dropped. **There is no
2025 serving in this mode.**

> **If you have an existing sessionful 1.x Streamable HTTP setup** (a `StreamableHTTPServerTransport` wiring with session IDs that your deployed 2025-era clients depend on), keep that handler serving 2025 traffic and route it in front of a strict (`legacy: 'reject'`)
> entry with the exported `isLegacyRequest(request)` predicate. The predicate is the entry's own classification step (the same code `createMcpHandler` runs to decide a request is not on the modern path), so a composition that branches on it can never disagree with the
> entry:
>
> ```typescript
> // An existing sessionful 1.x streamable HTTP wiring keeps serving 2025 clients, routed in front of a strict entry.
> import { createMcpHandler, isLegacyRequest } from '@modelcontextprotocol/server';
>
> const modern = createMcpHandler(factory, { legacy: 'reject' });
>
> export default {
> async fetch(request: Request): Promise<Response> {
> if (await isLegacyRequest(request)) {
> return myExistingLegacyHandler(request); // e.g. an existing sessionful WebStandardStreamableHTTPServerTransport wiring
> }
> return modern.fetch(request);
> }
> };
> ```
>
> `isLegacyRequest` returns `true` only for requests with no per-request `_meta` envelope claim (claim-less POSTs including `initialize`, GET/DELETE session operations, all-legacy batches, posted responses, and non-JSON bodies). It returns `false` for everything the
> modern path answers — including a request carrying a **malformed** modern claim, which the modern path rejects with `-32602` — so route `false` traffic to the modern handler, never to your legacy handler. The predicate classifies a clone, so the request body stays
> readable for whichever handler you route to (pass an already-parsed body as the second argument if the stream has been consumed).

The optional `responseMode` controls how modern request exchanges are answered: `'auto'` (default) returns a single JSON body and lazily upgrades to an SSE stream when the handler emits a related message before its result; `'sse'` always streams; **`'json'` never streams
and DROPS mid-call notifications** (progress, logging, and any other related message emitted before the result) — only the terminal result is delivered. Subscription (listen-class) streams are always served over SSE regardless of the setting. `onerror` receives
out-of-band errors and rejected requests for logging.

The entry performs no Origin/Host validation (see the origin-validation middleware below) and no token verification: `authInfo` passed to `handler.fetch(request, { authInfo })` / attached as `req.auth` on the Node face is forwarded to handlers as-is and never derived from
request headers. Power users who want to compose routing themselves can use the exported `classifyInboundRequest` and `PerRequestHTTPServerTransport` building blocks directly; the handler faces are bound properties, so they can be detached and passed around
(`const { fetch } = handler`).
request headers. Power users who want to compose routing themselves can use the exported `isLegacyRequest`, `classifyInboundRequest` and `PerRequestHTTPServerTransport` building blocks directly; the handler faces are bound properties, so they can be detached and passed
around (`const { fetch } = handler`).

### Serving the 2026-07-28 draft revision on stdio: `serveStdio`

Expand Down
37 changes: 18 additions & 19 deletions examples/server/src/dualEraStreamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,25 @@
* Dual-era HTTP serving with `createMcpHandler`: one factory, one endpoint,
* both protocol eras.
*
* The same factory backs every serving mode; the `MCP_LEGACY_MODE` environment
* variable selects how 2025-era (non-envelope) traffic is handled:
* The same factory backs both legacy postures; the `MCP_LEGACY_MODE`
* environment variable selects how 2025-era (non-envelope) traffic is handled:
*
* - `MCP_LEGACY_MODE=none` → modern-only strict: 2026-07-28 requests are
* - unset / `MCP_LEGACY_MODE=stateless` → (the entry's default) 2025-era
* traffic is served per-request via the
* stateless idiom from the same factory.
* - `MCP_LEGACY_MODE=reject` → modern-only strict: 2026-07-28 requests are
* served, 2025-era requests get the documented
* rejection naming the supported revisions.
* - `MCP_LEGACY_MODE=stateless` → (default) 2025-era traffic is additionally
* served per-request via the stateless idiom.
* - `MCP_LEGACY_MODE=byo` → the same, but wired explicitly through the
* exported `legacyStatelessFallback` slot value
* (stand-in for bringing your own legacy handler,
* e.g. an existing sessionful wiring).
*
* To keep an existing sessionful 2025 deployment serving legacy traffic next
* to a strict endpoint, route in user land with the exported `isLegacyRequest`
* predicate in front of a `legacy: 'reject'` handler (see the createMcpHandler
* section of docs/migration.md for the pattern) — there is no handler-valued
* `legacy` option.
*
* Run with `tsx examples/server/src/dualEraStreamableHttp.ts`, then point any
* plain 2025 client at http://localhost:3000/mcp (served through the legacy
* slot when one is configured). A `versionNegotiation: { mode: 'auto' }`
* fallback unless `reject` is selected). A `versionNegotiation: { mode: 'auto' }`
* client negotiates 2026-07-28 against the same endpoint, but automatic
* envelope emission for every request is still a client-side follow-up:
* ordinary typed calls (for example `callTool`) must attach the per-request
Expand All @@ -27,11 +30,11 @@
*/
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import type { CallToolResult, CreateMcpHandlerOptions, McpRequestContext } from '@modelcontextprotocol/server';
import { createMcpHandler, legacyStatelessFallback, McpServer } from '@modelcontextprotocol/server';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import type { Request, Response } from 'express';
import * as z from 'zod/v4';

// One factory for both legs (and every slot state): tools are defined once and
// One factory for both legs (and both postures): tools are defined once and
// served identically to 2025-era and 2026-era clients.
const getServer = (ctx: McpRequestContext) => {
const server = new McpServer(
Expand Down Expand Up @@ -60,13 +63,9 @@ const legacyMode = process.env.MCP_LEGACY_MODE ?? 'stateless';
const options: CreateMcpHandlerOptions = {
onerror: error => console.error('MCP handler error:', error.message)
};
if (legacyMode === 'stateless') {
options.legacy = 'stateless';
} else if (legacyMode === 'byo') {
// Bring-your-own legacy serving: any fetch-shaped handler works here. The
// canonical stateless fallback doubles as the simplest BYO value; an
// existing sessionful streamable HTTP wiring would be passed the same way.
options.legacy = legacyStatelessFallback(getServer);
if (legacyMode === 'reject') {
// Modern-only strict: turn the default stateless legacy fallback off.
options.legacy = 'reject';
}

const handler = createMcpHandler(getServer, options);
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export type {
NodeIncomingMessageLike,
NodeServerResponseLike
} from './server/createMcpHandler.js';
export { createMcpHandler, legacyStatelessFallback } from './server/createMcpHandler.js';
export { createMcpHandler, isLegacyRequest, legacyStatelessFallback } from './server/createMcpHandler.js';
export type {
AnyToolHandler,
BaseToolCallback,
Expand Down
Loading
Loading