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

Source: https://voxgig.com/howto/upgrade-an-mcp-server-to-a-newer-revision

- Audience: platform-team
- Level: advanced
- Languages: typescript, python
- Verified: 2026-09-25
- Published: 2026-09-25

## 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](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning) 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](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#backward-compatibility-with-initialization-based-versions) | 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](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content) | 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](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2) | 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](https://modelcontextprotocol.io/specification/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](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2) 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.

```ts title="server-2026.mjs"
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](https://ts.sdk.modelcontextprotocol.io/v2/serving/legacy-clients) 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.

```ts title="clients.mjs"
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`](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions), which
defaults to the handshake, probes with `server/discover` under `auto`, and refuses to fall back
under a pin.

```bash
node matrix.mjs
```

```text output
@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.

```bash
node structured.mjs
```

```text output
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.

```bash
node --test upgrade.test.mjs
```

```text output
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](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions) 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](https://ts.sdk.modelcontextprotocol.io/v2/serving/legacy-clients) 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

- [Choose between stdio and Streamable HTTP for an MCP server](/howto/choose-an-mcp-transport)

- [Run an MCP server against a mock API while you build tools](/howto/run-an-mcp-server-against-a-mock-api)

- [Keep an agent informed while an MCP tool runs for minutes](/howto/report-progress-from-a-long-running-tool)

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

[^1]: The specification's own
[compatibility matrix](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#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.

[^2]: [JSON-RPC 2.0](https://www.jsonrpc.org/specification) reserved `-32000` to `-32099` for
implementation-defined server errors in 2010. The
[2026-07-28 base protocol](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-codes)
partitions that reservation: the first twenty numbers stay with whatever implementations had
already taken, and the remaining eighty belong to the specification. The
[changelog](https://modelcontextprotocol.io/specification/2026-07-28/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.

[^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](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2) 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.