This is the documentation for the v2 beta — looking for the v1 documentation?
Skip to content

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 covers the era model itself.

Choose a legacy posture

createMcpHandler has two postures. The default, legacy: 'stateless', serves each legacy request from a fresh instance out of your factory, with no sessions. legacy: 'reject' makes the endpoint modern-only.

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

const buildServer = () => new McpServer({ name: 'notes', version: '1.0.0' });

const strict = createMcpHandler(buildServer, { legacy: 'reject' });

A 2025-era initialize POST to the strict handler gets HTTP 400 and the unsupported-protocol-version error naming the one revision the endpoint serves:

400
{
  "jsonrpc": "2.0",
  "error": {
    "code": -32022,
    "message": "Unsupported protocol version: 2025-06-18",
    "data": {
      "supported": [
        "2026-07-28"
      ],
      "requested": "2025-06-18"
    }
  },
  "id": 1
}

Drop the option and the same request gets a normal 2025 InitializeResult from a fresh instance, torn down when the exchange ends. Per request means no sessions: under the default posture a legacy GET (the standalone SSE stream) and DELETE (session termination) answer 405 Method not allowed. — a client that needs those needs the routing below.

TIP

A strict endpoint still acknowledges legacy-classified notification POSTs with 202 — and then drops them. Legacy GET and DELETE answer 405 there too.

Choose the same posture on stdio

serveStdio takes the same option with a different default — 'serve' — and applies it once per connection, not per request.

ts
serveStdio(buildServer, { legacy: 'reject' });

Under 'serve' a 2025-era opening pins the connection to a legacy instance from your factory and serves it exactly as a hand-wired stdio server would. Under 'reject' the entry answers the opening with the same unsupported-protocol-version error and keeps the connection open for a modern opening.

Keep a sessionful 2025 deployment running

Neither entry point accepts a handler as the legacy value. To keep an existing sessionful deployment serving the 2025 clients it already has, route in front of a strict handler with isLegacyRequest — the entry's own classification step exported as a predicate, so the branch never disagrees with createMcpHandler.

ts
import { isLegacyRequest, legacyStatelessFallback } from '@modelcontextprotocol/server';

const legacy = legacyStatelessFallback(buildServer);

async function serve(request: Request): Promise<Response> {
    if (await isLegacyRequest(request)) {
        return legacy(request);
    }
    return strict.fetch(request);
}

legacyStatelessFallback(factory) is the entry's default legacy serving as a standalone handler — it holds the legacy leg's place here. Put your existing wiring there instead and it keeps its sessions, its event store, and its clients: legacy-routing/server.ts runs a sessionful StreamableHTTPServerTransport deployment behind this exact branch. Route every false to the strict handler — the modern path owns the error answers for malformed modern requests.

The initialize the strict handler rejected above now completes the 2025 handshake on the legacy leg:

200
{
  protocolVersion: '2025-06-18',
  capabilities: {},
  serverInfo: { name: 'notes', version: '1.0.0' }
}

TIP

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.

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.

The client side keeps SSEClientTransport, so a v2 Client still reaches old SSE servers. For a server deployment that cannot move yet, a frozen v1 copy of the transport ships as @modelcontextprotocol/server-legacy/sse (deprecated).

Recap

  • Both entry points serve 2025-era clients from the same factory by default; legacy: 'reject' makes an endpoint modern-only.
  • 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 v2 server never serves SSE; the frozen v1 transport is @modelcontextprotocol/server-legacy/sse, and the client keeps SSEClientTransport.