Danila (Dayfing)
Back to writing
2,182 words10 min

MCP 2026-07-28: Stateless Servers and a Safe Migration Path

The Model Context Protocol revision dated 2026-07-28 changes remote MCP. Its core is stateless and request based, so a load balancer may send the next call to another instance. The release adds server/discover, Multi Round-Trip Requests (MRTR), routing headers, cache hints, extensions, and stronger authorization. The official release post and specification changelog provide the normative details used here.

This changes protocol state, not business state. A tool may still use a database, queue, or durable workflow. What disappears is hidden state tied to an MCP transport session, which matters when a service moves from one process to many replicas.

What changed from the 2025-era protocol

The previous lifecycle used an initialize request, an initialized notification, and, for Streamable HTTP, a possible Mcp-Session-Id. The server could associate later messages with that connection. In 2026-07-28, the initialize exchange and protocol session header are removed. Each request declares its protocol version and client capabilities in _meta. A client should also provide io.modelcontextprotocol/clientInfo, while a server should identify itself in result metadata.

Use this comparison during planning:

Area 2025-era behavior 2026-07-28 behavior
Lifecycle initialize and notifications/initialized No protocol handshake
Session Optional Mcp-Session-Id on HTTP No protocol-level session
Capabilities Negotiated once Declared per request
Discovery Read after initialization or by convention server/discover is required on a modern server and optional for a client
Server to client Requests on a held-open channel MRTR returns input requests in the response
HTTP routing Gateway often parses JSON Mcp-Method and applicable Mcp-Name headers
Lists and reads Client-defined freshness ttlMs and cacheScope hints
Resumption SSE could use event IDs No Last-Event-ID resumption; retry as a new request
Registration DCR was the usual automatic path CIMD is preferred, DCR remains for compatibility

The revision moves Tasks into io.modelcontextprotocol/tasks, replaces the old change stream with subscriptions/listen, and deprecates Roots, Sampling, Logging, and legacy HTTP+SSE. The lifecycle policy provides a minimum twelve-month window, but new implementations should not adopt deprecated features.

What stateless means in practice

For Streamable HTTP, the modern server exposes one MCP endpoint that accepts POST. The client sends one JSON-RPC request or notification per POST. A request receives either a JSON object or an SSE response scoped to that request. The server does not mint a session identifier, and a broken response stream does not have a resumable event history. On HTTP, closing the response stream is the cancellation signal.

The transport header and body describe the same operation. A minimal tool call looks like this:

POST /mcp HTTP/1.1
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"q":"otters"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{},"io.modelcontextprotocol/clientInfo":{"name":"catalog-app","version":"1.0.0"}}}}

The MCP-Protocol-Version header must match the _meta value. A modern server rejects a missing or inconsistent required header with HTTP 400 and the HeaderMismatch error code -32020. The server must validate the headers again after any gateway has handled them. This prevents a proxy from routing by one tool name while the application executes another.

Statelessness changes scaling, not business semantics. If a workflow needs continuity, return an explicit handle and require the next call to present it. Keep authoritative state in a database or workflow service, bind the handle to the user and operation, and apply an expiry. Never treat an unverified handle, requestState, or tool argument as proof of permission.

server/discover and version compatibility

Every modern server must implement server/discover. Its result advertises supported versions, capabilities, and optional instructions. A client may call it first to choose a version or send a normal modern request. Discovery is useful but is not a client-side handshake requirement.

When the version is unsupported, the server returns UnsupportedProtocolVersionError with supported versions. The client chooses a mutual version and retries. A client supporting both eras should classify probes carefully. An empty or non-modern 400 can indicate an older endpoint, but a recognized modern error means the request must be fixed or renegotiated. Authentication and infrastructure failures are not legacy evidence.

A modern SDK can probe a standard-input connection and pin an era for that connection. A server can keep a legacy path while new clients use modern requests. Do not infer an era from a successful TCP connection or a generic 404.

MRTR replaces server-initiated requests

The modern wire format removes the server-to-client JSON-RPC request channel. A tool that needs approval, a missing value, or a model-assisted step returns an interim result instead of holding a stream open. The result has resultType: "input_required" and an inputRequests map. The client satisfies those requests and retries the original method with inputResponses. The retry is a new request and can reach a different replica.

Conceptually, a confirmation flow is:

{
  "resultType": "input_required",
  "inputRequests": {
    "confirm": {
      "type": "elicitation",
      "message": "Delete three files?",
      "schema": {"type": "boolean"}
    }
  },
  "requestState": "signed-opaque-state"
}

The client later sends inputResponses.confirm and echoes requestState byte for byte. The server must re-enter the handler as if it were a fresh request. Make the handler idempotent, derive the current workflow step from verified state, and ask only for information that is still missing. Do not mark a destructive action complete before the confirmation has been validated.

requestState is not a secure container. It travels through the client and is attacker-controlled. Sign it with an HMAC or use authenticated encryption, bind it to the principal, original method, relevant parameters, and an expiry, and reject tampering before the handler runs. A signature does not hide the payload, so keep secrets out of it. The TypeScript SDK provides a request-state codec and a verification hook for this purpose. Its legacy shim can translate the same input_required handler to the older elicitation/create, sampling/createMessage, and roots/list requests while a 2025 client is still supported.

For long-running work, use the Tasks extension with a durable task handle, tasks/get polling, and tasks/update where client input is needed.

Cache hints and deterministic catalogs

Modern results from tools/list, prompts/list, resources/list, resources/templates/list, and resources/read carry ttlMs and cacheScope. ttlMs is a non-negative freshness hint in milliseconds, analogous to HTTP max-age. Zero means immediately stale. A missing value should be treated as zero for older servers. A positive value tells a client when it may avoid fetching again, not that the data cannot change before expiry. Clients should check freshness when the data is needed instead of turning TTL into an unbounded polling loop.

The cache key must include the method and every parameter that affects the result. That includes a resource URI and the cursor on a paginated list. Do not cache a response that contains inputResponses or requestState, because the request context is not represented by a simple list key. cacheScope: "public" allows sharing across authorization contexts, so use it only when the result contains no user-specific or permission-specific data. Per-tool authorization remains necessary even when a catalog is cached.

Servers should return tools in deterministic order. Stable ordering reduces noisy diffs, keeps model prompts stable, and improves cache reuse after reconnects. A listChanged notification can complement a TTL, but it does not remove the need to set the cache hint correctly.

OAuth and security changes

Authorization is optional for MCP. An HTTP server protecting resources should follow the 2026 OAuth 2.1 profile. It acts as a resource server and must publish OAuth Protected Resource Metadata under RFC 9728. A 401 should point to it with WWW-Authenticate and a useful scope challenge. Clients must support the header URL and both well-known forms.

The metadata identifies one or more authorization servers. Clients must support OAuth Authorization Server Metadata under RFC 8414 and OpenID Connect Discovery. For a path-based issuer, try path-insertion forms before the path-appending OpenID form.

Client registration now favors Client ID Metadata Documents. Pre-registration is also valid. Dynamic Client Registration is a deprecated fallback. If DCR is used, send the correct application_type for a desktop or CLI client. Validate PKCE and use S256, register exact redirect URIs, and use HTTPS except for an allowed localhost callback.

Record the validated issuer with the PKCE transaction. If the response contains iss, compare it before redeeming the code. Credentials are bound to their issuer, so never reuse them with another authorization server. Include the canonical MCP server URI as RFC 8707 resource in authorization and token requests. Servers must validate token audience and reject tokens for another resource.

For Streamable HTTP, validate Origin to block DNS rebinding. A local server should bind to localhost rather than all interfaces. Keep bearer tokens out of query strings and logs, require consent before exposing private resources or invoking tools, and treat tool descriptions and annotations as untrusted unless the server is trusted. A 401 means missing or invalid authorization. A 403 means insufficient permission and should carry an insufficient_scope challenge when possible.

TypeScript SDK migration

The TypeScript SDK v2 separates client, server, core, and runtime packages. Read the 2026-07-28 SDK guide and v1-to-v2 guide. An upgrade alone does not put modern bytes on the wire. The v2 client defaults to legacy negotiation, so opt in with versionNegotiation.

For a client that can work with both eras, the documented shape is:

import { Client } from '@modelcontextprotocol/client';

const client = new Client(
  { name: 'catalog-app', version: '1.0.0' },
  { versionNegotiation: { mode: 'auto' } },
);
await client.connect(transport);

mode: 'auto' probes server/discover and falls back to the 2025 handshake only for a legacy peer. Pin 2026-07-28 when fallback would hide incompatibility. createMcpHandler(factory) builds a fresh modern HTTP server per request and can serve both eras. For stdio era selection, use serveStdio(() => buildServer()).

Move v1 schema-based handler registration to method strings such as setRequestHandler('tools/call', handler). Replace code that reads ctx.sessionId with explicit application handles or verified requestState. Replace push-style elicitation with inputRequired(...), and review logging because a modern request emits no message notification unless it includes io.modelcontextprotocol/logLevel. Treat the SDK codemod as a mechanical aid, not a compatibility test.

For verification, drive createMcpHandler through a fetch-shaped test transport and keep separate coverage for a legacy handshake. Assert headers, _meta, retry idempotency, cache scope, audience validation, and HTTP status with real old and modern clients.

Migration checklist

  1. Inventory clients, servers, transports, session stores, SSE event stores, and code that reads Mcp-Session-Id.
  2. Decide which endpoint serves modern traffic and which legacy traffic remains during the transition.
  3. Upgrade the SDK and pin the actual package versions in the lockfile.
  4. Add server/discover and a version-negotiation policy.
  5. Make every request self-describing and validate _meta and mirrored headers.
  6. Replace session-keyed state with explicit handles or signed, expiring requestState.
  7. Rewrite server-initiated interaction as MRTR and make retries safe.
  8. Add ttlMs, cacheScope, and deterministic ordering to catalog and resource results.
  9. Update gateways, WAF rules, metrics, and traces for Mcp-Method and Mcp-Name.
  10. Implement issuer, resource, audience, PKCE, redirect, Origin, and scope checks.
  11. Migrate new code away from deprecated HTTP+SSE, Roots, Sampling, Logging, and DCR.
  12. Roll out behind observability, compare modern and legacy error rates, then remove compatibility code only after consumers have moved.

Troubleshooting

Symptom Likely cause Action
HTTP 400 with -32020 A required header is missing or disagrees with the body Recompute MCP-Protocol-Version, Mcp-Method, and Mcp-Name from the same request object
HTTP 400 with unsupported-version error The peer does not serve the requested revision Select a version from supported or use the legacy path
HTTP 404 with JSON-RPC method-not-found The endpoint is modern but the method is not in this revision Check the method and extension support; do not blindly start initialize
HTTP 401 or 403 during discovery Authentication blocks the probe Fix credentials and metadata discovery; auth status does not prove legacy behavior
No server log notifications Modern request omitted io.modelcontextprotocol/logLevel Opt in per request or use stderr and OpenTelemetry
Duplicate side effects after reconnect A stream is no longer resumable Use idempotency keys and retry with a new request ID
User-specific data appears in another cache cacheScope was incorrectly marked public Mark the result private and include the principal in application authorization checks
Old client receives 405 on GET It expects HTTP+SSE Keep a temporary legacy endpoint or upgrade the client to Streamable HTTP

For architecture context, compare this migration with production AI agent architecture and the implementation details in MCP server with TypeScript and OAuth. Both links assume the English site routes. The localized versions use the locale-prefixed paths on this site.

Sources

This article follows the MCP 2026-07-28 specification, its key changes and deprecations, the Streamable HTTP transport requirements, SEP-2575 on stateless MCP, the 2026-07-28 release post, the MCP authorization specification, and the TypeScript SDK migration documentation.

More