Skip to content

· 10 min read · Nadeera Silva

AI & Automation

MCP Stateless Update: Migrate Your Server to 2026-07-28

MCP's 2026-07-28 spec drops sessions and the initialize handshake. What breaks in your MCP server, and how to migrate a TypeScript server step by step.

Illustration of sealed envelopes on a teal conveyor splitting evenly through an amber turnstile to four identical servers, with an old coiled cable cut and discarded, showing stateless MCP requests that any server instance can handle without a held-open connection

The MCP stateless update is the biggest change to the Model Context Protocol since remote servers arrived. The 2026-07-28 specification removes the initialize handshake and the Mcp-Session-Id header, makes every request self-describing, and replaces server-to-client requests with a retry pattern. If you run an MCP server for Claude, Cursor, ChatGPT or your own agent, some of that code is now running on deprecated paths. This post covers what changed, what breaks in a typical TypeScript server, and a migration order that keeps older clients working.

What the MCP stateless update changes

The release post describes MCP moving "from a bidirectional stateful protocol into a request/response stateless protocol". In practice that means six changes you will touch:

AreaBefore (2025-11-25 and earlier)Now (2026-07-28)
Connection setupinitialize then notifications/initializedNo handshake. Optional server/discover, which servers must implement
SessionsMcp-Session-Id header on Streamable HTTPRemoved. Each request carries version, client info and capabilities in _meta
HTTP headersBody onlyMCP-Protocol-Version, Mcp-Method and Mcp-Name required on POSTs
Asking the user or the modelServer sends elicitation/create or sampling/createMessageServer returns resultType: "input_required", client retries with inputResponses
List resultsNo cache guidancettlMs and cacheScope required, tools listed in a deterministic order
Change notificationsHTTP GET stream, resources/subscribeOne opt-in subscriptions/listen stream

Every result now also carries a resultType of "complete" or "input_required", and clients treat results without it (from older servers) as complete.

The payoff is operational. Any request can land on any instance behind a plain round-robin load balancer, with no sticky sessions and no shared session store. That is why hosts like Cloudflare, Netlify and AWS Bedrock AgentCore announced day-one support in the release post.

What breaks in an existing MCP server

Not everything breaks at once. Older clients still speak the 2025 protocol, and the SDKs serve both. But these are the places where a server written last year needs work:

  • Session-keyed state. If you stored anything against Mcp-Session-Id (a selected workspace, a shopping cart, an auth context), there is no session to key on any more.
  • Push-style elicitation and sampling. In the TypeScript SDK, calling ctx.mcpReq.elicitInput() or requestSampling() on a 2026-era request fails with a typed local error before anything reaches the wire.
  • Hand-rolled clients and gateways. A 2026-era POST without Mcp-Name for tools/call is rejected with HTTP 400 and JSON-RPC error -32020 (HeaderMismatch).
  • Logging. logging/setLevel is gone. The log level now travels per request in _meta, and if the client doesn't send it, the server must not emit log notifications for that request.
  • Error codes. "Resource not found" moved from -32002 to -32602, and the new protocol errors live in the -32020 to -32099 range.
  • SSE resumability. Last-Event-ID redelivery is removed. A broken response stream loses the in-flight request and the client re-issues it.

Here is what the missing header looks like against a 2026-07-28 server, captured from the example below:

{
  "jsonrpc": "2.0",
  "error": {
    "code": -32020,
    "message": "Bad Request: the request headers and body disagree: the body carries params.name=\"delete_project\" but the required Mcp-Name header is absent"
  },
  "id": 3
}

Multi Round-Trip Requests: confirmations without a session

Multi Round-Trip Requests (MRTR) are how a stateless server still asks the user something mid-call. Instead of opening a request back to the client, the tool returns an interim result that lists what it needs. The client collects the answers and calls the same tool again with them attached.

Four-step flow of an MCP Multi Round-Trip Request: tools/call, input_required with a signed requestState, the user answers and the client retries, then any server instance completes the call
The MRTR flow: the retry carries everything the server needs, so it can land on any instance.

Any state the server needs between rounds goes into requestState, an opaque string the client echoes back byte for byte. The spec's own advice for longer-lived state is similar: mint an explicit handle from a tool and let the model pass it back as an argument.

Migrating a TypeScript MCP server to 2026-07-28

The TypeScript SDK v2 packages are split: @modelcontextprotocol/server for the server, @modelcontextprotocol/node for Node framework adapters. The legacy @modelcontextprotocol/sdk package is still on the 1.x line.

npm install @modelcontextprotocol/server @modelcontextprotocol/node express zod

The example below is a small Express server with two tools. delete_project asks for confirmation using MRTR, and tools/list advertises a five-minute public cache. createMcpHandler builds a fresh server for each request and, by default, also serves 2025-era clients statelessly, so one endpoint covers both.

import express from 'express';
import * as z from 'zod/v4';
import {
  McpServer,
  createMcpHandler,
  createRequestStateCodec,
  inputRequired,
  acceptedContent,
} from '@modelcontextprotocol/server';
import { toNodeHandler } from '@modelcontextprotocol/node';

type DeleteState = { step: 'confirm'; projectId: string };

// The same secret on every instance, so any of them can verify the state.
const stateKey = process.env.MCP_STATE_KEY;
if (!stateKey || stateKey.length < 32) throw new Error('Set MCP_STATE_KEY (32+ chars)');

const stateCodec = createRequestStateCodec<DeleteState>({
  key: new TextEncoder().encode(stateKey),
  ttlSeconds: 300,
});

const CONFIRM = z.object({ confirm: z.boolean() });

function buildServer(): McpServer {
  const server = new McpServer(
    { name: 'projects-mcp', version: '2.0.0' },
    {
      capabilities: { tools: {} },
      requestState: { verify: stateCodec.verify }, // rejects tampered or expired state
      cacheHints: { 'tools/list': { ttlMs: 300_000, cacheScope: 'public' } },
    },
  );

  server.registerTool(
    'list_projects',
    {
      description: 'List projects for the signed-in account.',
      inputSchema: z.object({ limit: z.number().int().max(50).default(20) }),
    },
    async ({ limit }) => ({
      content: [{ type: 'text', text: JSON.stringify(await db.listProjects(limit)) }],
    }),
  );

  server.registerTool(
    'delete_project',
    {
      description: 'Permanently delete a project. Asks the user to confirm first.',
      inputSchema: z.object({ projectId: z.string() }),
    },
    async ({ projectId }, ctx) => {
      const state = ctx.mcpReq.requestState<DeleteState>();
      const answer = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', CONFIRM);

      // First call, a declined answer, or state minted for another project: ask again.
      if (!state || state.projectId !== projectId || answer?.confirm !== true) {
        return inputRequired({
          inputRequests: {
            confirm: inputRequired.elicit({
              message: `Delete project ${projectId}? This cannot be undone.`,
              requestedSchema: CONFIRM,
            }),
          },
          requestState: await stateCodec.mint({ step: 'confirm', projectId }),
        });
      }

      await db.deleteProject(projectId);
      return { content: [{ type: 'text', text: `Deleted ${projectId}` }] };
    },
  );

  return server;
}

// Replace with your real data layer.
const db = {
  async listProjects(limit: number) { return [{ id: 'p1', name: 'Demo' }].slice(0, limit); },
  async deleteProject(_id: string) {},
};

const handler = createMcpHandler(buildServer); // legacy: 'stateless' is the default
const app = express();
app.all('/mcp', toNodeHandler(handler));
app.listen(3000, () => console.log('MCP server on http://localhost:3000/mcp'));

The first tools/call to delete_project returns "resultType": "input_required" with an elicitation form and a signed requestState. When the client retries with inputResponses: { confirm: { action: "accept", content: { confirm: true } } } and the same state, the tool completes. A modified requestState is rejected with -32602 "Invalid or expired requestState" before the handler runs.

If you run a sessionful v1 server

A v1 server that used StreamableHTTPServerTransport with sessionIdGenerator: undefined maps straight onto createMcpHandler. If yours is sessionful, keep it for old clients and put a strict 2026 handler in front, using isLegacyRequest() to route, as the migration guide shows:

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

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

export default {
  async fetch(request: Request) {
    if (await isLegacyRequest(request)) return myExistingLegacyHandler(request);
    return modern.fetch(request);
  },
};

stdio servers

A server that calls server.connect(new StdioServerTransport()) keeps speaking only the 2025 protocol, even after the SDK upgrade. Switch to serveStdio(() => buildServer()) from @modelcontextprotocol/server/stdio to serve both eras on one connection.

Authorization changes for remote MCP servers

If your remote MCP server uses OAuth, three rules matter:

  • Clients must validate the iss parameter (RFC 9207) before redeeming an authorization code, which closes an authorization-server mix-up attack. Your authorization server should send it.
  • Dynamic Client Registration (DCR) is deprecated in favour of Client ID Metadata Documents (CIMD). DCR still works for backward compatibility. Clients that use DCR must now set application_type, which fixes the redirect_uri errors CLI and desktop clients hit with localhost redirects.
  • Client credentials are bound to the issuer that minted them and must not be reused with another authorization server.

If you verify tokens yourself on the server side, the same discipline applies as in verifying Supabase JWTs with JWKS: pin the issuer and audience, and never trust a token just because it parses.

Deprecations and timelines

The suggested replacements are straightforward: pass directories or files as tool parameters or server configuration instead of Roots, call your LLM provider's API directly instead of Sampling, and log to stderr or OpenTelemetry instead of Logging. Tasks moved out of the core into the io.modelcontextprotocol/tasks extension, with polling via tasks/get and a new tasks/update.

All four Tier 1 SDKs (TypeScript, Python, Go and C#) support 2026-07-28, and the Rust SDK has it in beta.

Migration checklist

  • Upgrade to the v2 TypeScript packages (or your language's updated SDK) and serve HTTP through createMcpHandler, or stdio through serveStdio.
  • Search for anything keyed on the session ID and move it into tool arguments, explicit handles or a signed requestState.
  • Replace elicitInput() and requestSampling() calls with inputRequired(...) returns.
  • Load one requestState signing secret on every instance, and set a short TTL.
  • Make sure gateways, WAF rules and proxies pass MCP-Protocol-Version, Mcp-Method and Mcp-Name through, and route or rate limit on them if useful.
  • Return tools in a stable order and set cacheHints for list results that rarely change.
  • Move logging to stderr or OpenTelemetry.
  • Update "resource not found" handling from -32002 to -32602.
  • For OAuth, send iss from your authorization server and plan the move from DCR to CIMD.
  • Test with both a 2025-era client and a 2026-era client before you remove anything.

Should you migrate now?

If your MCP server runs on more than one instance, or you have been fighting sticky sessions and dropped SSE streams, yes: this release removes exactly that class of problem. If it's a single stdio server used by one desktop client, there's no rush, since 2025-era clients keep working and the deprecations run until at least July 2027. Either way, write new tools in the inputRequired(...) style now, because that works on both protocol versions.

If your agent also answers questions from your own documents, the same "make every call carry what it needs" idea shows up in RAG with citations on pgvector. And for a production example of tools that touch customer data, see AI agents for WhatsApp CRM automation.

Frequently asked questions

What is the MCP stateless update?

It is the 2026-07-28 revision of the Model Context Protocol. It removes the initialize handshake and the Mcp-Session-Id header, so every request carries its own protocol version, client info and capabilities and can be served by any server instance.

Is the Mcp-Session-Id header still required?

No. The 2026-07-28 spec removes protocol-level sessions entirely. Servers that need state across calls should pass explicit handles as tool arguments or use a signed requestState in a multi round-trip request.

Do old MCP clients still work with a 2026-07-28 server?

Yes, if the server serves both versions. In the TypeScript SDK, createMcpHandler serves 2025-era clients statelessly by default, and serveStdio does the same for stdio connections.

How does elicitation work in stateless MCP?

The tool returns a result with resultType input_required that lists the questions. The client asks the user, then retries the same call with the answers in inputResponses and echoes back the server's requestState.

When will Sampling, Roots and Logging be removed?

They are deprecated, and the new policy keeps deprecated features working for at least twelve months from the 28 July 2026 release. New implementations should not adopt them.

Sources

  1. The 2026-07-28 Specification (Model Context Protocol Blog, 2026-07-28)
  2. Key Changes (2026-07-28 changelog) (Model Context Protocol)
  3. Supporting protocol revision 2026-07-28 (TypeScript SDK migration guide) (GitHub)
  4. Protocol versions (TypeScript SDK v2 docs) (Model Context Protocol)
  5. MRTR example server (GitHub)
  6. RFC 9207: OAuth 2.0 Authorization Server Issuer Identification (IETF)
SharePost on XLinkedIn

Comments

Share a question or note about this article.

Loading comments…