From 30b47a9489f90bf24a7e4a1a592934612c79e7e2 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 11:06:23 +0000 Subject: [PATCH 1/4] docs(migration): close guide gaps surfaced by v1-to-v2 upgrade trials Behavioral changes: initialize skipped on sessionId-bearing transports, async dispatch vs immediate aborts, error-message prefix removal, encode-seam -32002 mapping for deliberate throws, advertised tool-schema wire-shape changes, protocol-version pinning via supportedProtocolVersions, first-page list escape hatch, silent issuer-drop escalation in saveTokens. Errors: duck-typed .code HTTP-status reads (SdkHttpError .status vs SseError .code), raw numeric -32000/-32001 comparisons vs string SdkErrorCode. Auth: token verifiers must throw the v2 OAuthError or invalid tokens surface as HTTP 500 behind requireBearerAuth. Types & schemas: gateway/proxy passthrough result schema via @modelcontextprotocol/core ResultSchema, toJsonSchemaCompat removal note, RegisteredTool.update schema objects, zod fix ladder incl. dropped .describe() descriptions in the <4.2.0 fallback, validate() vs .parse() semantics, ElicitResult.content narrowing, Protocol/fallbackRequestHandler note. support-2026-07-28: schema artifact location, who should not default to auto, probe envelope timing, in-process testing recipe via the handler fetch override, DiscoverResult wire-only cache fields, supportedProtocolVersions cross-reference. Also: run the codemod at the package root (it rewrites package.json), typedoc frontmatter (titles + children) so deep links resolve on the rendered site, and the Unchanged-APIs footnote no longer contradicts the Types section. --- docs/migration/index.md | 11 +- docs/migration/support-2026-07-28.md | 102 +++++-- docs/migration/upgrade-to-v2.md | 440 +++++++++++++++++---------- packages/codemod/README.md | 2 +- 4 files changed, 371 insertions(+), 184 deletions(-) diff --git a/docs/migration/index.md b/docs/migration/index.md index 2e9d8bd99a..2274a7128f 100644 --- a/docs/migration/index.md +++ b/docs/migration/index.md @@ -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. @@ -12,9 +19,11 @@ 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 — it also rewrites `package.json`. + 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 diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index a094123616..a7480f203c 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -1,3 +1,7 @@ +--- +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 @@ -5,6 +9,11 @@ protocol revision — and for code written against an earlier **v2 alpha** that 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 @@ -49,6 +58,10 @@ client.getProtocolEra(); // 'modern' | 'legacy' - **`mode: { pin: '2026-07-28' }`** — modern only; no fallback, `connect()` rejects with `SdkError(EraNegotiationFailed)` against a 2025-only server. +Every mode selects only among revisions in `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)). + #### Probe policy Failure semantics under `'auto'` are deliberately conservative but never silent about @@ -78,8 +91,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` @@ -151,6 +175,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 @@ -227,12 +267,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 @@ -258,6 +298,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. - **High-level methods return the named public types** (`client.callTool()` → `Promise`, etc.). Handler return positions are unaffected. - **Reserved envelope keys and retry fields appear in no public params/result type.** @@ -287,11 +331,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 @@ -306,11 +350,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 @@ -429,15 +473,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)` | diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index c48c0a2b9c..fd32b01e1c 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -1,4 +1,5 @@ --- +title: Upgrading from v1.x to v2 name: migrate-v1-to-v2 description: Migrate MCP TypeScript SDK code from v1 (@modelcontextprotocol/sdk) to v2 (@modelcontextprotocol/core, /client, /server). Use when a user asks to migrate, upgrade, or port their MCP TypeScript code from v1 to v2. --- @@ -17,14 +18,16 @@ If you are already on v2 and want to adopt the **2026-07-28 protocol revision**, 1. **Prerequisites.** Node.js 20+ and ESM (`"type": "module"` or `.mts`). v2 ships ESM only; CommonJS callers must use dynamic `import()`. 2. **Run the codemod.** - ```bash - npx @modelcontextprotocol/codemod@alpha v1-to-v2 ./src - ``` + ```bash + npx @modelcontextprotocol/codemod@alpha v1-to-v2 . + ``` + Run it at the **package root** (`.`), not `./src` — it also rewrites `package.json`, + and real projects import the SDK from `test/`, `scripts/`, and fixtures too. 3. **Grep for markers.** Anything the codemod recognized but could not safely rewrite is marked in place: - ```bash - grep -rn '@mcp-codemod-error' . - ``` + ```bash + grep -rn '@mcp-codemod-error' . + ``` 4. **Type-check.** `tsc --noEmit` (or your build). Remaining errors map to the [manual sections](#manual-changes-what-the-codemod-does-not-handle) below. 5. **Format.** The codemod rewrites the AST without reformatting — run your formatter on @@ -58,12 +61,12 @@ The codemod ([`@modelcontextprotocol/codemod`](../../packages/codemod/README.md) mechanically applies every rename whose mapping is fixed. The mappings are the **source of truth** — they live in the codemod package and are not reproduced here: -| Mapping | Source file | -| --- | --- | -| `@modelcontextprotocol/sdk/...` import paths → v2 packages | [`mappings/importMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts) | -| Symbol renames (`McpError` → `ProtocolError`, `JSONRPCError` → `JSONRPCErrorResponse`, …) | [`mappings/symbolMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/symbolMap.ts) | -| `setRequestHandler(Schema, …)` → `setRequestHandler('method/string', …)` | [`mappings/schemaToMethodMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts) | -| `extra.*` → `ctx.mcpReq.*` / `ctx.http?.*` property remap | [`mappings/contextPropertyMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/contextPropertyMap.ts) | +| Mapping | Source file | +| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `@modelcontextprotocol/sdk/...` import paths → v2 packages | [`mappings/importMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts) | +| Symbol renames (`McpError` → `ProtocolError`, `JSONRPCError` → `JSONRPCErrorResponse`, …) | [`mappings/symbolMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/symbolMap.ts) | +| `setRequestHandler(Schema, …)` → `setRequestHandler('method/string', …)` | [`mappings/schemaToMethodMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts) | +| `extra.*` → `ctx.mcpReq.*` / `ctx.http?.*` property remap | [`mappings/contextPropertyMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/contextPropertyMap.ts) | In addition the codemod: @@ -129,12 +132,12 @@ recognized but could not safely rewrite with an `@mcp-codemod-error` comment. The single `@modelcontextprotocol/sdk` package is split: -| v1 | v2 | -| --- | --- | -| `@modelcontextprotocol/sdk` | `@modelcontextprotocol/client` (client implementation) | -| | `@modelcontextprotocol/server` (server implementation) | -| | `@modelcontextprotocol/core` (public Zod `*Schema` constants) | -| | `@modelcontextprotocol/core-internal` (internal — never import directly) | +| v1 | v2 | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `@modelcontextprotocol/sdk` | `@modelcontextprotocol/client` (client implementation) | +| | `@modelcontextprotocol/server` (server implementation) | +| | `@modelcontextprotocol/core` (public Zod `*Schema` constants) | +| | `@modelcontextprotocol/core-internal` (internal — never import directly) | | Built-in HTTP framework support | `@modelcontextprotocol/node` / `@modelcontextprotocol/express` / `@modelcontextprotocol/hono` / `@modelcontextprotocol/fastify` | `@modelcontextprotocol/client` and `@modelcontextprotocol/server` both re-export shared @@ -179,12 +182,12 @@ A few transports need a decision the codemod can't make: `import { CallToolResult, CallToolResultSchema } from '…/types.js'` is split by the codemod — see [Types & schemas](#types--schemas). - ```typescript - // v1 - import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; - // v2 - import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; - ``` + ```typescript + // v1 + import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + // v2 + import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + ``` - **`SSEServerTransport`** is removed. Migrate to Streamable HTTP. A frozen v1 copy is available from `@modelcontextprotocol/server-legacy/sse` as a temporary bridge. @@ -195,12 +198,12 @@ A few transports need a decision the codemod can't make: - **`InMemoryTransport`** is now exported from `@modelcontextprotocol/client` and `@modelcontextprotocol/server` (both re-export it): - ```typescript - // v1 - import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; - // v2 - import { InMemoryTransport } from '@modelcontextprotocol/server'; // or /client - ``` + ```typescript + // v1 + import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; + // v2 + import { InMemoryTransport } from '@modelcontextprotocol/server'; // or /client + ``` - **`EventStore`, `StreamId`, `EventId`** are exported from `@modelcontextprotocol/server` only (v1 re-exported them alongside the transport from `sdk/server/streamableHttp.js`; @@ -215,14 +218,14 @@ A few transports need a decision the codemod can't make: (deprecated, frozen v1 copy); migrate AS to a dedicated IdP/OAuth library. `AuthInfo` is now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`. - The codemod's [`importMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts) - routes every `…/server/auth/**` deep path (including - `…/server/auth/middleware/{bearerAuth,allowedMethods,clientAuth}.js`, - `…/server/auth/handlers/*.js`, `…/server/auth/providers/proxyProvider.js`) to - `@modelcontextprotocol/server-legacy/auth`, and `…/server/express.js` / - `…/server/middleware/hostHeaderValidation.js` to `@modelcontextprotocol/express`. The - AS→`server-legacy` routing is conservative — re-point RS-only call sites - (`requireBearerAuth`, `mcpAuthMetadataRouter`) at `@modelcontextprotocol/express` by hand. + The codemod's [`importMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts) + routes every `…/server/auth/**` deep path (including + `…/server/auth/middleware/{bearerAuth,allowedMethods,clientAuth}.js`, + `…/server/auth/handlers/*.js`, `…/server/auth/providers/proxyProvider.js`) to + `@modelcontextprotocol/server-legacy/auth`, and `…/server/express.js` / + `…/server/middleware/hostHeaderValidation.js` to `@modelcontextprotocol/express`. The + AS→`server-legacy` routing is conservative — re-point RS-only call sites + (`requireBearerAuth`, `mcpAuthMetadataRouter`) at `@modelcontextprotocol/express` by hand. ### Low-level protocol & handler context (`ctx`) @@ -235,29 +238,29 @@ The codemod renames the parameter and remaps property access via A few mappings need optional-chaining adjustment (the `http` group is `undefined` on stdio): -| v1 (`extra.*`) | v2 (`ctx.*`) | Note | -| --- | --- | --- | -| `extra.signal` | `ctx.mcpReq.signal` | | -| `extra.requestId` | `ctx.mcpReq.id` | | -| `extra._meta` | `ctx.mcpReq._meta` | | -| `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` | | -| `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` | | -| `extra.sessionId` | `ctx.sessionId` | | -| `extra.authInfo` | `ctx.http?.authInfo` | optional — `undefined` on stdio | -| `extra.requestInfo` | `ctx.http?.req` | a standard Web `Request`; `ServerContext` only | -| `extra.closeSSEStream` | `ctx.http?.closeSSE` | `ServerContext` only | -| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` | `ServerContext` only | -| `extra.taskStore` / `taskId` / `taskRequestedTtl` | _removed_ | see [Experimental tasks](#experimental-tasks-interception-removed) | +| v1 (`extra.*`) | v2 (`ctx.*`) | Note | +| ------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------ | +| `extra.signal` | `ctx.mcpReq.signal` | | +| `extra.requestId` | `ctx.mcpReq.id` | | +| `extra._meta` | `ctx.mcpReq._meta` | | +| `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` | | +| `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` | | +| `extra.sessionId` | `ctx.sessionId` | | +| `extra.authInfo` | `ctx.http?.authInfo` | optional — `undefined` on stdio | +| `extra.requestInfo` | `ctx.http?.req` | a standard Web `Request`; `ServerContext` only | +| `extra.closeSSEStream` | `ctx.http?.closeSSE` | `ServerContext` only | +| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` | `ServerContext` only | +| `extra.taskStore` / `taskId` / `taskRequestedTtl` | _removed_ | see [Experimental tasks](#experimental-tasks-interception-removed) | `BaseContext` is the common base; `ServerContext` and `ClientContext` extend it. `ServerContext.mcpReq` adds convenience methods that replace calling `server.*` from inside a handler: -| `ctx.mcpReq.*` (new) | Replaces (inside a handler) | -| --- | --- | -| `ctx.mcpReq.log(level, data, logger?)` | `server.sendLoggingMessage(...)` — ⚠ **`@deprecated`**, see [§Deprecated in v2](#deprecated-in-v2-sep-2577) | -| `ctx.mcpReq.elicitInput(params, options?)` | `server.elicitInput(...)` | -| `ctx.mcpReq.requestSampling(params, options?)` | `server.createMessage(...)` — ⚠ **`@deprecated`**, see [§Deprecated in v2](#deprecated-in-v2-sep-2577) | +| `ctx.mcpReq.*` (new) | Replaces (inside a handler) | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `ctx.mcpReq.log(level, data, logger?)` | `server.sendLoggingMessage(...)` — ⚠ **`@deprecated`**, see [§Deprecated in v2](#deprecated-in-v2-sep-2577) | +| `ctx.mcpReq.elicitInput(params, options?)` | `server.elicitInput(...)` | +| `ctx.mcpReq.requestSampling(params, options?)` | `server.createMessage(...)` — ⚠ **`@deprecated`**, see [§Deprecated in v2](#deprecated-in-v2-sep-2577) | #### Deprecated in v2 (SEP-2577) @@ -330,6 +333,23 @@ For **custom (non-spec)** methods, keep the result-schema argument: `await client.request({ method: 'acme/search', params }, SearchResult)` — only drop the schema when calling a spec method. +**Forwarding arbitrary methods (gateways / proxies).** Dropping the schema changes +semantics, not just the signature: a schema-less spec-method call now **enforces** the +spec result schema (a non-conforming upstream result is rejected locally with +`SdkError(SdkErrorCode.InvalidResult)` and a conforming one is re-serialized in schema +key order), and a schema-less call for an **unknown** method resolves to `undefined`. +A relay that forwards `{ method, params }` it does not understand must keep passing an +explicit result schema. The v1 idiom survives with an import-path change: + +```typescript +import { ResultSchema } from '@modelcontextprotocol/core'; +const result = await upstream.request({ method, params }, ResultSchema); // v1-identical passthrough +``` + +For byte-exact forwarding (member order preserved), pass your own accept-anything +Standard Schema instead. Check call sites whose `method` is **not a literal** — the +codemod may have dropped the schema argument there; restore it. + The return type is inferred from the method name via `ResultTypeMap` (e.g. `client.request({ method: 'tools/call', ... })` returns `Promise`). @@ -347,13 +367,9 @@ server.tool('greet', 'Greet a user', { name: z.string() }, async ({ name }) => { }); // v2 — config object, Standard Schema -server.registerTool( - 'greet', - { description: 'Greet a user', inputSchema: z.object({ name: z.string() }) }, - async ({ name }) => { - return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; - } -); +server.registerTool('greet', { description: 'Greet a user', inputSchema: z.object({ name: z.string() }) }, async ({ name }) => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; +}); ``` `registerResource` requires a `metadata` argument — pass `{}` if you have none. @@ -366,10 +382,23 @@ are still **accepted via `@deprecated` overloads** on `registerTool`/`registerPr (auto-wrapped with `z.object()`), and `completable()` accepts any `StandardSchemaV1`; prefer wrapping explicitly. Zod v4, ArkType, and Valibot all implement the spec. -**Zod v3 is no longer supported** (v1 peer was `^3.25 || ^4.0`). Passing a Zod v3 schema -hard-errors with a pointer at `fromJsonSchema()`; Zod 4.0–4.1 schemas (which lack -`~standard.jsonSchema`) work via a bundled fallback with a one-time console warning. -Upgrade to `zod ^4.2.0` or use another Standard Schema library. +**Zod v3 is no longer supported** (v1 peer was `^3.25 || ^4.0`). A Zod v3 schema +hard-errors with a pointer at `fromJsonSchema()`. Zod **≥4.2.0** self-converts via +`~standard.jsonSchema` — the supported path. Zod **4.0–4.1** lacks it, so the SDK falls +back to its bundled Zod's `z.toJSONSchema()` with a one-time `[mcp-sdk]` console +warning; and because `.describe()` field descriptions live in the _authoring_ Zod's +registry, the fallback **drops them** from the generated JSON Schema. Fix ladder: +(1) upgrade to `zod ^4.2.0`; (2) if you must pin an older or separate Zod, attach a +`~standard.jsonSchema` provider backed by _your_ Zod's `toJSONSchema` so conversion +(and descriptions) run through your instance; (3) author the schema as raw JSON Schema +via `fromJsonSchema()`. (Raw shapes are wrapped with the SDK's **bundled** Zod — built +with a foreign Zod they fail at registration or at the first `tools/list`; pass +`z.object()`-wrapped schemas from your own Zod instead.) + +The deprecated raw-shape overloads exist only on `registerTool` / `registerPrompt`. +`RegisteredTool.update()` / `RegisteredPrompt.update()` take **schema objects** +(`paramsSchema` / `outputSchema`: `StandardSchemaWithJSON`) — a raw shape passed to +`update()` is not auto-wrapped; wrap it with `z.object()` yourself. ```typescript import * as z from 'zod/v4'; @@ -396,6 +425,12 @@ have no replacement (internal Zod introspection). `SchemaInput` → internal `standardSchemaToJsonSchema` / `validateStandardSchema` helpers are **not** part of the public surface — do not import them. +v1's second compat module, `server/zod-json-schema-compat.js` (`toJsonSchemaCompat`), is +also removed — and the codemod does **not** rewrite its import (expect `TS2307`). If you +build `Tool` / `Prompt` advertisements yourself, use your schema library's native +conversion: zod 4's `z.toJSONSchema(schema, { io: 'input', target: 'draft-2020-12' })` +produces the dialect v2 advertises. + ### HTTP & headers Transport APIs and `ctx.http?.req?.headers` use the Web Standard `Headers` object @@ -443,21 +478,21 @@ The codemod renames `McpError` → `ProtocolError`, `ErrorCode` → `ProtocolErr checks already name the v2 classes — what's left is choosing which `SdkErrorCode` / class to match per scenario: -| Scenario | v1 | v2 | -| --- | --- | --- | -| Request timeout | `McpError` + `ErrorCode.RequestTimeout` | `SdkError` + `SdkErrorCode.RequestTimeout` | -| Connection closed | `McpError` + `ErrorCode.ConnectionClosed` | `SdkError` + `SdkErrorCode.ConnectionClosed` | -| Capability not supported | `new Error(...)` | `SdkError` + `SdkErrorCode.CapabilityNotSupported` | -| Not connected | `new Error('Not connected')` | `SdkError` + `SdkErrorCode.NotConnected` | -| Response result fails schema | raw `ZodError` | `SdkError` + `SdkErrorCode.InvalidResult` | -| Invalid params (server response) | `McpError` + `ErrorCode.InvalidParams` | `ProtocolError` + `ProtocolErrorCode.InvalidParams` | -| HTTP transport error | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttp*` | -| Failed to open SSE stream | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpFailedToOpenStream` | -| 401 after re-auth (circuit break) | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpAuthentication` | -| `SSEClientTransport.send()` 401 after re-auth | `UnauthorizedError` | `SdkHttpError` + `SdkErrorCode.ClientHttpAuthentication` | -| 403 `insufficient_scope` after step-up retry cap | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpForbidden` | -| Unexpected content type | `StreamableHTTPError` | `SdkError` + `SdkErrorCode.ClientHttpUnexpectedContent` | -| Session termination failed | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpFailedToTerminateSession` | +| Scenario | v1 | v2 | +| ------------------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------ | +| Request timeout | `McpError` + `ErrorCode.RequestTimeout` | `SdkError` + `SdkErrorCode.RequestTimeout` | +| Connection closed | `McpError` + `ErrorCode.ConnectionClosed` | `SdkError` + `SdkErrorCode.ConnectionClosed` | +| Capability not supported | `new Error(...)` | `SdkError` + `SdkErrorCode.CapabilityNotSupported` | +| Not connected | `new Error('Not connected')` | `SdkError` + `SdkErrorCode.NotConnected` | +| Response result fails schema | raw `ZodError` | `SdkError` + `SdkErrorCode.InvalidResult` | +| Invalid params (server response) | `McpError` + `ErrorCode.InvalidParams` | `ProtocolError` + `ProtocolErrorCode.InvalidParams` | +| HTTP transport error | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttp*` | +| Failed to open SSE stream | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpFailedToOpenStream` | +| 401 after re-auth (circuit break) | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpAuthentication` | +| `SSEClientTransport.send()` 401 after re-auth | `UnauthorizedError` | `SdkHttpError` + `SdkErrorCode.ClientHttpAuthentication` | +| 403 `insufficient_scope` after step-up retry cap | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpForbidden` | +| Unexpected content type | `StreamableHTTPError` | `SdkError` + `SdkErrorCode.ClientHttpUnexpectedContent` | +| Session termination failed | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpFailedToTerminateSession` | ```typescript // v1 @@ -481,29 +516,61 @@ if (error instanceof SdkHttpError) { `StreamableHTTPError` is removed. +**Status read off `.code` by duck-typing.** Code that classified HTTP failures by the +status without an `instanceof` — `if ('code' in e && e.code === 403)` — silently stops +matching: on `SdkHttpError` the HTTP status moved to `.status` (its `.code` is a +`SdkErrorCode` string). The codemod renames `instanceof StreamableHTTPError`, but a +status read that never named the class is invisible to it. Watch the inconsistency: +`SseError` still carries its HTTP status on numeric `.code`, so one duck-typed +`.code === 401` that caught both transports in v1 now catches only SSE. + +```typescript +// v1 — one duck-typed check caught both Streamable HTTP and SSE +if ('code' in e && (e.code === 401 || e.code === 403)) reauth(); +// v2 — match each explicitly +if (e instanceof SdkHttpError && (e.status === 401 || e.status === 403)) reauth(); // Streamable HTTP +if (e instanceof SseError && (e.code === 401 || e.code === 403)) reauth(); // SSE still uses .code +``` + +Silent at runtime (no compile error) — grep for `.code ===` status comparisons. + +**Raw numeric code comparisons.** The codemod rewrites `ErrorCode.X` symbol references, +but a check against the raw JSON-RPC number — `(e as { code?: unknown }).code === -32000` +— is invisible to it and silently never matches in v2, because the two SDK-local codes +it usually targeted are now **string** `SdkErrorCode` values: + +| v1 numeric | v2 | +| --------------------------- | -------------------------------------------- | +| `-32000` (ConnectionClosed) | `SdkError` + `SdkErrorCode.ConnectionClosed` | +| `-32001` (RequestTimeout) | `SdkError` + `SdkErrorCode.RequestTimeout` | + +Replace the literal with the named code. Loud (`TS2367`) when the compared value is +typed `SdkErrorCode`; silent when the left side is `unknown` or a cast — grep for +`=== -32000` / `=== -32001`. + #### `SdkErrorCode` enum (complete) -| Code | When thrown | -| --- | --- | -| `NotConnected` | Transport is not connected | -| `AlreadyConnected` | Transport is already connected | -| `NotInitialized` | Protocol is not initialized | -| `CapabilityNotSupported` | Required capability is not supported | -| `RequestTimeout` | Request timed out waiting for response | -| `ConnectionClosed` | Connection was closed | -| `SendFailed` | Failed to send message | -| `InvalidResult` | Response result failed local schema validation | -| `UnsupportedResultType` | A 2026-era response carried an unrecognized `resultType` | -| `InputRequiredRoundsExceeded` | Multi-round-trip auto-fulfilment hit `maxRounds` | -| `ListPaginationExceeded` | No-arg `list*()` aggregate walk hit `listMaxPages` | -| `MethodNotSupportedByProtocolVersion` | Outbound spec method does not exist on the negotiated protocol version | -| `EraNegotiationFailed` | `connect()` could not negotiate a protocol era (probe failed / no overlap) | -| `ClientHttpNotImplemented` | HTTP POST request failed | -| `ClientHttpAuthentication` | Server returned 401 after re-authentication | -| `ClientHttpForbidden` | Server returned 403 `insufficient_scope` after step-up retry cap | -| `ClientHttpUnexpectedContent` | Unexpected content type in HTTP response | -| `ClientHttpFailedToOpenStream` | Failed to open SSE stream | -| `ClientHttpFailedToTerminateSession` | Failed to terminate session | +| Code | When thrown | +| ------------------------------------- | -------------------------------------------------------------------------- | +| `NotConnected` | Transport is not connected | +| `AlreadyConnected` | Transport is already connected | +| `NotInitialized` | Protocol is not initialized | +| `CapabilityNotSupported` | Required capability is not supported | +| `RequestTimeout` | Request timed out waiting for response | +| `ConnectionClosed` | Connection was closed | +| `SendFailed` | Failed to send message | +| `InvalidResult` | Response result failed local schema validation | +| `UnsupportedResultType` | A 2026-era response carried an unrecognized `resultType` | +| `InputRequiredRoundsExceeded` | Multi-round-trip auto-fulfilment hit `maxRounds` | +| `ListPaginationExceeded` | No-arg `list*()` aggregate walk hit `listMaxPages` | +| `MethodNotSupportedByProtocolVersion` | Outbound spec method does not exist on the negotiated protocol version | +| `EraNegotiationFailed` | `connect()` could not negotiate a protocol era (probe failed / no overlap) | +| `ClientHttpNotImplemented` | HTTP POST request failed | +| `ClientHttpAuthentication` | Server returned 401 after re-authentication | +| `ClientHttpForbidden` | Server returned 403 `insufficient_scope` after step-up retry cap | +| `ClientHttpUnexpectedContent` | Unexpected content type in HTTP response | +| `ClientHttpFailedToOpenStream` | Failed to open SSE stream | +| `ClientHttpFailedToTerminateSession` | Failed to terminate session | #### Typed `ProtocolError` subclasses @@ -511,7 +578,10 @@ if (error instanceof SdkHttpError) { (carries `data.requiredCapabilities`) are new typed `ProtocolError` subclasses. `resources/read` for an unknown URI now answers `-32602` on every protocol revision (v1.x already emitted `-32602`; an interim `-32002` from earlier v2 alphas is mapped at -the encode seam). `ProtocolErrorCode.ResourceNotFound` (`-32002`) stays importable as +the encode seam). The encode-seam mapping applies to **your own throws too**: a handler +that deliberately throws `ProtocolError(ProtocolErrorCode.ResourceNotFound, …)` reaches +peers as `-32602` — a server can no longer emit `-32002` on the wire. +`ProtocolErrorCode.ResourceNotFound` (`-32002`) stays importable as receive-tolerated vocabulary — accept both `-32602` and `-32002` from peers. `ProtocolError.fromError(code, message, data)` reconstructs the typed subclass from code + data alone, so it works across bundle boundaries where `instanceof` doesn't. @@ -524,26 +594,26 @@ The individual OAuth error classes are replaced with a single `OAuthError` + `OA The `OAUTH_ERRORS` constant is removed. The codemod does not rewrite `instanceof` checks on these classes — switch on `error.code` instead. -| v1 class | v2 equivalent | -| --- | --- | -| `InvalidRequestError` | `OAuthError` + `OAuthErrorCode.InvalidRequest` | -| `InvalidClientError` | `OAuthError` + `OAuthErrorCode.InvalidClient` | -| `InvalidGrantError` | `OAuthError` + `OAuthErrorCode.InvalidGrant` | -| `UnauthorizedClientError` | `OAuthError` + `OAuthErrorCode.UnauthorizedClient` | -| `UnsupportedGrantTypeError` | `OAuthError` + `OAuthErrorCode.UnsupportedGrantType` | -| `InvalidScopeError` | `OAuthError` + `OAuthErrorCode.InvalidScope` | -| `AccessDeniedError` | `OAuthError` + `OAuthErrorCode.AccessDenied` | -| `ServerError` | `OAuthError` + `OAuthErrorCode.ServerError` | -| `TemporarilyUnavailableError` | `OAuthError` + `OAuthErrorCode.TemporarilyUnavailable` | +| v1 class | v2 equivalent | +| ------------------------------ | ------------------------------------------------------- | +| `InvalidRequestError` | `OAuthError` + `OAuthErrorCode.InvalidRequest` | +| `InvalidClientError` | `OAuthError` + `OAuthErrorCode.InvalidClient` | +| `InvalidGrantError` | `OAuthError` + `OAuthErrorCode.InvalidGrant` | +| `UnauthorizedClientError` | `OAuthError` + `OAuthErrorCode.UnauthorizedClient` | +| `UnsupportedGrantTypeError` | `OAuthError` + `OAuthErrorCode.UnsupportedGrantType` | +| `InvalidScopeError` | `OAuthError` + `OAuthErrorCode.InvalidScope` | +| `AccessDeniedError` | `OAuthError` + `OAuthErrorCode.AccessDenied` | +| `ServerError` | `OAuthError` + `OAuthErrorCode.ServerError` | +| `TemporarilyUnavailableError` | `OAuthError` + `OAuthErrorCode.TemporarilyUnavailable` | | `UnsupportedResponseTypeError` | `OAuthError` + `OAuthErrorCode.UnsupportedResponseType` | -| `UnsupportedTokenTypeError` | `OAuthError` + `OAuthErrorCode.UnsupportedTokenType` | -| `InvalidTokenError` | `OAuthError` + `OAuthErrorCode.InvalidToken` | -| `MethodNotAllowedError` | `OAuthError` + `OAuthErrorCode.MethodNotAllowed` | -| `TooManyRequestsError` | `OAuthError` + `OAuthErrorCode.TooManyRequests` | -| `InvalidClientMetadataError` | `OAuthError` + `OAuthErrorCode.InvalidClientMetadata` | -| `InsufficientScopeError` | `OAuthError` + `OAuthErrorCode.InsufficientScope` ¹ | -| `InvalidTargetError` | `OAuthError` + `OAuthErrorCode.InvalidTarget` | -| `CustomOAuthError` | `new OAuthError(customCode, message)` | +| `UnsupportedTokenTypeError` | `OAuthError` + `OAuthErrorCode.UnsupportedTokenType` | +| `InvalidTokenError` | `OAuthError` + `OAuthErrorCode.InvalidToken` | +| `MethodNotAllowedError` | `OAuthError` + `OAuthErrorCode.MethodNotAllowed` | +| `TooManyRequestsError` | `OAuthError` + `OAuthErrorCode.TooManyRequests` | +| `InvalidClientMetadataError` | `OAuthError` + `OAuthErrorCode.InvalidClientMetadata` | +| `InsufficientScopeError` | `OAuthError` + `OAuthErrorCode.InsufficientScope` ¹ | +| `InvalidTargetError` | `OAuthError` + `OAuthErrorCode.InvalidTarget` | +| `CustomOAuthError` | `new OAuthError(customCode, message)` | ¹ Unrelated to the new transport-layer `InsufficientScopeError` (SEP-2350) exported from `@modelcontextprotocol/client`, which carries an RFC 6750 challenge from the resource @@ -557,6 +627,16 @@ import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/client'; if (error instanceof OAuthError && error.code === OAuthErrorCode.InvalidClient) { ... } ``` +⚠ **Token verifiers must throw the v2 `OAuthError`.** `requireBearerAuth` (from +`@modelcontextprotocol/express`) classifies the error your +`OAuthTokenVerifier.verifyAccessToken()` throws: a v2 +`OAuthError(OAuthErrorCode.InvalidToken)` produces the proper `401` + +`WWW-Authenticate` challenge, while the legacy `InvalidTokenError` (from +`server-legacy`) or a generic `Error` falls through as unexpected — **invalid tokens +become HTTP `500`**. When you re-point `requireBearerAuth` at +`@modelcontextprotocol/express`, migrate the error classes your verifier throws in the +same change. + A frozen copy of the v1 classes (and `mcpAuthRouter`) is available from `@modelcontextprotocol/server-legacy/auth` during migration. @@ -591,13 +671,13 @@ The OAuth client flow now throws dedicated classes from `@modelcontextprotocol/c (all extend `OAuthClientFlowError`, **not** `OAuthError` — `auth()`'s `OAuthError` retry path will not catch them): -| Throw site | v2 class | -| --- | --- | -| `registerClient()` rejected by AS (⚠ `@deprecated` — see [§Deprecated in v2](#deprecated-in-v2-sep-2577)) | `RegistrationRejectedError` (`status`, `body`, `submittedMetadata`) | -| Token-exchange / refresh / `fetchToken` / Cross-App grant on a non-`https:` token endpoint | `InsecureTokenEndpointError` (`tokenEndpoint`) | -| RFC 9207 `iss` mismatch / RFC 8414 §3.3 issuer-echo mismatch | `IssuerMismatchError` (`kind`, `expected`, `received`) | +| Throw site | v2 class | +| ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | +| `registerClient()` rejected by AS (⚠ `@deprecated` — see [§Deprecated in v2](#deprecated-in-v2-sep-2577)) | `RegistrationRejectedError` (`status`, `body`, `submittedMetadata`) | +| Token-exchange / refresh / `fetchToken` / Cross-App grant on a non-`https:` token endpoint | `InsecureTokenEndpointError` (`tokenEndpoint`) | +| RFC 9207 `iss` mismatch / RFC 8414 §3.3 issuer-echo mismatch | `IssuerMismatchError` (`kind`, `expected`, `received`) | | Transport 403 `insufficient_scope` with `onInsufficientScope: 'throw'`, or default mode without an `OAuthClientProvider` | `InsufficientScopeError` (`requiredScope`, `resourceMetadataUrl`, `errorDescription`) | -| `auth()` callback leg: discovery resolves a different AS than the recorded redirect target | `AuthorizationServerMismatchError` (`recordedIssuer`, `currentIssuer`) | +| `auth()` callback leg: discovery resolves a different AS than the recorded redirect target | `AuthorizationServerMismatchError` (`recordedIssuer`, `currentIssuer`) | #### `auth()` options are now `AuthOptions` @@ -687,7 +767,10 @@ same handling as the POST send path. methods plus `tokens()` / `clientInformation()`. On read, a stored value whose `issuer` names a different AS is treated as `undefined` and the flow re-registers / re-authorizes. **Round-trip the stored object verbatim and you're protected** — single-slot storage -works. To hold credentials for several authorization servers at once, key your storage +works. The failure mode is silent: a `saveTokens()` implementation that rebuilds the +object field-by-field and **drops `issuer`** makes every read look foreign — the refresh +path quietly escalates to a full re-authorization (plus a per-read `console.warn`). If +your users re-consent on every restart after upgrading, check this first. To hold credentials for several authorization servers at once, key your storage on `ctx.issuer` (treat **`ctx === undefined` as "return the most-recently-saved token set"** — the transport's per-request `Authorization: Bearer` read calls `tokens()` with no `ctx`). New TypeScript-only aliases `StoredOAuthTokens` / `StoredOAuthClientInformation` @@ -760,6 +843,9 @@ const result = specTypeSchemas.CallToolResult['~standard'].validate(value); `isSpecType` and `specTypeSchemas` are keyed by `SpecTypeName` — a literal union of every named type in the MCP spec — so you get autocomplete and a compile error on typos. `specTypeSchemas.X` is a `StandardSchemaV1Sync` (`validate()` is synchronous). +`validate()` returns `{ value }` or `{ issues }` and never throws — unlike `.parse()` on +the real schema; code that caught a `ZodError` should inspect `result.issues` (or keep +`.parse()` on the schema imported from `@modelcontextprotocol/core`). The pre-existing `isCallToolResult(value)` guard still works. **`specTypeSchemas.X` is `StandardSchemaV1`, not `ZodType`.** Zod-specific composition @@ -779,18 +865,18 @@ include task vocabulary; the deprecated `Task*` types remain importable on their #### Removed type aliases -| Removed | Replacement | -| --- | --- | -| `JSONRPCError` | `JSONRPCErrorResponse` | -| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | -| `isJSONRPCError` | `isJSONRPCErrorResponse` | -| `isJSONRPCResponse` (deprecated in v1) | `isJSONRPCResultResponse` ² | -| `JSONRPCResponseSchema` (result-only in v1) | `JSONRPCResultResponseSchema` ² | -| `JSONRPCResponse` (result-only in v1) | `JSONRPCResultResponse` ² | -| `ResourceReference` / `ResourceReferenceSchema` | `ResourceTemplateReference` / `ResourceTemplateReferenceSchema` | -| `IsomorphicHeaders` | Web Standard `Headers` | -| `RequestHandlerExtra` | `ServerContext` / `ClientContext` / `BaseContext` | -| `ResourceTemplate` (the spec wire **type** from `sdk/types.js`) | `ResourceTemplateType` ³ | +| Removed | Replacement | +| --------------------------------------------------------------- | --------------------------------------------------------------- | +| `JSONRPCError` | `JSONRPCErrorResponse` | +| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | +| `isJSONRPCError` | `isJSONRPCErrorResponse` | +| `isJSONRPCResponse` (deprecated in v1) | `isJSONRPCResultResponse` ² | +| `JSONRPCResponseSchema` (result-only in v1) | `JSONRPCResultResponseSchema` ² | +| `JSONRPCResponse` (result-only in v1) | `JSONRPCResultResponse` ² | +| `ResourceReference` / `ResourceReferenceSchema` | `ResourceTemplateReference` / `ResourceTemplateReferenceSchema` | +| `IsomorphicHeaders` | Web Standard `Headers` | +| `RequestHandlerExtra` | `ServerContext` / `ClientContext` / `BaseContext` | +| `ResourceTemplate` (the spec wire **type** from `sdk/types.js`) | `ResourceTemplateType` ³ | ² v2 introduces **new** `isJSONRPCResponse` / `JSONRPCResponse` / `JSONRPCResponseSchema` with corrected semantics — they match **both** result and error responses (the schema is @@ -808,6 +894,11 @@ names — import the TypeScript types, error classes, enums, and type guards fro `@modelcontextprotocol/client` or `@modelcontextprotocol/server`, and the Zod `*Schema` constants from `@modelcontextprotocol/core`. +The `Protocol` base class itself is no longer exported (it is internal engine). If you +were reaching into protocol internals — rare, mostly debugging tools — +`client.fallbackRequestHandler` / `server.fallbackRequestHandler` receives every +inbound request that no registered handler matches, before capability gating. + #### JSON Schema 2020-12 posture (SEP-1613, SEP-2106) The default validator supports **JSON Schema 2020-12 only**. On Node it is now `Ajv2020` @@ -820,19 +911,27 @@ declaring a different `$schema` are rejected with `Error("…unsupported dialect `$ref` is not dereferenced (unchanged from v1; Ajv throws `MissingRefError` at compile, surfaced per-tool on `callTool`). -| v1 pattern | Mechanical fix | -| --- | --- | -| `result.structuredContent.` / `result.structuredContent?.` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '' in sc) { sc. }` | -| `if (!result.structuredContent)` | `if (result.structuredContent === undefined)` | -| relying on default `Ajv` being draft-07 | `new AjvJsonSchemaValidator(new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }))` (import `Ajv`, `addFormats`, `AjvJsonSchemaValidator` from `…/validators/ajv`) | -| draft-07 idioms via `fromJsonSchema(schema)` | `fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — the `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema`-authored schemas | -| `outputSchema` / `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` | +| v1 pattern | Mechanical fix | +| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `result.structuredContent.` / `result.structuredContent?.` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '' in sc) { sc. }` | +| `if (!result.structuredContent)` | `if (result.structuredContent === undefined)` | +| relying on default `Ajv` being draft-07 | `new AjvJsonSchemaValidator(new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }))` (import `Ajv`, `addFormats`, `AjvJsonSchemaValidator` from `…/validators/ajv`) | +| draft-07 idioms via `fromJsonSchema(schema)` | `fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — the `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema`-authored schemas | +| `outputSchema` / `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` | A tool may now register an `outputSchema` whose root is `type:"array"`, `type:"string"`, etc.; toward 2025-era clients the codec wraps it in a `{result:…}` envelope, and toward every era a non-object `structuredContent` with no `text` block of its own gets a `JSON.stringify(...)` `text` block auto-appended. See [support-2026-07-28.md › Per-era wire codecs](./support-2026-07-28.md#per-era-wire-codecs) for how the codec applies these per era. +**Your advertised tool schemas change shape on the wire.** The same `registerTool` +calls produce `tools/list` entries whose generated `inputSchema` differs from v1: +JSON Schema 2020-12 idioms (zod 4 conversion), different `additionalProperties` +handling (no `additionalProperties: false` by default; passthrough objects emit +`"additionalProperties": {}` instead of `true`), and no `execution.taskSupport` member. +Golden tests, transcript pins, and strict client-side validators of your advertised +tool list need re-baselining — the new shapes are spec-conformant. + ### Behavioral changes These are runtime-behavior changes that may affect tests and assertions; no source @@ -844,6 +943,10 @@ rewrite required unless noted. instead of resolving `CallToolResult{isError: true}`. v1 callers that checked `result.isError` for an unknown tool will get an unhandled rejection — catch the rejected promise instead. +- **The `MCP error : ` message prefix is gone.** v1 prefixed relayed JSON-RPC + error messages (`MCP error -32602: …`); v2's `ProtocolError.message` carries the + peer's message verbatim. Tests and log scrapers that matched the prefix or the numeric + code in rendered text should match `error.code` instead. - **In-flight request handlers are aborted on transport close** — `ctx.mcpReq.signal` fires (v1 let them run to completion). `InMemoryTransport.close()` no longer double-fires `onclose` on the initiating side. @@ -855,6 +958,29 @@ rewrite required unless noted. invalid URL) in Node and Cloudflare Workers instead of swallowing it as a CORS miss → `undefined`. The CORS-swallow remains browser-only. +#### Client connection & dispatch + +- **`connect()` skips the `initialize` handshake when the transport already exposes a + `sessionId`** — it assumes it is reconnecting to an existing session (v1 always + initialized). A custom or test transport that sets `sessionId` at construction + silently skips initialization: `getServerCapabilities()` stays `undefined` and the + list verbs return empty results. Expose `sessionId` only after the first request has + been sent. +- **Request dispatch is asynchronous inside `request()` / `callTool()`.** v1 wrote the + JSON-RPC frame to the transport before the first `await`, so an abort fired in the + same tick cancelled an in-flight request and emitted `notifications/cancelled`. v2 + sends after an async hop: an abort in that window means the request is **never sent** + and no cancellation notification is emitted. Once the frame is on the wire, aborting + still sends `notifications/cancelled` before rejecting. +- **Protocol-version pinning is a first-class option.** + `ProtocolOptions.supportedProtocolVersions` controls which versions the legacy + `initialize` handshake offers (the highest pre-2026 entry), which counter-offers are + accepted, and which revisions `versionNegotiation` may select + (see [support-2026-07-28.md](./support-2026-07-28.md#client-side-versionnegotiation)). v1 had no + public equivalent (`SUPPORTED_PROTOCOL_VERSIONS` was a fixed constant) — replace any + workaround that patched the offered version with this option. A list with no + pre-2026-07-28 entry makes the legacy handshake throw. + #### stdio transport - A configurable `maxBufferSize` (default **10 MB**) caps the stdio read buffer. A @@ -880,7 +1006,10 @@ rewrite required unless noted. return `nextCursor: undefined`. Passing `{ cursor }` still fetches one page. Manual pagination loops keep working (the first iteration returns everything); replace them with the bare no-arg call. The walk is capped at `ClientOptions.listMaxPages` (default - 64); overrun throws `SdkError(ListPaginationExceeded)`. + 64); overrun throws `SdkError(ListPaginationExceeded)`. There is no way to fetch only + the **first** page through the typed verbs — for page-level observation + (pagination tooling, per-page stats) drop to + `client.request({ method: 'tools/list', params })`, which never aggregates. - Output-schema validator compilation is now **lazy** — validators compile on the first `callTool()` against the cached `tools/list` entry, not eagerly inside `listTools()`. In v1, `listTools()` threw on an uncompilable `outputSchema`; now `listTools()` @@ -925,7 +1054,7 @@ rewrite required unless noted. for every primitive capability declared in `ServerOptions.capabilities`, even with zero registrations. `new McpServer(info, { capabilities: { tools: {} } })` with no registered tools answers `tools/list` with `{ tools: [] }` instead of `-32601 Method - not found`. Low-level `Server` users remain responsible for registering handlers for +not found`. Low-level `Server` users remain responsible for registering handlers for declared capabilities. - **`WebStandardStreamableHTTPServerTransport` store-first `eventStore` semantics.** Request-related events emitted after `closeSSE()` — and the final response when no @@ -953,6 +1082,11 @@ requests, the per-request `_meta.logLevel` envelope key is the filter — see affordance was removed. Tool handlers MUST include `content` (the TypeScript surface always required it; `content: []` is fine). A handler result without it is rejected with `-32602`. +- **`ElicitResult.content` values are typed and validated as + `string | number | boolean | string[]`.** v1's TypeScript surface accepted + `Record` content values; an elicitation handler returning arbitrary + objects now fails to compile (and fails schema validation) — narrow to the primitives + the elicitation spec allows. - **Custom (3-arg) handlers receive `_meta`.** `setRequestHandler(method, {params}, handler)` used to delete `params._meta` before validation; it now passes `_meta` through (minus the reserved `io.modelcontextprotocol/*` envelope keys). If your params schema is @@ -1043,8 +1177,8 @@ The following are unchanged between v1 and v2 (only the import path changed): > The `Server` (low-level) constructor and **most** of its methods are unchanged, but > `setRequestHandler` / `setNotificationHandler` and `request()` signatures changed > ([Low-level protocol](#low-level-protocol--handler-context-ctx)). The Zod `*Schema` -> constants are **not** part of the unchanged surface — they are no longer public -> ([Types & schemas](#types--schemas)). +> constants are **not** part of the unchanged surface — they moved to +> `@modelcontextprotocol/core` ([Types & schemas](#types--schemas)). --- diff --git a/packages/codemod/README.md b/packages/codemod/README.md index 6409f83e4c..52850247bc 100644 --- a/packages/codemod/README.md +++ b/packages/codemod/README.md @@ -5,7 +5,7 @@ Codemods for migrating MCP TypeScript SDK code between major versions. ## Usage ```bash -npx @modelcontextprotocol/codemod@alpha v1-to-v2 ./src +npx @modelcontextprotocol/codemod@alpha v1-to-v2 . ``` The codemod rewrites TypeScript and JavaScript source files From f104fb17b2ef0d95342b4769ffff3f19051c09f1 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 12:27:32 +0000 Subject: [PATCH 2/4] docs(migration): fold in second-wave migration findings SdkHttpError constructor mapping for code that constructs the error (test stubs, custom transports); leftover v1 dependency cleanup when the codemod reports no changes on already-migrated sources; the declared-zod-range trap (installs and typechecks cleanly, fails at first registration); and the broken Protocol named-import some early codemod versions emitted. --- docs/migration/upgrade-to-v2.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index fd32b01e1c..0c40025bbe 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -147,6 +147,11 @@ whichever package you already depend on. `@modelcontextprotocol/core-internal` i `@modelcontextprotocol/core` is the public Zod-schema package (raw `*Schema` constants only); see [Zod `*Schema` constants moved to `@modelcontextprotocol/core`](#zod-schema-constants-moved-to-modelcontextprotocolcore) below. +After the codemod runs, verify `@modelcontextprotocol/sdk` is gone from `package.json`: +the dependency swap keys off v1 imports rewritten in the same pass, so on sources that +were already (partially) migrated the codemod reports no changes and leaves the old +dependency behind — remove it by hand once nothing imports it. + The framework adapter packages declare their framework as a **peer dependency** (`express`, `hono`, `fastify`); v1 shipped them as direct deps. The codemod adds the `@modelcontextprotocol/*` packages your imports use, but does not add the framework @@ -382,7 +387,11 @@ are still **accepted via `@deprecated` overloads** on `registerTool`/`registerPr (auto-wrapped with `z.object()`), and `completable()` accepts any `StandardSchemaV1`; prefer wrapping explicitly. Zod v4, ArkType, and Valibot all implement the spec. -**Zod v3 is no longer supported** (v1 peer was `^3.25 || ^4.0`). A Zod v3 schema +**Zod v3 is no longer supported** (v1 peer was `^3.25 || ^4.0`). Check the **declared +range** in your `package.json`, not just the installed version: a zod-3 range that +satisfied the v1 peer installs and typechecks cleanly under v2 and only fails at +runtime, when the first registration throws — under a spawning harness that surfaces +as an opaque child exit two hops from the cause. A Zod v3 schema hard-errors with a pointer at `fromJsonSchema()`. Zod **≥4.2.0** self-converts via `~standard.jsonSchema` — the supported path. Zod **4.0–4.1** lacks it, so the SDK falls back to its bundled Zod's `z.toJSONSchema()` with a one-time `[mcp-sdk]` console @@ -548,6 +557,14 @@ Replace the literal with the named code. Loud (`TS2367`) when the compared value typed `SdkErrorCode`; silent when the left side is `unknown` or a cast — grep for `=== -32000` / `=== -32001`. +**Constructing the error (test stubs, custom transports).** v1 +`new StreamableHTTPError(code, message)` becomes +`new SdkHttpError(code, message, data)`: the first argument is now a `SdkErrorCode` +string (pick the branch from the scenario table above) and the HTTP status moves into +the third argument — `new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, +'Not Found', { status: 404, statusText: 'Not Found' })`. v1's implicit +`Streamable HTTP error: ` message prefix is gone; pass the full message you want. + #### `SdkErrorCode` enum (complete) | Code | When thrown | @@ -897,7 +914,10 @@ names — import the TypeScript types, error classes, enums, and type guards fro The `Protocol` base class itself is no longer exported (it is internal engine). If you were reaching into protocol internals — rare, mostly debugging tools — `client.fallbackRequestHandler` / `server.fallbackRequestHandler` receives every -inbound request that no registered handler matches, before capability gating. +inbound request that no registered handler matches, before capability gating. Delete +the v1 `shared/protocol.js` import: `Protocol` has no v2 import path (early codemod +versions rewrote it to a named import from `@modelcontextprotocol/client` that does +not exist — remove that import if you find it). #### JSON Schema 2020-12 posture (SEP-1613, SEP-2106) From ecbe8b2176442abbc43337806a9cd9917a44df99 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 13:13:40 +0000 Subject: [PATCH 3/4] docs(migration): correct five claims against the implementation Review follow-up, each verified against source: request() dispatches before its first await (the async pre-work is in the typed verbs on top of it); schema-less request() throws a TypeError for non-spec methods rather than resolving undefined; a missing issuer stamp is used as-is with a console warning (only a mismatched stamp triggers re-authorization); versionNegotiation consults supportedProtocolVersions in auto mode only (a pin is honored as given); the leftover-v1-dependency cause is the updater only touching the target-directory manifest, not an already-migrated skip. Protocol import note switched to present tense (the codemod fix is tracked, not shipped). Adds an empty changeset for the changeset bot. --- .changeset/migration-doc-findings.md | 4 +++ docs/migration/index.md | 3 +- docs/migration/support-2026-07-28.md | 10 +++++-- docs/migration/upgrade-to-v2.md | 42 +++++++++++++++++----------- 4 files changed, 38 insertions(+), 21 deletions(-) create mode 100644 .changeset/migration-doc-findings.md diff --git a/.changeset/migration-doc-findings.md b/.changeset/migration-doc-findings.md new file mode 100644 index 0000000000..ac8f1e824d --- /dev/null +++ b/.changeset/migration-doc-findings.md @@ -0,0 +1,4 @@ +--- +--- + +Docs-only: migration-guide corrections from real v1-to-v2 migrations. No package changes. diff --git a/docs/migration/index.md b/docs/migration/index.md index 2274a7128f..61ba586367 100644 --- a/docs/migration/index.md +++ b/docs/migration/index.md @@ -22,7 +22,8 @@ Start by running the codemod: npx @modelcontextprotocol/codemod@alpha v1-to-v2 . ``` -Run it at the package root — it also rewrites `package.json`. +Run it at the package root (`.`) — the dependency swap updates the `package.json` at +the target directory only. 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 diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index a7480f203c..0f56f869de 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -58,9 +58,13 @@ client.getProtocolEra(); // 'modern' | 'legacy' - **`mode: { pin: '2026-07-28' }`** — modern only; no fallback, `connect()` rejects with `SdkError(EraNegotiationFailed)` against a 2025-only server. -Every mode selects only among revisions in `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)). +`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 diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 0c40025bbe..92e2bd2da1 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -148,9 +148,10 @@ whichever package you already depend on. `@modelcontextprotocol/core-internal` i only); see [Zod `*Schema` constants moved to `@modelcontextprotocol/core`](#zod-schema-constants-moved-to-modelcontextprotocolcore) below. After the codemod runs, verify `@modelcontextprotocol/sdk` is gone from `package.json`: -the dependency swap keys off v1 imports rewritten in the same pass, so on sources that -were already (partially) migrated the codemod reports no changes and leaves the old -dependency behind — remove it by hand once nothing imports it. +the dependency swap only updates the manifest found at the **target directory** (another +reason to run at the package root, not `./src`), and workspace-member manifests in a +monorepo are not visited — remove the v1 dependency from those by hand once nothing +imports it. The framework adapter packages declare their framework as a **peer dependency** (`express`, `hono`, `fastify`); v1 shipped them as direct deps. The codemod adds the @@ -342,7 +343,8 @@ schema when calling a spec method. semantics, not just the signature: a schema-less spec-method call now **enforces** the spec result schema (a non-conforming upstream result is rejected locally with `SdkError(SdkErrorCode.InvalidResult)` and a conforming one is re-serialized in schema -key order), and a schema-less call for an **unknown** method resolves to `undefined`. +key order), and a schema-less call for a **non-spec** method throws a `TypeError` at +the call site (`'…' is not a spec method; pass a result schema`). A relay that forwards `{ method, params }` it does not understand must keep passing an explicit result schema. The v1 idiom survives with an import-path change: @@ -784,10 +786,13 @@ same handling as the POST send path. methods plus `tokens()` / `clientInformation()`. On read, a stored value whose `issuer` names a different AS is treated as `undefined` and the flow re-registers / re-authorizes. **Round-trip the stored object verbatim and you're protected** — single-slot storage -works. The failure mode is silent: a `saveTokens()` implementation that rebuilds the -object field-by-field and **drops `issuer`** makes every read look foreign — the refresh -path quietly escalates to a full re-authorization (plus a per-read `console.warn`). If -your users re-consent on every restart after upgrading, check this first. To hold credentials for several authorization servers at once, key your storage +works. The failure modes differ: a stamp naming a **different** AS reads back as +`undefined` and the flow re-registers / re-authorizes. A **missing** stamp (a +`saveTokens()` that rebuilds the object field-by-field and drops `issuer`, or +pre-upgrade storage) is used **as-is** with a `[mcp-sdk]` console warning — SEP-2352 +isolation is silently inactive for that read; `auth()` re-stamps on first use where the +provider can persist it. If you see that warning repeatedly, your provider is not +round-tripping the stored object. To hold credentials for several authorization servers at once, key your storage on `ctx.issuer` (treat **`ctx === undefined` as "return the most-recently-saved token set"** — the transport's per-request `Authorization: Bearer` read calls `tokens()` with no `ctx`). New TypeScript-only aliases `StoredOAuthTokens` / `StoredOAuthClientInformation` @@ -915,9 +920,9 @@ The `Protocol` base class itself is no longer exported (it is internal engine). were reaching into protocol internals — rare, mostly debugging tools — `client.fallbackRequestHandler` / `server.fallbackRequestHandler` receives every inbound request that no registered handler matches, before capability gating. Delete -the v1 `shared/protocol.js` import: `Protocol` has no v2 import path (early codemod -versions rewrote it to a named import from `@modelcontextprotocol/client` that does -not exist — remove that import if you find it). +the v1 `shared/protocol.js` import: `Protocol` has no v2 import path. The codemod +currently rewrites it to a named import from `@modelcontextprotocol/client` that does +not exist (a codemod fix is tracked) — delete that import. #### JSON Schema 2020-12 posture (SEP-1613, SEP-2106) @@ -986,12 +991,15 @@ rewrite required unless noted. silently skips initialization: `getServerCapabilities()` stays `undefined` and the list verbs return empty results. Expose `sessionId` only after the first request has been sent. -- **Request dispatch is asynchronous inside `request()` / `callTool()`.** v1 wrote the - JSON-RPC frame to the transport before the first `await`, so an abort fired in the - same tick cancelled an in-flight request and emitted `notifications/cancelled`. v2 - sends after an async hop: an abort in that window means the request is **never sent** - and no cancellation notification is emitted. Once the frame is on the wire, aborting - still sends `notifications/cancelled` before rejecting. +- **The typed verbs dispatch after async pre-work.** `Protocol.request()` itself still + hands the frame to the transport before its first `await` (v1-compatible). The typed + verbs on top of it — `callTool()` and the cacheable list verbs — perform async work + first (header-mirroring scan, response-cache freshness, output-validator resolution), + so an abort fired in the same tick can land before the frame is ever sent: the call + rejects with `SdkError(RequestTimeout, reason)` and **no `notifications/cancelled` is + emitted** (nothing was in flight). v1 sent the frame synchronously from these verbs. + Once the frame is on the wire, aborting still sends `notifications/cancelled` before + rejecting. - **Protocol-version pinning is a first-class option.** `ProtocolOptions.supportedProtocolVersions` controls which versions the legacy `initialize` handshake offers (the highest pre-2026 entry), which counter-offers are From f47d25f0ef1caf3955be306d4854b6a5b646f9bf Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 14:03:45 +0000 Subject: [PATCH 4/4] docs(migration): third-round corrections from review The supportedProtocolVersions bullet now matches resolveVersionNegotiation and the legacy handshake exactly (first pre-2026 entry offered in list order, counter-offers gated on the list's pre-2026 entries, auto-mode candidates from the list's modern entries with the SDK default as fallback, pin not checked against the list) and no longer contradicts the support guide's corrected sentence. The package.json caveat now describes the real mechanism: the updater rewrites the nearest manifest walking up from the target directory, so workspace members are the leftover-dependency cause, and already-migrated sources get the v1 dependency removed without the v2 additions. index.md's package-root rationale switched to the valid reason (test/scripts/fixtures imports). --- docs/migration/index.md | 4 ++-- docs/migration/upgrade-to-v2.md | 28 ++++++++++++++++------------ 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/docs/migration/index.md b/docs/migration/index.md index 61ba586367..c9c4161b60 100644 --- a/docs/migration/index.md +++ b/docs/migration/index.md @@ -22,8 +22,8 @@ Start by running the codemod: npx @modelcontextprotocol/codemod@alpha v1-to-v2 . ``` -Run it at the package root (`.`) — the dependency swap updates the `package.json` at -the target directory only. +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 diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 92e2bd2da1..4455aaa652 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -147,11 +147,12 @@ whichever package you already depend on. `@modelcontextprotocol/core-internal` i `@modelcontextprotocol/core` is the public Zod-schema package (raw `*Schema` constants only); see [Zod `*Schema` constants moved to `@modelcontextprotocol/core`](#zod-schema-constants-moved-to-modelcontextprotocolcore) below. -After the codemod runs, verify `@modelcontextprotocol/sdk` is gone from `package.json`: -the dependency swap only updates the manifest found at the **target directory** (another -reason to run at the package root, not `./src`), and workspace-member manifests in a -monorepo are not visited — remove the v1 dependency from those by hand once nothing -imports it. +After the codemod runs, verify the dependencies in `package.json`: the swap rewrites +the **nearest** manifest found walking up from the target directory — one manifest +total, so workspace-member manifests in a monorepo are not visited (remove the v1 +dependency from those by hand once nothing imports it). On already-migrated sources +the codemod still removes the v1 dependency but may not add the v2 packages you need +— check both directions. The framework adapter packages declare their framework as a **peer dependency** (`express`, `hono`, `fastify`); v1 shipped them as direct deps. The codemod adds the @@ -1001,13 +1002,16 @@ rewrite required unless noted. Once the frame is on the wire, aborting still sends `notifications/cancelled` before rejecting. - **Protocol-version pinning is a first-class option.** - `ProtocolOptions.supportedProtocolVersions` controls which versions the legacy - `initialize` handshake offers (the highest pre-2026 entry), which counter-offers are - accepted, and which revisions `versionNegotiation` may select - (see [support-2026-07-28.md](./support-2026-07-28.md#client-side-versionnegotiation)). v1 had no - public equivalent (`SUPPORTED_PROTOCOL_VERSIONS` was a fixed constant) — replace any - workaround that patched the offered version with this option. A list with no - pre-2026-07-28 entry makes the legacy handshake throw. + `ProtocolOptions.supportedProtocolVersions` pins the legacy `initialize` handshake: + the **first** pre-2026 entry in the list is offered (list order is preference order), + a counter-offer is accepted only if it is one of the list's pre-2026 entries, and a + list with no pre-2026 entry makes the handshake throw. Under + `versionNegotiation: 'auto'` the modern probe candidates are the list's modern + entries when it has any (otherwise the SDK's default modern set); a `{ pin }` is + honored as given and is not checked against the list (see + [support-2026-07-28.md](./support-2026-07-28.md#client-side-versionnegotiation)). + v1 had no public equivalent (`SUPPORTED_PROTOCOL_VERSIONS` was a fixed constant) — + replace any workaround that patched the offered version with this option. #### stdio transport