Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/migration-doc-findings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Docs-only: migration-guide corrections from real v1-to-v2 migrations. No package changes.
12 changes: 11 additions & 1 deletion docs/migration/index.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
---
title: Migration Guides
children:
- ./upgrade-to-v2.md
- ./support-2026-07-28.md
---

# MCP TypeScript SDK — Migration Guides

Pick the guide for your starting point.
Expand All @@ -12,9 +19,12 @@ v2 packages (`@modelcontextprotocol/client`, `@modelcontextprotocol/server`, …
Start by running the codemod:

```bash
npx @modelcontextprotocol/codemod@alpha v1-to-v2 ./src
npx @modelcontextprotocol/codemod@alpha v1-to-v2 .
```

Run it at the package root (`.`) — real projects import the SDK from `test/`,
`scripts/`, and fixtures too, and those rewrites are missed when you point it at `./src`.

The codemod handles most mechanical renames. The guide covers what it can't. The
codemod handles the v1→v2 SDK surface upgrade only — adopting the 2026-07-28 protocol
revision (`createMcpHandler`, multi-round-trip requests, `versionNegotiation`) is
Expand Down
106 changes: 77 additions & 29 deletions docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
---
title: Supporting protocol revision 2026-07-28
---

# Supporting protocol revision 2026-07-28

This guide is for code **already on the v2 packages** that wants to speak the 2026-07-28
protocol revision — and for code written against an earlier **v2 alpha** that read
wire-only members directly. If you are on `@modelcontextprotocol/sdk` (v1.x), start with
[upgrade-to-v2.md](./upgrade-to-v2.md) instead.

> **Schema artifact:** until the revision is finalized, the spec repository publishes
> the 2026-07-28 schema under `schema/draft/` — there is no `schema/2026-07-28/`
> directory yet. Tooling that vendors per-revision schema artifacts should track
> `draft/` and note the divergence.

Nothing in v2 puts a 2026-07-28 byte on the wire by default: a hand-constructed
`Client` / `Server` / `McpServer` keeps speaking the 2025-era protocol it was written
for. Serving or speaking 2026-07-28 is always an explicit opt-in via one of the entries
Expand Down Expand Up @@ -49,6 +58,14 @@ client.getProtocolEra(); // 'modern' | 'legacy'
- **`mode: { pin: '2026-07-28' }`** — modern only; no fallback, `connect()` rejects with
`SdkError(EraNegotiationFailed)` against a 2025-only server.

`ProtocolOptions.supportedProtocolVersions` — the same option that pins what the legacy
`initialize` handshake offers (see
[upgrade-to-v2.md › Client connection & dispatch](./upgrade-to-v2.md#client-connection--dispatch))
— shapes `'auto'`: the modern candidates are the option's modern entries (when it lists
any; otherwise the SDK's default modern set), and legacy fallback is available only if
the list has a pre-2026 entry. A `{ pin }` is honored as given — it must name a modern
revision but is not checked against the list.

#### Probe policy

Failure semantics under `'auto'` are deliberately conservative but never silent about
Expand Down Expand Up @@ -78,8 +95,19 @@ versionNegotiation: {
continuation — select-and-continue with a mutual version — is a separate negotiation step
and is never counted against it).

Once a modern era is negotiated the client auto-attaches the per-request `_meta`
envelope to every outgoing request and notification. A gateway/worker fleet can skip the
**Who should not default to `'auto'`:** spawn-per-invocation CLI and debugging tools.
On stdio, a legacy server that never answers unknown pre-`initialize` requests stalls
`connect()` for the full probe timeout before falling back; and the probe round trip
changes recorded transcripts/raw logs, which matters for tools whose value is
byte-stable observation. Such tools should keep the default and expose `'auto'` /
a pin as an explicit flag.

The probe request itself already carries the per-request `_meta` envelope
(`io.modelcontextprotocol/protocolVersion`, `clientInfo`, `clientCapabilities`) —
**before** the era is known. Once a modern era is negotiated the client auto-attaches
the envelope to every outgoing request and notification. Tooling that classifies
traffic must not treat "saw an envelope" as "modern era negotiated": the legacy-fallback
path also begins with one enveloped probe. A gateway/worker fleet can skip the
probe entirely with `client.connect(transport, { prior: persistedDiscoverResult })`.

### Server over HTTP: `createMcpHandler`
Expand Down Expand Up @@ -151,6 +179,22 @@ A client whose connection negotiated a modern era drops inbound server→client
requests (the 2026 era has no such channel) instead of answering them; legacy-era
connections are unchanged.

### In-process testing

There is no in-memory serving entry — `InMemoryTransport.createLinkedPair()` connects
2025-era instances only. To exercise 2026-07-28 behavior in tests without sockets,
drive `createMcpHandler` directly through its fetch function:

```typescript
const handler = createMcpHandler(buildServer);
const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), {
fetch: (url, init) => handler.fetch(new Request(url, init))
});
```

The URL is never dialed — `handler.fetch` serves the request in-process. For stdio-era
coverage, spawn `serveStdio` as a child process.

### Client cancellation on Streamable HTTP

On a 2026-07-28 Streamable HTTP connection, aborting an in-flight client request
Expand Down Expand Up @@ -227,12 +271,12 @@ toward a 2025-era peer, or any `tasks/*` method toward a 2026-era peer) throws

If you were on a v2 alpha and consumed wire schemas directly:

| v2-alpha pattern | Mechanical fix |
| --- | --- |
| parsing wire bytes with `EmptyResultSchema` that may carry `resultType` | strip `resultType` first (the schema now rejects it as an unknown key) |
| `specTypeSchemas` / `SpecTypeName` references to task message types or `RequestMetaEnvelope` | remove — these validators left the public set (the **types** remain importable) |
| `ClientRequest` / `ServerResult` / … aggregate types expected to include task members | use the individual deprecated `Task*` types — role aggregates are now the neutral (task-free) sets |
| relying on `isCallToolResult` to reject wire-only members | guards validate neutral shapes (loose passthrough); validate raw wire traffic with a transport-level parse |
| v2-alpha pattern | Mechanical fix |
| -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| parsing wire bytes with `EmptyResultSchema` that may carry `resultType` | strip `resultType` first (the schema now rejects it as an unknown key) |
| `specTypeSchemas` / `SpecTypeName` references to task message types or `RequestMetaEnvelope` | remove — these validators left the public set (the **types** remain importable) |
| `ClientRequest` / `ServerResult` / … aggregate types expected to include task members | use the individual deprecated `Task*` types — role aggregates are now the neutral (task-free) sets |
| relying on `isCallToolResult` to reject wire-only members | guards validate neutral shapes (loose passthrough); validate raw wire traffic with a transport-level parse |

The `resultType` / `EmptyResultSchema` / `specTypeSchemas` rules above have **no v1.x
impact** — these members did not exist before 2026-07-28. The neutral-model wire
Expand All @@ -258,6 +302,10 @@ and the multi-round-trip retry fields (`inputResponses`, `requestState`).
- **`resultType` is gone from every public result type** (`Result`, `CallToolResult`,
`GetPromptResult`, …). The wire schemas keep parsing it, and the protocol layer
consumes it before results reach your code.
- **`DiscoverResult` strips its cache fields too.** `ttlMs` / `cacheScope` on
`server/discover` are wire-only — consumed by the client's response-cache layer and
absent from the public `DiscoverResult` type returned by `getDiscoverResult()`.
Tooling that displays the server's advertised cache policy must parse raw frames.
Comment on lines +305 to +308

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The "strips its cache fields too" / "wire-only" claim here is only true at the type level: nothing at runtime removes ttlMs/cacheScope — the probe path stores the wire-parse output verbatim and getDiscoverResult() returns it with both fields still present (the client's own _freshness() reads them off that runtime body). Reword to scope the bullet to the public type surface (the fields are undeclared on DiscoverResult, readable via a cast, with the .catch() 0/'private' defaults making absent-vs-default indistinguishable) — "must parse raw frames" overstates it.

Extended reasoning...

What the bullet claims vs. what the code does. The new bullet (docs/migration/support-2026-07-28.md:305-308) says DiscoverResult "strips its cache fields too", that ttlMs / cacheScope are "wire-only — consumed by the client's response-cache layer", and that "tooling that displays the server's advertised cache policy must parse raw frames." Only the type-level half holds. Unlike the resultType bullet directly above it (which describes a real runtime delete), nothing in the implementation removes the cache fields from the value getDiscoverResult() returns.

The code path. The 2026 wire DiscoverResultSchema explicitly declares ttlMs (.catch(0)) and cacheScope (.catch('private')) (packages/core-internal/src/wire/rev2026-07-28/schemas.ts:808-822), so the parse output carries both fields. The probe path stores that output verbatim: probeClassifier.ts classifyResult() returns { kind: 'modern', ..., discover: parsed.value } (packages/client/src/client/probeClassifier.ts:137-152), _connectNegotiated assigns this._discoverResult = result.discover (packages/client/src/client/client.ts:1093), and getDiscoverResult() returns it as-is (client.ts:1290-1291). The codec's decode lift consumes only resultType (delete lifted['resultType'], codec.ts:258-261); there is no equivalent strip of the cache fields anywhere. The discover() typed-verb path validates with the neutral DiscoverResultSchema, which extends the loose ResultSchema, so the runtime keys survive there too.

Why "consumed by the client's response-cache layer" is the opposite of stripping. Client._freshness() reads the cache hints off the runtime result body, and its JSDoc says exactly that: "The fields pass through the loose result schema, so they are read off the runtime body" (client.ts:~1660-1675). The SDK relies on the fields not being stripped — "consumed" here means "read", not "removed".

Step-by-step proof. (1) A client with versionNegotiation: { mode: 'auto' } connects to a 2026-capable server; the server/discover response body carries ttlMs: 30000, cacheScope: 'public'. (2) classifyResult() parses it with the 2026 wire codec — parsed.value includes both fields (the wire schema declares them; nothing deletes them). (3) client.ts:1093 stores parsed.value as this._discoverResult. (4) client.getDiscoverResult() returns that exact object. (5) (client.getDiscoverResult() as Record<string, unknown>).ttlMs reads 30000 — no raw-frame parsing needed. A v2-alpha consumer auditing tooling against this bullet would conclude such reads stopped working at runtime; in fact they keep working (only the TypeScript declaration is gone).

The accurate part, and the genuine caveat. The public neutral DiscoverResultSchema (packages/core-internal/src/types/schemas.ts:577) does not declare the cache fields, and StripWireOnly only removes resultType (types/types.ts:211-218) — so the type-level claim ("absent from the public DiscoverResult type") is correct. There is also one real reason a faithful policy display might want raw frames: on the probe path the wire schema's .catch() fills 0 / 'private' for absent or malformed hints, so a runtime read cannot distinguish "server advertised 0/private" from "server omitted the fields".

Why it matters. This is a docs-only PR whose stated bar is that every claim was verified against the SDK source, and the bullet deliberately echoes the resultType bullet above it — which is a real runtime delete — making the over-claim more misleading. All three verifiers confirmed against current main; none refuted.

How to fix. Reword to scope the claim to the type surface, e.g.: "ttlMs / cacheScope on server/discover are undeclared on the public DiscoverResult type returned by getDiscoverResult() — the client reads them off the runtime body for its response cache, and they remain present on the returned object (readable via a cast). Note the wire schema's lenient defaults mean absent or malformed hints read back as 0 / 'private'; tooling that must distinguish 'omitted' from 'advertised default' should parse raw frames." Drop the "strips" / "wire-only" wording.

- **High-level methods return the named public types** (`client.callTool()` →
`Promise<CallToolResult>`, etc.). Handler return positions are unaffected.
- **Reserved envelope keys and retry fields appear in no public params/result type.**
Expand Down Expand Up @@ -287,11 +335,11 @@ The protocol layer enforces the same boundary at runtime:

**If you were on a v2 alpha** and read the wire shape directly:

| Pattern | Mechanical fix |
| --- | --- |
| `result.resultType` (typed read) | delete the read — the SDK consumes the field; results are complete when delivered |
| `Result['resultType']` type reference | remove; the member is no longer declared |
| return-type capture of `callTool` etc. | use the named public types (`CallToolResult`, `ListToolsResult`, …) |
| Pattern | Mechanical fix |
| -------------------------------------- | --------------------------------------------------------------------------------- |
| `result.resultType` (typed read) | delete the read — the SDK consumes the field; results are complete when delivered |
| `Result['resultType']` type reference | remove; the member is no longer declared |
| return-type capture of `callTool` etc. | use the named public types (`CallToolResult`, `ListToolsResult`, …) |

`MessageExtraInfo.classification` is an optional carrier (`{ era, revision?, envelope? }`)
for transports that classify inbound messages at the edge; dispatch validates it against
Expand All @@ -306,11 +354,11 @@ obtain client input (elicitation, sampling, roots) **in-band** by returning
`inputRequired(...)` from a `tools/call` / `prompts/get` / `resources/read` handler; the
client retries the original call with the responses.

| Handler serving 2026-07-28 requests | Mechanical fix |
| --- | --- |
| Handler serving 2026-07-28 requests | Mechanical fix |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `await ctx.mcpReq.elicitInput({…})` / `requestSampling({…})` | `return inputRequired({ inputRequests: { id: inputRequired.elicit({…}) } })`; read `acceptedContent(ctx.mcpReq.inputResponses, 'id')` on re-entry |
| `throw new UrlElicitationRequiredError([…])` | `return inputRequired({ inputRequests: { id: inputRequired.elicitUrl({…}) } })` |
| handler shared across both eras | branch on the served era: keep the push-style call toward 2025-era requests, return `inputRequired(...)` toward 2026-07-28 requests |
| `throw new UrlElicitationRequiredError([…])` | `return inputRequired({ inputRequests: { id: inputRequired.elicitUrl({…}) } })` |
| handler shared across both eras | branch on the served era: keep the push-style call toward 2025-era requests, return `inputRequired(...)` toward 2026-07-28 requests |

`inputRequired` / `acceptedContent` / `InputRequiredSpec` are exported from
`@modelcontextprotocol/server`. On 2026-era requests the push-style APIs
Expand Down Expand Up @@ -429,15 +477,15 @@ The experimental tasks **interception** layer is removed entirely — see

## Appendix: 2025-era vs 2026-era behavior matrix

| Axis | 2025-era (2024-10-07 … 2025-11-25) | 2026-07-28 |
| --- | --- | --- |
| Server HTTP entry | `*StreamableHTTPServerTransport` | `createMcpHandler` (`legacy: 'stateless'` also serves 2025) |
| Server stdio entry | `server.connect(new StdioServerTransport())` | `serveStdio(factory)` (also serves 2025 unless `legacy: 'reject'`) |
| Client connect | `initialize` handshake | `server/discover` probe (`versionNegotiation`) |
| Client identity | `getClientCapabilities()` / `getClientVersion()` (initialize-scoped) | `ctx.mcpReq.envelope` (per request) |
| Server→client requests | `ctx.mcpReq.elicitInput` / `requestSampling`, instance `createMessage()` etc. | `return inputRequired(...)` from handler |
| Change notifications | unsolicited `list_changed` / `resources/updated` | `subscriptions/listen` stream |
| Client cancellation (Streamable HTTP) | POST `notifications/cancelled` | close the request's SSE response stream |
| `ctx.mcpReq.log()` level filter | session-scoped `logging/setLevel` | per-request `_meta.logLevel` envelope key (absent = opt-out) |
| `400` JSON-RPC error body | `SdkHttpError` | `ProtocolError` (in-band) |
| Era-mismatched spec method (outbound) | n/a | `SdkError(MethodNotSupportedByProtocolVersion)` |
| Axis | 2025-era (2024-10-07 … 2025-11-25) | 2026-07-28 |
| ------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| Server HTTP entry | `*StreamableHTTPServerTransport` | `createMcpHandler` (`legacy: 'stateless'` also serves 2025) |
| Server stdio entry | `server.connect(new StdioServerTransport())` | `serveStdio(factory)` (also serves 2025 unless `legacy: 'reject'`) |
| Client connect | `initialize` handshake | `server/discover` probe (`versionNegotiation`) |
| Client identity | `getClientCapabilities()` / `getClientVersion()` (initialize-scoped) | `ctx.mcpReq.envelope` (per request) |
| Server→client requests | `ctx.mcpReq.elicitInput` / `requestSampling`, instance `createMessage()` etc. | `return inputRequired(...)` from handler |
| Change notifications | unsolicited `list_changed` / `resources/updated` | `subscriptions/listen` stream |
| Client cancellation (Streamable HTTP) | POST `notifications/cancelled` | close the request's SSE response stream |
| `ctx.mcpReq.log()` level filter | session-scoped `logging/setLevel` | per-request `_meta.logLevel` envelope key (absent = opt-out) |
| `400` JSON-RPC error body | `SdkHttpError` | `ProtocolError` (in-band) |
| Era-mismatched spec method (outbound) | n/a | `SdkError(MethodNotSupportedByProtocolVersion)` |
Loading
Loading