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
14 changes: 14 additions & 0 deletions .changeset/calltoolresult-content-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@modelcontextprotocol/core-internal': patch
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/core': patch
'@modelcontextprotocol/server': patch
---

Restore the v1 parse tolerance for `CallToolResult.content`: an inbound legacy-era `tools/call` result without `content` defaults to `[]` instead of failing validation. Deployed servers — accepted by SDK v1 for years — return `structuredContent`-only (or otherwise content-less) results, and the strict parse turned every such call into an `INVALID_RESULT` error before application code could run.

The silent-empty-success hazard the strictness guarded is preserved where it matters: the 2025 era's wire-seam schema refuses to default `content` for a body carrying another result family's vocabulary (`task`, `inputRequests`, `requestState` — the era is frozen, so the list is complete), and the 2026-era wire schemas stay strict — modern-revision servers have no legacy excuse. Task interop through an explicit result schema is untouched (including bodies that also stamp a foreign `resultType`), and the server-side authoring normalization refuses the same foreign-family vocabulary.

Server-side authoring is era-independent: a handler result without `content` (dynamic/JS callers — the TypeScript surface requires it) is normalized to `content: []` before era validation on every leg, reaching the wire spec-valid.

Conscious call: the nested sampling `ToolResultContentSchema` stays spec-strict — v1 had defaulted its `content` too, but it is params-side (tool results a caller authors into a sampling message), deliberately not restored.
5 changes: 5 additions & 0 deletions .changeset/initialize-session-hygiene.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

The Streamable HTTP client transport no longer attaches a session ID to a POST containing an `initialize` request — a new session starts "without a session ID attached" (2025-11-25 transports §Session Management) — and it only captures the `mcp-session-id` response header from a successful initialize response, since the spec assigns the session ID "at initialization time … on the HTTP response containing the InitializeResult". Previously the transport stored the header from any response, so a legacy server answering a protocol-version probe with an error that happened to carry a session ID would poison the fallback initialize, which then went out with a session ID it should not have had. A stale session ID from a previous connection is likewise no longer leaked onto the initialize handshake, and a successful initialize response that carries no session ID now clears any stale ID the transport was holding — clients include only an ID "returned by the server during initialization", so an ID the server never returned this session is outside the session model. Ignoring `mcp-session-id` headers mid-session is the complement of the spec's one actual rotation mechanism: a server that wants a new session terminates the old one (it "MAY terminate the session at any time") and answers 404, after which the client "MUST start a new session by sending a new InitializeRequest without a session ID attached". Rotation exists as session replacement via 404 + re-initialize, never as a header swap on a live session, so a server that rotates per the spec's own flow is handled correctly by this transport.
16 changes: 15 additions & 1 deletion .changeset/pre.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,22 @@
},
"changesets": [
"beta-release",
"calltoolresult-content-default",
"cjs-ajv-validator-subpath",
"cjs-support-v2-packages",
"codemod-iterations-5",
"post-dispatch-32021-http-400"
"codemod-versions-from-manifests",
"content-type-media-type-validation",
"cross-bundle-error-instanceof",
"examples-protected-wiring",
"initialize-session-hygiene",
"malformed-resource-uri-invalid-params",
"post-dispatch-32021-http-400",
"probe-window-handler-restore",
"silent-validators-wave",
"standard-header-ows",
"standard-schema-elicitation",
"web-standard-bearer-auth",
"web-standard-oauth-metadata"
]
}
2 changes: 1 addition & 1 deletion .changeset/standard-schema-elicitation.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
'@modelcontextprotocol/server': minor
---

Allow `inputRequired.elicit()` to accept a Standard Schema such as a Zod object for `requestedSchema`. The builder converts it to MCP's restricted form-elicitation JSON Schema, while the same schema can validate and type the response through `acceptedContent()` on handler re-entry.
Allow `inputRequired.elicit()` to accept a Standard Schema such as a Zod object for `requestedSchema`. The builder converts it to MCP's restricted form-elicitation JSON Schema, while the same schema can validate and type the response through `acceptedContent()` on handler re-entry. Zod formats mapping to `email`, `uri`, `date`, and `date-time` are supported. Shapes the restricted schema cannot express reject before anything is sent — nested objects, `.regex()` and customized zod format patterns, exclusive number bounds (`.positive()`/`.gt()`), literal unions (use `z.enum` or `z.literal(['a', 'b'])`), and non-spec root keywords like `z.strictObject()`'s `additionalProperties`.
8 changes: 5 additions & 3 deletions docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,9 +340,11 @@ If you were on a v2 alpha and consumed wire schemas directly:

The `resultType` / `EmptyResultSchema` / `specTypeSchemas` rules above have **no v1.x
impact** — these members did not exist before 2026-07-28. The neutral-model wire
tightening that **does** affect v1 code (`content` required, custom-handler `_meta`
passthrough, `specTypeSchemas` narrowing) is in
[upgrade-to-v2.md › Wire tightening](./upgrade-to-v2.md#wire-tightening-every-era).
tightening that **does** affect v1 code (custom-handler `_meta` passthrough,
`specTypeSchemas` narrowing) is in
[upgrade-to-v2.md › Wire tightening](./upgrade-to-v2.md#wire-tightening-every-era);
`CallToolResult.content` keeps its v1 default on the legacy era (2026-07-28
connections require it explicitly).

> **If you were on a v2 alpha:** the 2026-07-28 draft error codes were renumbered:
> `HeaderMismatch` `-32001`→`-32020`, `MissingRequiredClientCapability` `-32003`→`-32021`,
Expand Down
14 changes: 10 additions & 4 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -1673,10 +1673,16 @@ requests, the per-request `_meta.logLevel` envelope key is the filter — see

#### Wire tightening (every era)

- **`CallToolResult.content` is required at the wire boundary.** The `content.default([])`
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`.
- **`CallToolResult.content` keeps the v1 parse tolerance on the legacy era.** An
inbound result without `content` defaults to `[]` (deployed servers omit it
alongside `structuredContent`); 2026-07-28 connections stay strict. Authoring is
unchanged and era-independent: the TypeScript surface requires `content` on handler
results, and a content-less handler result is normalized to `content: []` before it
reaches the wire. One sharpening remains: a content-less body carrying another
result family's vocabulary (a task handle or an `input_required` round) is still
rejected loudly — tolerance never turns a different result kind into a silent empty
success. A body whose only foreign key was `resultType` strips to an empty object
and defaults, exactly as v1 parsed a payload-free body.
- **`ElicitResult.content` values are typed and validated as
`string | number | boolean | string[]`.** v1's TypeScript surface accepted
`Record<string, unknown>` content values; an elicitation handler returning arbitrary
Expand Down
2 changes: 2 additions & 0 deletions docs/servers/input-required.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ server.registerTool(

The first round converts `confirmationSchema` to MCP's restricted elicitation JSON Schema and returns it inside `resultType: 'input_required'`. The client fulfils the request and retries `deploy`; on re-entry `acceptedContent` validates the answer with that same schema and the handler finishes.

The restricted wire schema is a flat object of primitive properties, so only schemas that convert to that shape are accepted: strings (including the `email`, `uri`, `date`, and `date-time` formats — `z.email()`, `z.iso.date()`, and friends), numbers and their inclusive bounds (`.min()`/`.max()`; exclusive bounds like `.positive()` or `.gt()` do not convert), booleans, enums (`z.enum` or `z.literal(['a', 'b'])` — a union of literals does not convert), multi-select enum arrays, `.optional()`, and `.default()`. Anything the wire cannot express — nested objects, `.regex()` patterns, customized zod format patterns (`z.email({ pattern })`) — throws a `TypeError` when the request is built, before anything is sent. For non-zod libraries a pattern accompanying a supported format is treated as the library's own format regex and dropped from the wire. Constraints the wire cannot advertise at all (refinements, transforms) still hold on re-entry, because `acceptedContent` validates with the original schema.

Every call on this page comes from an in-memory `Client` with an `elicitation/create` handler — [Test a server](../testing.md) shows that wiring. Calling `deploy` once produces both rounds:

```
Expand Down
39 changes: 39 additions & 0 deletions packages/client/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,44 @@
# @modelcontextprotocol/client

## 2.0.0-beta.3

### Patch Changes

- [#2456](https://github.com/modelcontextprotocol/typescript-sdk/pull/2456) [`44797d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/44797d77792953d0ce70b68922bb6bb69e697c32) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Restore the v1 parse tolerance for `CallToolResult.content`: an inbound legacy-era `tools/call` result without `content` defaults to `[]` instead of failing validation. Deployed servers — accepted by SDK v1 for years — return `structuredContent`-only (or otherwise content-less) results, and the strict parse turned every such call into an `INVALID_RESULT` error before application code could run.

The silent-empty-success hazard the strictness guarded is preserved where it matters: the 2025 era's wire-seam schema refuses to default `content` for a body carrying another result family's vocabulary (`task`, `inputRequests`, `requestState` — the era is frozen, so the list is complete), and the 2026-era wire schemas stay strict — modern-revision servers have no legacy excuse. Task interop through an explicit result schema is untouched (including bodies that also stamp a foreign `resultType`), and the server-side authoring normalization refuses the same foreign-family vocabulary.

Server-side authoring is era-independent: a handler result without `content` (dynamic/JS callers — the TypeScript surface requires it) is normalized to `content: []` before era validation on every leg, reaching the wire spec-valid.

Conscious call: the nested sampling `ToolResultContentSchema` stays spec-strict — v1 had defaulted its `content` too, but it is params-side (tool results a caller authors into a sampling message), deliberately not restored.

- [#2431](https://github.com/modelcontextprotocol/typescript-sdk/pull/2431) [`1b90c96`](https://github.com/modelcontextprotocol/typescript-sdk/commit/1b90c96d11fd17016d2977cae9dd661de3fb84df) Thanks [@morluto](https://github.com/morluto)! - Fix the CommonJS `validators/ajv` subpath so reading the exported `Ajv` class no longer throws `ReferenceError: import_ajv is not defined`. The subpath now re-exports the bundled provider's concrete `Ajv` value in CJS output, matching the existing ESM behavior.

- [#2441](https://github.com/modelcontextprotocol/typescript-sdk/pull/2441) [`561c6d8`](https://github.com/modelcontextprotocol/typescript-sdk/commit/561c6d83456ef98d6c713bbda9837e64337f22c9) Thanks [@felixweinberger](https://github.com/felixweinberger)! - POSTs whose `Content-Type` media type is not `application/json` are now
rejected with `415 Unsupported Media Type`; the header is parsed instead of
substring-matched. Previously any value merely containing the substring
passed the check (for example `text/plain; a=application/json`), case
variants were wrongly rejected, and the 2026-07-28 entry did not inspect
`Content-Type` at all — requests with a missing or non-JSON header that used
to be served on that path now also answer 415. Values with parameters
(`application/json; charset=utf-8`, including malformed parameter sections
like `application/json;`) continue to work. SDK clients always send the
correct header and are unaffected.

The new `isJsonContentType(header)` helper is exported for transport and
framework-adapter authors — custom entries composing the exported building
blocks (`classifyInboundRequest`, `PerRequestHTTPServerTransport`) must apply
it themselves. The hono adapter's JSON body pre-parse and the client's
response dispatch now use the same parsed-media-type comparison.

- [#2384](https://github.com/modelcontextprotocol/typescript-sdk/pull/2384) [`ce2f65d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/ce2f65db0e019506f4d2526466ec8cc7106de98e) Thanks [@felixweinberger](https://github.com/felixweinberger)! - `instanceof` on the SDK error classes (`ProtocolError` and its typed subclasses, `SdkError`/`SdkHttpError`, `OAuthError`, and the client's `SseError`, `UnauthorizedError`, and OAuth-client-flow error family — `OAuthClientFlowError` and its subclasses) now works across separately bundled copies of the SDK. The classes match by a stable brand (via `Symbol.hasInstance` and a registry symbol) instead of prototype identity, so a process that uses both `@modelcontextprotocol/client` and `@modelcontextprotocol/server` - a gateway, host, or in-process test - can check errors constructed by either package against the class re-exported by the other. Ordinary prototype-based `instanceof` is preserved as a fallback; user-defined subclasses keep plain prototype semantics. Notes: cross-bundle matching requires both copies to be at or after this release; brands assert identity, not field shape, across versions - keep reading fields defensively. As a side effect, a foreign-bundle `SdkError` used as an abort reason is now rethrown as-is instead of being wrapped as a `RequestTimeout`. Branded hierarchies additionally expose an explicit static guard, `X.isInstance(value)`, that reads the same brand and narrows in TypeScript — an alternative for codebases that prefer predicate-style checks over `instanceof`. Also: `UnauthorizedError` now sets `error.name` to `'UnauthorizedError'` (previously `'Error'`), and per-package conformance tests enforce that every exported error class participates in branding. Version-negotiation probing now recognizes `UnauthorizedError` (previously a dead name-string check) and propagates it unchanged, so `connect()` on an auth-gated server rejects with the original `UnauthorizedError` (previously wrapped as the `cause` of an `SdkError(EraNegotiationFailed)`) — run `finishAuth()` and reconnect, and the retry probes with the token.

- [#2469](https://github.com/modelcontextprotocol/typescript-sdk/pull/2469) [`9b41b56`](https://github.com/modelcontextprotocol/typescript-sdk/commit/9b41b5685ded29c0afc194bbd91bb1902bee6f84) Thanks [@felixweinberger](https://github.com/felixweinberger)! - The Streamable HTTP client transport no longer attaches a session ID to a POST containing an `initialize` request — a new session starts "without a session ID attached" (2025-11-25 transports §Session Management) — and it only captures the `mcp-session-id` response header from a successful initialize response, since the spec assigns the session ID "at initialization time … on the HTTP response containing the InitializeResult". Previously the transport stored the header from any response, so a legacy server answering a protocol-version probe with an error that happened to carry a session ID would poison the fallback initialize, which then went out with a session ID it should not have had. A stale session ID from a previous connection is likewise no longer leaked onto the initialize handshake, and a successful initialize response that carries no session ID now clears any stale ID the transport was holding — clients include only an ID "returned by the server during initialization", so an ID the server never returned this session is outside the session model. Ignoring `mcp-session-id` headers mid-session is the complement of the spec's one actual rotation mechanism: a server that wants a new session terminates the old one (it "MAY terminate the session at any time") and answers 404, after which the client "MUST start a new session by sending a new InitializeRequest without a session ID attached". Rotation exists as session replacement via 404 + re-initialize, never as a header swap on a live session, so a server that rotates per the spec's own flow is handled correctly by this transport.

- [#2455](https://github.com/modelcontextprotocol/typescript-sdk/pull/2455) [`cc70c5e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/cc70c5e6a9f9b1c15dcba0bdd019a479b81375de) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Version negotiation no longer discards transport handlers the caller set before `connect()`. The probe window now saves any pre-set `onmessage`/`onerror`/`onclose`, forwards error and close events to them while the probe is in flight, and restores them when the window closes — so `Protocol.connect()` chains them exactly as it does on a plain connect. Previously, connecting with `versionNegotiation` silently cleared pre-set handlers (e.g. an `onerror` used to detect session-expiry auth failures), leaving them permanently detached for the life of the connection.

- [#2425](https://github.com/modelcontextprotocol/typescript-sdk/pull/2425) [`e8de519`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e8de519d3129f46b7528d2999b7641f55be1f091) Thanks [@Sehlani042](https://github.com/Sehlani042)! - Stop advertising validator provider classes from the root client/server type declarations. The provider classes remain available from the explicit validator subpaths.

## 2.0.0-beta.2

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/client",
"version": "2.0.0-beta.2",
"version": "2.0.0-beta.3",
"description": "Model Context Protocol implementation for TypeScript - Client package",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
Expand Down
14 changes: 10 additions & 4 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
createFetchWithInit,
encodeMcpParamValue,
isInitializedNotification,
isInitializeRequest,
isJSONRPCErrorResponse,
isJSONRPCRequest,
isJSONRPCResultResponse,
Expand Down Expand Up @@ -936,6 +937,11 @@ export class StreamableHTTPClientTransport implements Transport {

const headers = await this._commonHeaders();
this._applyBodyDerivedHeaders(headers, message);
// A new session starts "without a session ID attached" (2025-11-25 transports §Session Management).
const isHandshake = Array.isArray(message) ? message.some(m => isInitializeRequest(m)) : isInitializeRequest(message);
if (isHandshake) {
headers.delete('mcp-session-id');
}
// Per-request additional headers (the Client passes SEP-2243
// `Mcp-Param-*` here on a 2026-07-28 connection). Reserved
// standard/auth header names are skipped so a caller cannot
Expand Down Expand Up @@ -973,10 +979,10 @@ export class StreamableHTTPClientTransport implements Transport {

const response = await (this._fetch ?? fetch)(this._url, init);

// Handle session ID received during initialization
const sessionId = response.headers.get('mcp-session-id');
if (sessionId) {
this._sessionId = sessionId;
// The spec assigns the session id "at initialization time … on the HTTP response containing the InitializeResult"; it is ignored everywhere else.
// Clients include only an id "returned by the server during initialization", so a sessionless handshake clears any stale id.
if (isHandshake && response.ok) {
this._sessionId = response.headers.get('mcp-session-id') || undefined;
}

if (!response.ok) {
Expand Down
Loading
Loading