Legacy Clients

Purpose and Scope

A legacy client, in the SDK documentation, is a client that speaks a 2025-era MCP protocol revision. Its opening request is initialize, and it does not send the newer per-request _meta envelope used by the 2026-era surface. This page explains how to keep those clients working while you adopt the v2 server and client packages, and how to avoid accidentally mixing stateful 2025 routing with a modern-only endpoint. The goal is not to preserve every old deployment shape forever; it is to make the compatibility boundary explicit and testable.

Sources: docs/serving/legacy-clients.md

The main decision is your legacy posture. A posture is the policy a serving entry point applies after it classifies an incoming exchange as legacy. For Streamable HTTP, createMcpHandler defaults to legacy: 'stateless', which means each legacy request is answered by a fresh server instance from your factory and then torn down. Set legacy: 'reject' when the endpoint should be modern-only. That strict posture returns an unsupported-protocol-version JSON-RPC error for a 2025 initialize request, while still letting the modern handler own malformed modern request errors.

Sources: docs/serving/legacy-clients.md

Relevant Source Files

  • docs/serving/legacy-clients.md - Defines legacy clients, the legacy option for HTTP and stdio serving, isLegacyRequest, and legacyStatelessFallback routing.
  • docs/clients/connect.md - Shows client connection primitives, v1 import-path migration notes, Streamable HTTP first, and SSE fallback behavior for older servers.
  • docs/clients/calling.md - Documents post-connect behavior such as listing tools, calling tools, pagination, resources, prompts, and protocol-era structured result notes.
  • docs/clients/caching.md - Explains response caching, cache hints, cache modes, and private versus public cache scope considerations that matter during mixed-era operation.
  • docs/clients/machine-auth.md - Covers machine authentication options that modern clients may still need when connecting to protected deployments during migration.
  • docs/clients/middleware.md - Documents client-side fetch middleware, logging, ordering, and OAuth middleware interactions useful for diagnosing legacy routing and transport behavior.

Server Compatibility Postures

Use legacy: 'stateless' only when a request-by-request compatibility shim is enough. Under that default HTTP posture, a legacy initialize can complete and receive a normal 2025 InitializeResult, but there is no retained session behind it. The docs call out the practical consequence: legacy standalone SSE GET requests and legacy DELETE session termination requests receive 405 Method not allowed. If an older client expects a long-lived SSE stream or server-side session lifecycle, the default fallback is intentionally too small; route those clients to a real legacy leg instead.

Sources: docs/serving/legacy-clients.md

serveStdio exposes the same posture idea but with a different default and a different lifetime. Its default is legacy: 'serve', and the decision applies once per stdio connection rather than once per HTTP request. A 2025-era opening pins the connection to a legacy server instance from your factory, matching the shape of a hand-wired stdio server. If you set legacy: 'reject', the entry point answers the opening with the unsupported-protocol-version error and keeps the connection available for a modern opening.

Sources: docs/serving/legacy-clients.md

import { createMcpHandler, McpServer, serveStdio } from '@modelcontextprotocol/server';
 
const buildServer = () => new McpServer({ name: 'notes', version: '1.0.0' });
 
const strictHttp = createMcpHandler(buildServer, { legacy: 'reject' });
 
await serveStdio(buildServer, { legacy: 'reject' });

Sessionful Legacy Routing

If you already operate a sessionful 2025 deployment, do not try to pass that existing handler as the legacy option. The documented pattern is to route in front of a strict modern handler by using isLegacyRequest, the same predicate the SDK entry point uses internally. This keeps classification consistent: requests identified as legacy go to a legacy branch, and every non-legacy request goes to the strict modern handler. That separation prevents a partial shim from accidentally owning modern errors or from silently dropping session behavior older clients require.

Sources: docs/serving/legacy-clients.md

legacyStatelessFallback(factory) is available as the standalone version of the HTTP entry point default. It is useful when stateless compatibility is enough, and it also demonstrates where a custom legacy branch belongs. For an existing deployment, replace that fallback with your current sessionful 2025 wiring, including its event store, session IDs, and SSE routes. The key constraint is routing completeness: every false result from isLegacyRequest should continue into the strict modern handler, because that handler is responsible for modern protocol responses and validation behavior.

Sources: docs/serving/legacy-clients.md

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 strictHttp.fetch(request);
}

Client-Side Migration Signals

On the client side, the v2 docs preserve familiar names while moving imports into split packages. A Client still holds one connection to one server, and a transport still owns the wire shape. For HTTP, start with StreamableHTTPClientTransport; for a local child process, use StdioClientTransport from the /stdio subpath. The connect guide explicitly tells v1 users that the class names remain recognizable and that the import paths moved to @modelcontextprotocol/client and @modelcontextprotocol/client/stdio. After connect() resolves, the client has the negotiated protocol version, server capabilities, and instructions.

Sources: docs/clients/connect.md

For servers that predate Streamable HTTP, the documented client migration pattern is a transport fallback rather than branching the rest of the application. Try StreamableHTTPClientTransport first; if it fails, create a fresh Client and retry with SSEClientTransport. The returned Client should be treated the same from that point forward. Listing tools, calling tools, reading resources, fetching prompts, and following paginated lists are client API concerns, not transport concerns. The calling guide also notes that structured result encoding differs by protocol era, so validate the behavior you depend on.

Sources: docs/clients/connect.md, docs/clients/calling.md

Operational Considerations

Mixed-era deployments are easiest to debug when each layer has a single responsibility. Client middleware wraps the fetch used by HTTP transports, so it can add request headers, log response status codes, or express OAuth as a retrying middleware layer. The logging middleware example is especially useful when confirming whether initialize, notifications/initialized, SSE stream attempts, or tool calls reach a compatibility endpoint. Keep in mind that the default logger writes to console streams, which can interfere with stdio transports unless you supply a safe logger.

Sources: docs/clients/middleware.md

Authentication and caching can create confusing migration symptoms if they are not partitioned correctly. Machine clients can use ClientCredentialsProvider, a custom bearer-token AuthProvider, PrivateKeyJwtProvider, or CrossAppAccessProvider, all attached to the transport rather than to legacy routing. Response caching is held by every Client, but the server's cache hints determine freshness, and private data must stay scoped to the caller. During a staged migration, set a stable cachePartition for shared stores and be careful not to treat a public cache entry as safe when it depends on authorization context.

Sources: docs/clients/machine-auth.md, docs/clients/caching.md

Compact Reference

ConcernPublic name or optionBehavior to verifySource
HTTP legacy posturecreateMcpHandler(factory, { legacy: 'stateless' })Default HTTP compatibility; fresh instance per legacy request; no legacy sessionsdocs/serving/legacy-clients.md
Modern-only HTTPcreateMcpHandler(factory, { legacy: 'reject' })Rejects 2025 initialize with unsupported-protocol-version errordocs/serving/legacy-clients.md
Stdio legacy postureserveStdio(factory, { legacy: 'serve' })Default stdio behavior; decision applies per connectiondocs/serving/legacy-clients.md
Stdio strict modeserveStdio(factory, { legacy: 'reject' })Rejects a legacy opening but keeps the connection availabledocs/serving/legacy-clients.md
Routing predicateisLegacyRequest(request)Branches legacy requests before a strict modern handlerdocs/serving/legacy-clients.md
Stateless fallbacklegacyStatelessFallback(factory)Standalone version of default HTTP legacy servingdocs/serving/legacy-clients.md
Older server fallbackSSEClientTransportRetry after Streamable HTTP fails, using a fresh Clientdocs/clients/connect.md
v2 client imports@modelcontextprotocol/client, @modelcontextprotocol/client/stdioClass names stay familiar; package paths move from v1docs/clients/connect.md

Next Steps

Start by classifying the clients you still need to support: stateless 2025 requesters, sessionful SSE clients, stdio integrations, and modern 2026-era clients. Then pick one explicit posture for each endpoint and encode it in tests: strict modern HTTP, stateless fallback, or front-routed sessionful legacy. For client migrations, update import paths, connect with Streamable HTTP first, fall back to SSE only for older servers, and keep downstream call logic transport-neutral. Read the protocol version, Streamable HTTP, stdio, authorization, and upgrade guides next if your deployment spans multiple protocol eras.