How-to › Expose your API to agents

How to upgrade an MCP server to a newer protocol revision#

Move a server from a handshake-era revision to 2026-07-28 by upgrading the SDK, then prove every client you support still connects with one scripted matrix run.

Audience
Platform team
Level
advanced
Topic
Build an MCP server
Languages
TypeScript and Python
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Pinning a revision and dual advertising capabilitiesClients you cannot update still open with initializeA session leg to operate, sticky routing for it on HTTP, and a matrix kept green for two erasEvery client you support already probes with server/discover
Rewriting tools to the structured output shapeAgents read the result as data and you want the schema listedAn outputSchema per tool to keep true, and text content you still write for older clientsThe result is prose an agent reads once and never parses
Upgrading the official SDK in placeOne clean cut, because every client you support probes for the eraA codemod pass plus manual renames, and a transport that serves 2025 only until you switch entry pointA 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.

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

  1. 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

  2. JSON-RPC 2.0 reserved -32000 to -32099 for 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, so UnsupportedProtocolVersion moved from -32004 to -32022 before 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

  3. SUPPORTED_PROTOCOL_VERSIONS on the last 1.x package and on the 2.x packages lists the same five handshake-era revisions, 2024-10-07 through 2025-11-25, and the upgrade guide says so. 2026-07-28 is not on it. The list describes what the initialize handshake may offer, and the revision that removed the handshake has nothing to offer there. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.