Your MCP server negotiates 2025-06-18 and the SDK under it is a major version behind. Client teams are asking for the 2026-07-28 revision, and the changelog runs to four screens of removed methods. Nobody can say which of the six clients you support will still connect once the upgrade ships.
What you get
You will end up with a server that serves both protocol eras from one tool module, and a matrix that runs one scripted task against every client you support. This is for you if you own an MCP server with clients you do not control.
Short answer
Upgrade the SDK, replace the hand-wired stdio transport with serveStdio, and keep the default
that still answers the initialize handshake. Then run one scripted task against every client
you support, handshake-era and modern, and read the matrix. Reject the handshake only when the
last handshake-era client is gone. Structured output is additive, so add outputSchema without
waiting for anyone.
You will need
A running MCP server on a handshake-era revision, the list of clients you support, and Node 22
or later. Verified 2026-09-25 against Node 22.22.2, @modelcontextprotocol/sdk 1.30.1,
@modelcontextprotocol/server 2.1.0, @modelcontextprotocol/client 2.1.0, and zod 4.4.2,
with the Python client against mcp 2.2.0. The two eras are defined on the
versioning page of
the specification: a legacy revision opens with initialize, and a modern one carries its
version in the _meta of every request.1
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Pinning a revision and dual advertising capabilities | Clients you cannot update still open with initialize | A session leg to operate, sticky routing for it on HTTP, and a matrix kept green for two eras | Every client you support already probes with server/discover |
| Rewriting tools to the structured output shape | Agents read the result as data and you want the schema listed | An outputSchema per tool to keep true, and text content you still write for older clients | The result is prose an agent reads once and never parses |
| Upgrading the official SDK in place | One clean cut, because every client you support probes for the era | A codemod pass plus manual renames, and a transport that serves 2025 only until you switch entry point | A client you support cannot move before you do |
The upgrade and the pin are not rivals. Upgrading the SDK is what makes serving both eras possible, and its cost is the renames and a changed entry point. The pin is what you keep after it, at the price of a session leg that needs sticky routing behind a load balancer. Structured output is separate from both, and it costs a schema you have to keep true.
Read the changelog for what your clients will notice
The
2026-07-28 changelog lists
nine major changes, and three of them decide whether a given client connects at all. The
initialize handshake is gone. Every request carries its protocol version and the client’s
capabilities in _meta, a version the server does not speak is answered with
UnsupportedProtocolVersionError, and every server must implement server/discover. Every
result carries a required resultType, and the experimental tasks of 2025-11-25 moved out of the
core protocol into an extension. The error codes introduced in the draft were renumbered before
it shipped.2
The SDK side has its own list. The
upgrade guide for the
TypeScript packages splits @modelcontextprotocol/sdk into client, server, and core, and
renames McpError to ProtocolError. It rewrites .tool() to registerTool with the raw Zod
shape wrapped in z.object(), and renames the handler’s extra argument to ctx. A codemod
package does the fixed renames and marks what it could not rewrite. The guide also says the part
that matters most here: a server hand-wired to StdioServerTransport serves the 2025 era only,
whatever SDK version sits under it.
Serve both eras from one entry point
The whole change to the server, after the renames, is the entry point. serveStdio takes a
factory instead of an instance, calls it once per connection, and tells it which era the client
opened with.
export function buildServer({ era }) {
const server = new McpServer({ name: 'meters', version: '2.0.0' })
server.registerTool(
'meter_read',
{
description: 'Read one meter by id.',
inputSchema: z.object({ id: z.string().describe('Meter id, such as mtr_8f2') }),
// The structured shape is additive. content still carries the text, so a
// client that predates structured output reads the same answer as before.
outputSchema: z.object({ id: z.string(), kwh: z.number() }),
},
async ({ id }) => ({
content: [{ type: 'text', text: `${id} reads 4180 kWh (served as ${era})` }],
structuredContent: { id, kwh: 4180 },
}),
)
return server
}
// 'serve' is the default: a 2025 client gets the initialize handshake it expects.
// 'reject' answers that handshake with -32022 and the versions this server does speak.
const legacy = process.argv.includes('--reject-legacy') ? 'reject' : 'serve'
serveStdio(buildServer, { legacy })
The legacy option is the pin. Under serve, a client that sends initialize gets the handshake
and a server instance pinned to that connection. Under reject, it gets error -32022 with the
supported versions attached, and the connection stays open for a modern opening. The
legacy clients page documents
the same option on the HTTP handler, where the default is per-request and stateless, so a
handshake-era GET or DELETE answers 405.
The tool reports the era it was served from inside its own text. That is a test hook: it lets the matrix check that both sides agree.
Run the same task against every client
Keep the client list in code, not in a wiki. Each entry opens a connection the way that client does, and the matrix runs one task through every pair.
export const CLIENTS = {
'2025 client': (args) => ({
client: new Client2025({ name: 'matrix', version: '0.0.0' }),
transport: new Stdio2025({ command: 'node', args, stderr: 'ignore' }),
}),
'2026 client, default': (args) => client2026(args, {}),
'2026 client, auto': (args) => client2026(args, { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 5000 } } }),
'2026 client, pinned': (args) => client2026(args, { versionNegotiation: { mode: { pin: '2026-07-28' }, probe: { timeoutMs: 5000 } } }),
}
The four entries stand in for the clients you support. The 2025 client is the previous SDK’s
Client, which only knows the handshake.3 The three 2026 clients differ in one option:
versionNegotiation, which
defaults to the handshake, probes with server/discover under auto, and refuses to fall back
under a pin.
node matrix.mjs
@modelcontextprotocol/sdk 1.30.1 | @modelcontextprotocol/server 2.1.0 | @modelcontextprotocol/client 2.1.0
2025 server
2025 client ok legacy mtr_8f2 reads 4180 kWh
2026 client, default ok legacy mtr_8f2 reads 4180 kWh
2026 client, auto ok legacy mtr_8f2 reads 4180 kWh
2026 client, pinned FAIL ERA_NEGOTIATION_FAILED: Version negotiation failed: the server did not offer pinned protocol v
2026 server
2025 client ok legacy mtr_8f2 reads 4180 kWh (served as legacy)
2026 client, default ok legacy mtr_8f2 reads 4180 kWh (served as legacy)
2026 client, auto ok modern mtr_8f2 reads 4180 kWh (served as modern)
2026 client, pinned ok modern mtr_8f2 reads 4180 kWh (served as modern)
2026 server, legacy rejected
2025 client FAIL -32022, supported 2026-07-28: MCP error -32022: Unsupported protocol version: 2025-11-25
2026 client, default FAIL -32022, supported 2026-07-28: Unsupported protocol version: 2025-11-25
2026 client, auto ok modern mtr_8f2 reads 4180 kWh (served as modern)
2026 client, pinned ok modern mtr_8f2 reads 4180 kWh (served as modern)
Read the middle block first. The upgraded server with the default legacy: 'serve' answers all
four clients, and the era in the tool text matches the era each client reports. That is the
compatible period: nothing you support broke, and clients that probe get the modern era without
being asked to.
The bottom block is the clean cut. Rejecting the handshake fails both clients that open with it,
and the error carries supported 2026-07-28, which is the only diagnostic a handshake-era client
can show its user. The top block is the mirror image. A client pinned to 2026-07-28 fails against
the old server before any tool is called, because that server answers server/discover with
-32601. The specification treats any error that is not a recognized modern one as the legacy
signal, and a pin forbids acting on it.
The Python SDK’s client makes the same choice with one argument. client.py connects to the
upgraded server twice, with mode="legacy" and with the default, and prints 2025-11-25 then
2026-07-28. It ran here against mcp 2.2.0 and is not part of the captured output, because
the runner that re-checks these pages carries no Python packages.
Add structured output without breaking the text
Structured tool output arrived in 2025-06-18, so a client on 2025-03-26 has never heard of
structuredContent. Pin the handshake to each older revision in turn and read what the upgraded
server’s result looks like from there.
node structured.mjs
asked for 2025-03-26, negotiated 2025-03-26
tools/list entry carries name, description, inputSchema, outputSchema
tools/call result carries content, structuredContent
content text mtr_8f2 reads 4180 kWh (served as legacy)
structuredContent {"id":"mtr_8f2","kwh":4180}
asked for 2025-06-18, negotiated 2025-06-18
tools/list entry carries name, description, inputSchema, outputSchema
tools/call result carries content, structuredContent
content text mtr_8f2 reads 4180 kWh (served as legacy)
structuredContent {"id":"mtr_8f2","kwh":4180}
asked for 2025-11-25, negotiated 2025-11-25
tools/list entry carries name, description, inputSchema, outputSchema
tools/call result carries content, structuredContent
content text mtr_8f2 reads 4180 kWh (served as legacy)
structuredContent {"id":"mtr_8f2","kwh":4180}
The SDK sends the same result to all three. A client that predates the field ignores it and reads
content, which is why the text has to stay complete on its own.
Check it worked
Six tests pin the matrix, so a change to the client list or the entry point fails in CI rather than in a customer’s terminal.
node --test upgrade.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 3536.464412
The assertions are the ones the matrix output shows. Every client works against the dual-era
server, with matching eras on both sides. The rejecting server answers -32022 and names
2026-07-28, the pinned client fails on the old server with ERA_NEGOTIATION_FAILED, and a
2025-03-26 connection still receives both content and structuredContent.
When it goes wrong
A client fails with ERA_NEGOTIATION_FAILED before any tool call. The client is pinned and the
server is still on the handshake era, so the probe was answered with an error and the pin
forbids the fallback. Use auto on that client until the server is upgraded, or upgrade the
server first.
A handshake-era client reports Unsupported protocol version and stops. The server rejects the
handshake. Nothing on the client side can fall forward, so the fix is on the server: return to
legacy: 'serve' until that client is retired.
A stdio client with auto hangs for seconds before connecting. The
protocol versions page explains
the probe rides a disposable sibling process, and a legacy server that never answers unknown
methods stalls it for the whole probe timeout. Set probe.timeoutMs, and do not default a
spawn-per-invocation CLI to auto.
A client on the HTTP with SSE transport cannot connect. The 2.x server never serves that transport, and the legacy clients page describes the frozen copy to mount while that client is retired.
When not to do this
Do not reject the handshake to hurry clients along. A handshake-era client has no way to move forward on its own, and the matrix shows what it sees: one error and no tool call. The compatible period costs you a session leg and a longer test matrix. The clean cut costs the clients you did not know about.
Do not upgrade the SDK and hand-wire StdioServerTransport again out of habit. That server
serves the 2025 era only, so the packages moved and the wire did not. The entry point is the
upgrade.
Do not advertise a capability your oldest client cannot negotiate. If one client you support opens with 2025-03-26, keep the tool text complete, keep the handshake served, and let the matrix tell you when that row is empty.
Do not trust a green matrix for a client that is not in it. The list in clients.mjs is the
claim the page makes, and a client team that was never added is a cell that was never run.
Related how-tos
Last verified
Verified 2026-09-25 against Node 22.22.2, @modelcontextprotocol/sdk 1.30.1,
@modelcontextprotocol/server 2.1.0, @modelcontextprotocol/client 2.1.0, and zod 4.4.2.
Every output block is what the command preceding it printed. The Python client in client.py
ran against mcp 2.2.0 on Python 3.11 and is not part of the captured output.
Footnotes
-
The specification’s own compatibility matrix has seven outcome rows, and two of them read Fails: a modern client on a legacy server, and a legacy client on a modern one. The second row ends on the fact that legacy clients have no fall-forward mechanism. The section before the matrix supplies the advice: a modern-only server should name the versions it supports in whatever error it returns to
initialize. That message may be the only diagnostic such a client can surface. The document then specifies the error and leaves the surfacing to the client. ↩︎ Back to text -
JSON-RPC 2.0 reserved
-32000to-32099for implementation-defined server errors in 2010. The 2026-07-28 base protocol partitions that reservation: the first twenty numbers stay with whatever implementations had already taken, and the remaining eighty belong to the specification. The changelog records that the three codes the draft introduced were renumbered to fit, soUnsupportedProtocolVersionmoved from-32004to-32022before the revision shipped. A client written against the draft of the draft sees its version declined under a number it does not recognize. ↩︎ Back to text -
SUPPORTED_PROTOCOL_VERSIONSon the last 1.x package and on the 2.x packages lists the same five handshake-era revisions,2024-10-07through2025-11-25, and the upgrade guide says so.2026-07-28is not on it. The list describes what theinitializehandshake may offer, and the revision that removed the handshake has nothing to offer there. ↩︎ Back to text