Skip to content

MCP 2026-07-28: what changed and how to migrate a server

MCP specification 2026-07-28 makes the protocol stateless: the initialize handshake and the Mcp-Session-Id header are gone, every request carries its protocol version and client capabilities in _meta, and servers must implement server/discover. A custom server migrates by upgrading to the v2 SDK, replacing session state and server-initiated requests, and serving both protocol eras side by side.

Your team’s internal MCP server wraps the deploy API. It keeps a per-session cache keyed on Mcp-Session-Id, asks the user to confirm a rollback through elicitation/create, and logs through logging/setLevel. All three are removed or deprecated in 2026-07-28. Claude Code already negotiates the new revision with HTTP servers by default, and Codex 0.157.1 still does not, so the same server has to answer both. This page is for the developer who owns that server and for the tech lead who signs off the migration.

Checked on 2026-09-26 against the 2026-07-28 changelog, the maintainers’ release post (2026-07-28), the TypeScript SDK 2.1.0 and Python SDK 2.2.0 documentation, Claude Code 2.1.283 and codex-cli 0.157.1. Cursor’s MCP protocol support was not verified on 2026-09-26; test it in the app (see the Cursor tab). This page assumes you already run a server built as in Building your own MCP server.

What you get from migrating a server to MCP 2026-07-28

Section titled “What you get from migrating a server to MCP 2026-07-28”
  • Every breaking change, what it breaks and what replaces it.
  • Which clients speak the new revision, and why you keep serving the old one.
  • A TypeScript migration, step by step, plus the Python equivalents.
  • A smoke test per protocol era that fails CI when either breaks.
  • Four copy-paste prompts: audit, migrate, call, and test.
  • A sign-off checklist for the migration pull request.

The maintainers summarise the release as “a stateless protocol core, Multi Round-Trip Requests, header-based routing, cacheable list results, authorization hardening, a formal extensions framework, and updated Tier 1 SDKs” (MCP blog, 2026-07-28). The release candidate was locked on 21 May 2026. For a server author, the changelog reduces to the rows below.

Change in 2026-07-28What it breaks in your serverReplace it with
initialize / notifications/initialized removedCode that reads client capabilities or client info once, at connect timePer-request _meta keys: io.modelcontextprotocol/protocolVersion, clientCapabilities, and optionally clientInfo
server/discover added, and servers must implement itA hand-rolled server that has no handler for itThe v2 SDKs answer it for you
Mcp-Session-Id and protocol sessions removedState keyed on the session id: caches, auth context, multi-step flowsExplicit handles returned by one tool and passed back as arguments to the next, or a signed requestState
Server-initiated sampling/createMessage, elicitation/create, roots/list replaced by Multi Round-Trip RequestsA tool that pauses mid-call to ask the user somethingReturn resultType: "input_required" with inputRequests; the client retries the call with inputResponses
Every result carries a required resultTypeHand-built JSON-RPC results"complete" for ordinary results (the SDKs set it)
HTTP GET stream and resources/subscribe replaced by subscriptions/listenChange notifications pushed on the standalone streamOne opted-in stream per client, tagged with a subscription id
ping, logging/setLevel and notifications/roots/list_changed removedHealth checks built on ping, session-wide log levelsPer-request io.modelcontextprotocol/logLevel in _meta; no logs are sent when it is absent
SSE resumability (Last-Event-ID) removedLong calls that relied on the client resuming a broken streamThe client re-issues the request, so make tool calls safe to repeat
Mcp-Method and Mcp-Name headers required on Streamable HTTP POSTsGateways and WAF rules that strip unknown headersAllow the headers through; route and rate-limit on them
List results carry ttlMs and cacheScope; tools/list should be in a deterministic orderTool lists built from an unordered mapA stable sort, so clients and prompt caches see the same list
Tasks moved to the io.modelcontextprotocol/tasks extensionCode on the experimental core tasks APIPoll with tasks/get, send input with tasks/update; tasks/list is gone
Resource-not-found error code changed from -32002 to -32602Clients or tests that match on the old codeThe JSON-RPC Invalid Params code

Four features are deprecated but still work for at least twelve months under the new deprecation policy: Roots, Sampling, Logging, and the HTTP+SSE transport. Dynamic Client Registration is deprecated in favour of Client ID Metadata Documents, and clients must now validate the iss parameter when an OAuth authorization response carries one. New code should not adopt any of the deprecated features.

The extensions framework also formalises MCP Apps: interactive UI that a server returns for rendering in a sandboxed iframe. Its SDK is @modelcontextprotocol/ext-apps (npm 2.0.0, the latest tag on 2026-09-26, published 2026-09-08), and its README lists Claude, ChatGPT and VS Code as supported clients. Its extension id is io.modelcontextprotocol/ui. Extensions stay off until both sides declare them, so fall back to core behaviour otherwise. In codex-cli 0.157.1 the enable_mcp_apps feature is under development and off, so a tool that returns an App must still return a useful text result.

Your server does not choose the protocol; the client does. A client that supports both eras probes first (with server/discover, or on HTTP with a first modern request) and falls back to initialize when the server gives no modern answer. A client that knows only the old era sends initialize and nothing else. Clients differ, so plan for both eras on the same endpoint.

ClientSpeaks 2026-07-28?How to force either era for a test
Claude Code 2.1.283Yes with HTTP servers, by default on the v2 client runtime (all install types by v2.1.274). Stdio servers stay on the older handshake unless you opt inMCP_PROTOCOL_NEGOTIATION=auto probes stdio servers too; legacy skips the probe for every server
Codex 0.157.1No. mcp_2026_07_28 is under development and off (codex features list)codex --enable mcp_2026_07_28 turns the unfinished feature on for one run. Treat it as a preview, not a gate
CursorNot verified on 2026-09-26Test in the app after each Cursor update

SDK status on 2026-09-26: the TypeScript v2 packages @modelcontextprotocol/server and @modelcontextprotocol/client are at 2.1.0 (npm), the v1 package @modelcontextprotocol/sdk is at 1.30.1 and gets fixes for at least six months after v2’s release, and PyPI mcp is at 2.2.0. The maintainers report “close to half-a-billion downloads a month” across the Tier 1 SDKs (TypeScript, Python, Go and C#) in the same release post, a maintainer-reported figure.

The SDKs keep serving 2025-era clients, so the migration is mostly about what your own code assumes. Find your server’s row.

Your server todayWork required
Stateless tools on the TypeScript v1 SDK, stdioUpgrade to v2 and switch to serveStdio. Small: the codemod does most of it
Stateless tools on the TypeScript v1 SDK, Streamable HTTP with sessionIdGenerator: undefinedUpgrade to v2; the default createMcpHandler maps onto this setup directly
Anything keyed on the session idRedesign that state as explicit handles or requestState. This is the real cost
Tools that call elicitInput, createMessage or roots/listRewrite them to return input_required (TypeScript) or use Resolve(...) parameters (Python)
HTTP+SSE transportMove to Streamable HTTP. The v2 server does not serve SSE
Python on mcp 1.xPin mcp<2 today (the SDK’s own advice; 1.29.1 is the last 1.x on 2026-09-26), then port to MCPServer (below)
Hand-written JSON-RPC, no SDKImplement server/discover, _meta parsing, resultType and the new headers yourself, or move to an SDK

Migrate a TypeScript MCP server to 2026-07-28

Section titled “Migrate a TypeScript MCP server to 2026-07-28”

The TypeScript SDK splits the migration in two. A codemod moves your code from the v1 package to the v2 packages. Speaking 2026-07-28 is then an explicit opt-in, because a hand-constructed v2 server connected straight to a transport keeps speaking the 2025 protocol it was written for.

  1. Inventory what the server assumes. Run the audit prompt below before you change anything. You want a list of every read of a session id, every server-initiated request, every log call and every tool whose result depends on an earlier call.

  2. Run the codemod from the package root, so that it also rewrites imports in tests and scripts:

    Terminal window
    npx @modelcontextprotocol/codemod@latest v1-to-v2 .

    It handles the mechanical renames from @modelcontextprotocol/sdk to @modelcontextprotocol/server and @modelcontextprotocol/client. It does not adopt 2026-07-28 for you.

  3. Switch to a per-request entry point. Build the server in a factory, because the 2026 path builds a fresh instance per request (HTTP) or per connection (stdio).

    // v1: new McpServer(...) + await server.connect(new StdioServerTransport())
    import { McpServer } from '@modelcontextprotocol/server';
    import { serveStdio } from '@modelcontextprotocol/server/stdio';
    import * as z from 'zod/v4';
    function buildServer() {
    const server = new McpServer({ name: 'deploy-tools', version: '2.0.0' });
    server.registerTool(
    'get_release',
    { description: 'Read one release by id', inputSchema: z.object({ releaseId: z.string() }) },
    async ({ releaseId }) => ({ content: [{ type: 'text', text: await readRelease(releaseId) }] }),
    );
    return server;
    }
    serveStdio(buildServer); // serves 2026-07-28 and, by default, 2025-era openings

    For HTTP, use createMcpHandler(buildServer) from @modelcontextprotocol/server. On Node frameworks, wrap it with toNodeHandler from @modelcontextprotocol/node, for example app.all('/mcp', toNodeHandler(handler)). The default legacy: 'stateless' also serves 2025-era clients per request.

  4. Replace session state. A cache or auth context keyed on the session id has no key anymore. Return an explicit handle from the tool that creates the state (for example releaseId) and make the next tool take it as an argument; the model sees the handle and threads it through. For a multi-step flow inside one call, use requestState, and sign it with createRequestStateCodec({ key }), because the client echoes it back and it is untrusted input. The key must be at least 32 bytes and come from your secret store, never from source code.

  5. Replace server-initiated requests. Where a tool called elicitInput or createMessage, it now returns inputRequired(...) and reads the answer from ctx.mcpReq.inputResponses on the retry. The same handler still runs for 2025-era clients through the SDK’s legacy shim.

  6. Move logging off the protocol. Log to stderr (never stdout on stdio) or to OpenTelemetry. ctx.mcpReq.log() sends nothing on a 2026 request unless the client set logLevel in _meta, and the SDK client does not set it by default.

  7. Sort tools/list. Register tools in a fixed order, so that the list is identical on every request and client-side caches stay valid.

  8. Keep an old sessionful deployment running if you must. Put a strict handler (createMcpHandler(buildServer, { legacy: 'reject' })) behind a branch on isLegacyRequest(request) and send legacy traffic to your existing v1 wiring until its clients are gone.

PyPI mcp 2.x implements 2026-07-28 and serves both eras from the same streamable_http_app() or stdio server, with no flag to set. The changes land in your code:

  • pip install mcp now installs 2.x. Pin mcp<2 in every project that is not migrated yet, or an unpinned build upgrades it for you.
  • FastMCP is now MCPServer: from mcp.server import MCPServer. Transport options such as host, port and stateless_http move from the constructor to run() and the app builders.
  • ctx.elicit() and ctx.session.create_message() raise NoBackChannelError on a 2026 connection. Move the question into a parameter annotated with Resolve(...), which asks over whichever mechanism the connection supports.
  • The sealed request_state key is minted per process by default. Behind a load balancer, pass RequestStateSecurity(keys=[...]) so that every replica can verify a retry.
  • The v2 migration guide lists the experimental Tasks support as removed. Until you have confirmed Tasks extension support in the SDK version you pin, keep long-running work behind your own job id and a status tool.

Prove the server works in both protocol eras

Section titled “Prove the server works in both protocol eras”

A green dot in one client proves one era. The gate that matters connects twice, once pinned to 2026-07-28 and once on the 2025 handshake, and fails when either connection lists no tools, a known call breaks, or the two eras expose different tool names. Pin mode never falls back, so a server that silently serves only the old protocol fails the first connection instead of passing.

// scripts/mcp-era-smoke.mjs (run: MCP_URL=http://localhost:3000/mcp node scripts/mcp-era-smoke.mjs)
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
const url = new URL(process.env.MCP_URL ?? 'http://localhost:3000/mcp');
const modes = [{ pin: '2026-07-28' }, 'legacy'];
const names = [];
for (const mode of modes) {
const client = new Client({ name: 'era-smoke', version: '1.0.0' }, { versionNegotiation: { mode } });
await client.connect(new StreamableHTTPClientTransport(url));
const { tools } = await client.listTools();
console.log(`${client.getProtocolEra()}: ${tools.length} tools`);
if (tools.length === 0) process.exitCode = 1;
names.push(JSON.stringify(tools.map((t) => t.name).sort()));
try {
// a known call with a fixture id must succeed in both eras
const result = await client.callTool({
name: 'get_release',
arguments: { releaseId: process.env.FIXTURE_ID ?? 'r-102' },
});
if (result.isError) process.exitCode = 1;
} catch (err) {
console.error(`${client.getProtocolEra()}: get_release failed`, err);
process.exitCode = 1;
}
await client.close();
}
// both eras must expose the same tool names
if (names[0] !== names[1]) {
console.error('tool lists differ between eras');
process.exitCode = 1;
}

The expected output is one modern line and one legacy line with the same tool count, and exit code 0; a failed get_release call or differing tool names set exit code 1. An ERA_NEGOTIATION_FAILED error on the first line means the server never offered 2026-07-28. For tests without a socket, pass the handler’s fetch to the transport (fetch: (u, init) => handler.fetch(new Request(u, init))), which serves the request in-process. In Python, Client(mcp) against the server object negotiates 2026-07-28 by default; pin a second test client to mode="legacy" to cover the old path.

To check a running server by hand without writing code, use the MCP Inspector CLI (@modelcontextprotocol/inspector 2.8.0 on 2026-09-26). It connects on the legacy era unless you say otherwise, so pass --protocol-era explicitly both times:

Terminal window
npx @modelcontextprotocol/inspector --cli http://localhost:3000/mcp --transport http --protocol-era modern --method tools/list
npx @modelcontextprotocol/inspector --cli http://localhost:3000/mcp --transport http --protocol-era legacy --method tools/list

Both commands should print the same tool names. For a stdio server, replace the URL and --transport http with the launch command, for example node build/index.js.

Then register the server in each agent you support and call one read-only tool. The commands differ per tool:

Register the local server, then compare the two eras. Claude Code probes HTTP servers for 2026-07-28 by default:

Terminal window
claude mcp add --transport http deploy-tools http://localhost:3000/mcp
claude mcp list # default: HTTP servers on 2026-07-28 where offered
MCP_PROTOCOL_NEGOTIATION=legacy claude mcp list # force the older handshake
MCP_PROTOCOL_NEGOTIATION=auto claude # also probe stdio servers, then call a tool

If the server works only with legacy, the bug is on its 2026 path. A stdio server that is also a channel is not registered as a channel once it negotiates 2026-07-28, because that revision cannot carry channel messages.

In any of the three agents, this prompt proves the tool works end to end and shows you what the model received:

You should see exactly one get_release call and the release record. A confirmation prompt or an input_required round trip on a read-only tool means the tool asks for input it should not need.

The migration is done when the evidence below is attached to the pull request, not when the code compiles. The server owner produces it; the tech lead who owns the team’s MCP configuration signs off.

  • The SDK is v2: @modelcontextprotocol/server 2.x, or PyPI mcp 2.x with an explicit version range.
  • No code reads a session id; cross-call state travels as explicit handles or signed requestState.
  • No tool sends a server-initiated request on the 2026 path; each question returns input_required.
  • The dual-era smoke test passes in CI, with the same tool names in both eras.
  • tools/list returns the same order on every call.
  • Logs go to stderr or OpenTelemetry; nothing writes to stdout on stdio.
  • Gateways, proxies and WAF rules pass Mcp-Method, Mcp-Name and MCP-Protocol-Version.
  • Each supported agent was tried: Claude Code in both eras, Codex on its default, Cursor by hand.
  • Rollback is written down: the previous release tag, and for Claude Code users MCP_PROTOCOL_NEGOTIATION=legacy (or MCP_SDK_GENERATION=v1) as the client-side escape hatch.

What 2026-07-28 changes about context cost

Section titled “What 2026-07-28 changes about context cost”

The revision does not shrink your tool definitions, so the tokens each tool costs in the agent’s context stay the same. What it changes is stability. A tools/list in a deterministic order, with a ttlMs freshness hint, lets a client cache the catalog and keeps the model provider’s prompt cache stable across reconnects. A server that shuffles its tools on every request defeats both. Measure before and after with /context in Claude Code, and cut tools with the patterns in reducing MCP token cost.

What breaks after migrating to MCP 2026-07-28?

Section titled “What breaks after migrating to MCP 2026-07-28?”

ERA_NEGOTIATION_FAILED: ... did not offer pinned protocol version 2026-07-28. The server still answers only initialize. Check that the entry point is serveStdio or createMcpHandler, not a direct server.connect(...), and that no proxy in front of it answers server/discover with an error page.

Logs stopped appearing. On a 2026 request the server sends notifications/message only when the client put logLevel in _meta. Log to stderr or OpenTelemetry instead of relying on protocol logging.

NoBackChannelError (Python) or a confirmation prompt that never shows. The tool still sends a server-initiated elicitation. Return input_required in TypeScript, or move the question into a Resolve(...) parameter in Python.

Retries fail with an invalid-params error behind a load balancer. Each replica signs requestState with its own per-process key, so a retry that lands on another replica fails verification. Configure one shared key set (createRequestStateCodec({ key }) in TypeScript, RequestStateSecurity(keys=[...]) in Python) and rotate it like any other secret.

Requests fail with HeaderMismatch (-32020) or never reach the server. A gateway rewrote or dropped Mcp-Method or Mcp-Name. Pass both headers through and let the gateway route on them. If browser-based clients call the server, add Mcp-Method, Mcp-Name and MCP-Protocol-Version to Access-Control-Allow-Headers, because a browser blocks custom headers that the CORS preflight does not allow.

The server works in Codex and fails in Claude Code. Codex speaks 2025, Claude Code speaks 2026 with HTTP servers. Run MCP_PROTOCOL_NEGOTIATION=legacy claude to confirm, which also serves as the users’ workaround, and fix the 2026 path with the smoke test above.

A Rust stdio server exits as soon as a client connects. Servers on the Rust SDK (rmcp) exit on any request before initialize, including the server/discover probe. The TypeScript client probes on a disposable sibling process for this reason. If you write your own client, probe the same way or pin mode: 'legacy' for that server.

A client still matches the old resource error code. Resource-not-found is now -32602, not -32002. Update tests that assert the old number.