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.
What changed in MCP 2026-07-28?
Section titled “What changed in MCP 2026-07-28?”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-28 | What it breaks in your server | Replace it with |
|---|---|---|
initialize / notifications/initialized removed | Code that reads client capabilities or client info once, at connect time | Per-request _meta keys: io.modelcontextprotocol/protocolVersion, clientCapabilities, and optionally clientInfo |
server/discover added, and servers must implement it | A hand-rolled server that has no handler for it | The v2 SDKs answer it for you |
Mcp-Session-Id and protocol sessions removed | State keyed on the session id: caches, auth context, multi-step flows | Explicit 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 Requests | A tool that pauses mid-call to ask the user something | Return resultType: "input_required" with inputRequests; the client retries the call with inputResponses |
Every result carries a required resultType | Hand-built JSON-RPC results | "complete" for ordinary results (the SDKs set it) |
HTTP GET stream and resources/subscribe replaced by subscriptions/listen | Change notifications pushed on the standalone stream | One opted-in stream per client, tagged with a subscription id |
ping, logging/setLevel and notifications/roots/list_changed removed | Health checks built on ping, session-wide log levels | Per-request io.modelcontextprotocol/logLevel in _meta; no logs are sent when it is absent |
SSE resumability (Last-Event-ID) removed | Long calls that relied on the client resuming a broken stream | The client re-issues the request, so make tool calls safe to repeat |
Mcp-Method and Mcp-Name headers required on Streamable HTTP POSTs | Gateways and WAF rules that strip unknown headers | Allow the headers through; route and rate-limit on them |
List results carry ttlMs and cacheScope; tools/list should be in a deterministic order | Tool lists built from an unordered map | A stable sort, so clients and prompt caches see the same list |
Tasks moved to the io.modelcontextprotocol/tasks extension | Code on the experimental core tasks API | Poll with tasks/get, send input with tasks/update; tasks/list is gone |
Resource-not-found error code changed from -32002 to -32602 | Clients or tests that match on the old code | The 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.
Which clients speak MCP 2026-07-28 today?
Section titled “Which clients speak MCP 2026-07-28 today?”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.
| Client | Speaks 2026-07-28? | How to force either era for a test |
|---|---|---|
| Claude Code 2.1.283 | Yes 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 in | MCP_PROTOCOL_NEGOTIATION=auto probes stdio servers too; legacy skips the probe for every server |
| Codex 0.157.1 | No. 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 |
| Cursor | Not verified on 2026-09-26 | Test 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.
Does your MCP server need to change?
Section titled “Does your MCP server need to change?”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 today | Work required |
|---|---|
| Stateless tools on the TypeScript v1 SDK, stdio | Upgrade to v2 and switch to serveStdio. Small: the codemod does most of it |
Stateless tools on the TypeScript v1 SDK, Streamable HTTP with sessionIdGenerator: undefined | Upgrade to v2; the default createMcpHandler maps onto this setup directly |
| Anything keyed on the session id | Redesign that state as explicit handles or requestState. This is the real cost |
Tools that call elicitInput, createMessage or roots/list | Rewrite them to return input_required (TypeScript) or use Resolve(...) parameters (Python) |
| HTTP+SSE transport | Move to Streamable HTTP. The v2 server does not serve SSE |
Python on mcp 1.x | Pin 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 SDK | Implement 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.
-
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.
-
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/sdkto@modelcontextprotocol/serverand@modelcontextprotocol/client. It does not adopt 2026-07-28 for you. -
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 openingsFor HTTP, use
createMcpHandler(buildServer)from@modelcontextprotocol/server. On Node frameworks, wrap it withtoNodeHandlerfrom@modelcontextprotocol/node, for exampleapp.all('/mcp', toNodeHandler(handler)). The defaultlegacy: 'stateless'also serves 2025-era clients per request. -
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, userequestState, and sign it withcreateRequestStateCodec({ 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. -
Replace server-initiated requests. Where a tool called
elicitInputorcreateMessage, it now returnsinputRequired(...)and reads the answer fromctx.mcpReq.inputResponseson the retry. The same handler still runs for 2025-era clients through the SDK’s legacy shim. -
Move logging off the protocol. Log to
stderr(neverstdouton stdio) or to OpenTelemetry.ctx.mcpReq.log()sends nothing on a 2026 request unless the client setlogLevelin_meta, and the SDK client does not set it by default. -
Sort
tools/list. Register tools in a fixed order, so that the list is identical on every request and client-side caches stay valid. -
Keep an old sessionful deployment running if you must. Put a strict handler (
createMcpHandler(buildServer, { legacy: 'reject' })) behind a branch onisLegacyRequest(request)and send legacy traffic to your existing v1 wiring until its clients are gone.
Migrate a Python MCP server to 2026-07-28
Section titled “Migrate a Python MCP server to 2026-07-28”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 mcpnow installs 2.x. Pinmcp<2in every project that is not migrated yet, or an unpinned build upgrades it for you.FastMCPis nowMCPServer:from mcp.server import MCPServer. Transport options such ashost,portandstateless_httpmove from the constructor torun()and the app builders.ctx.elicit()andctx.session.create_message()raiseNoBackChannelErroron a 2026 connection. Move the question into a parameter annotated withResolve(...), which asks over whichever mechanism the connection supports.- The sealed
request_statekey is minted per process by default. Behind a load balancer, passRequestStateSecurity(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 namesif (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:
npx @modelcontextprotocol/inspector --cli http://localhost:3000/mcp --transport http --protocol-era modern --method tools/listnpx @modelcontextprotocol/inspector --cli http://localhost:3000/mcp --transport http --protocol-era legacy --method tools/listBoth 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:
claude mcp add --transport http deploy-tools http://localhost:3000/mcpclaude mcp list # default: HTTP servers on 2026-07-28 where offeredMCP_PROTOCOL_NEGOTIATION=legacy claude mcp list # force the older handshakeMCP_PROTOCOL_NEGOTIATION=auto claude # also probe stdio servers, then call a toolIf 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.
Codex 0.157.1 connects on the older protocol, so a normal run tests your legacy path:
codex mcp add deploy-tools --url http://localhost:3000/mcpcodex mcp listcodex exec "Call the get_release tool with releaseId r-102 and print the raw result."To try the unfinished 2026-07-28 client on one run, add --enable mcp_2026_07_28. It is under development, so a failure there is not yet a bug in your server.
Add the server to .cursor/mcp.json (this shape comes from vendor READMEs and was not tested in Cursor):
{ "mcpServers": { "deploy-tools": { "url": "http://localhost:3000/mcp" } }}Which protocol revision Cursor negotiates was not verified on 2026-09-26. After a Cursor update, open the MCP settings, confirm that the server shows its tools, and ask the agent to call one read-only tool. Keep the dual-era smoke test as the gate, because it does not depend on Cursor’s version.
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.
Sign off an MCP server migration
Section titled “Sign off an MCP server migration”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/server2.x, or PyPImcp2.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/listreturns the same order on every call. - Logs go to
stderror OpenTelemetry; nothing writes tostdouton stdio. - Gateways, proxies and WAF rules pass
Mcp-Method,Mcp-NameandMCP-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(orMCP_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.