diff --git a/.changeset/calltoolresult-content-default.md b/.changeset/calltoolresult-content-default.md new file mode 100644 index 0000000000..9d0daeebbf --- /dev/null +++ b/.changeset/calltoolresult-content-default.md @@ -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. diff --git a/.changeset/initialize-session-hygiene.md b/.changeset/initialize-session-hygiene.md new file mode 100644 index 0000000000..b12e3c514f --- /dev/null +++ b/.changeset/initialize-session-hygiene.md @@ -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. diff --git a/.changeset/pre.json b/.changeset/pre.json index 559973ea8a..fca2983b36 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -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" ] } diff --git a/.changeset/standard-schema-elicitation.md b/.changeset/standard-schema-elicitation.md index d4b1d86e06..bf584e5569 100644 --- a/.changeset/standard-schema-elicitation.md +++ b/.changeset/standard-schema-elicitation.md @@ -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`. diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index a9d603d11a..c781aea0c2 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -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`, diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 89188aa893..a5d5e99a5f 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -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` content values; an elicitation handler returning arbitrary diff --git a/docs/servers/input-required.md b/docs/servers/input-required.md index 595260f1e5..1fc42383f1 100644 --- a/docs/servers/input-required.md +++ b/docs/servers/input-required.md @@ -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: ``` diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index 98ca06c430..36b5e6a012 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -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 diff --git a/packages/client/package.json b/packages/client/package.json index f96b681bc7..752b5a38c3 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -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)", diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 0d8eff917b..9067fd1ec4 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -5,6 +5,7 @@ import { createFetchWithInit, encodeMcpParamValue, isInitializedNotification, + isInitializeRequest, isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, @@ -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 @@ -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) { diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index a20fb92252..a36bbc0ad3 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -93,6 +93,7 @@ describe('StreamableHTTPClientTransport', () => { method: 'initialize', params: { clientInfo: { name: 'test-client', version: '1.0' }, + capabilities: {}, protocolVersion: '2025-03-26' }, id: 'init-id' @@ -122,6 +123,123 @@ describe('StreamableHTTPClientTransport', () => { expect(lastCall[1].headers.get('mcp-session-id')).toBe('test-session-id'); }); + it('should not store session ID from an error response, then store it from a later successful initialize', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + capabilities: {}, + protocolVersion: '2025-03-26' + }, + id: 'init-id' + }; + + // A failed initialize (e.g. a legacy server rejecting a version probe) that carries a session ID + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: 'Bad Request', + text: () => Promise.resolve('Bad Request'), + headers: new Headers({ 'mcp-session-id': 'poisoned-session-id' }) + }); + + await expect(transport.send(message)).rejects.toThrow(); + expect(transport.sessionId).toBeUndefined(); + + // The fallback initialize succeeds and its session ID is captured + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'real-session-id' }) + }); + + await transport.send(message); + expect(transport.sessionId).toBe('real-session-id'); + }); + + it('should not attach a session ID to an initialize POST, clear a stale ID on a sessionless handshake, and adopt a newly returned one', async () => { + const initMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + capabilities: {}, + protocolVersion: '2025-03-26' + }, + id: 'init-id' + }; + + const staleTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + sessionId: 'stale-session-id' + }); + + // Sessionless handshake: the response carries no session ID + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }) + }); + + await staleTransport.send(initMessage); + + const initCall = (globalThis.fetch as Mock).mock.calls.at(-1)!; + expect(initCall[1].headers.get('mcp-session-id')).toBeNull(); + + // The sessionless handshake cleared the stale ID, so an ordinary request carries none + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers() + }); + + await staleTransport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'test-id' } as JSONRPCMessage); + expect((globalThis.fetch as Mock).mock.calls.at(-1)![1].headers.get('mcp-session-id')).toBeNull(); + + await staleTransport.close().catch(() => {}); + + // When the handshake DOES return a new ID, subsequent requests carry it instead of the preset + const replacedTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + sessionId: 'preset-session-id' + }); + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'new-session-id' }) + }); + + await replacedTransport.send(initMessage); + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers() + }); + + await replacedTransport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'test-id' } as JSONRPCMessage); + expect((globalThis.fetch as Mock).mock.calls.at(-1)![1].headers.get('mcp-session-id')).toBe('new-session-id'); + + await replacedTransport.close().catch(() => {}); + }); + + it('should ignore a session ID on a successful non-initialize response', async () => { + const sessionTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + sessionId: 'session-a' + }); + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers({ 'mcp-session-id': 'session-b' }) + }); + + await sessionTransport.send({ jsonrpc: '2.0', method: 'notifications/roots/list_changed' } as JSONRPCMessage); + expect(sessionTransport.sessionId).toBe('session-a'); + + await sessionTransport.close().catch(() => {}); + }); + it('should accept protocolVersion constructor option and include it in request headers', async () => { // When reconnecting with a preserved sessionId, users need to also preserve the // negotiated protocol version so the required mcp-protocol-version header is sent. @@ -156,6 +274,7 @@ describe('StreamableHTTPClientTransport', () => { method: 'initialize', params: { clientInfo: { name: 'test-client', version: '1.0' }, + capabilities: {}, protocolVersion: '2025-03-26' }, id: 'init-id' @@ -196,6 +315,7 @@ describe('StreamableHTTPClientTransport', () => { method: 'initialize', params: { clientInfo: { name: 'test-client', version: '1.0' }, + capabilities: {}, protocolVersion: '2025-03-26' }, id: 'init-id' diff --git a/packages/codemod/CHANGELOG.md b/packages/codemod/CHANGELOG.md index 88458c85a4..c603f018b1 100644 --- a/packages/codemod/CHANGELOG.md +++ b/packages/codemod/CHANGELOG.md @@ -1,5 +1,21 @@ # @modelcontextprotocol/codemod +## 2.0.0-beta.3 + +### Patch Changes + +- [#2419](https://github.com/modelcontextprotocol/typescript-sdk/pull/2419) [`79dc162`](https://github.com/modelcontextprotocol/typescript-sdk/commit/79dc162efcb4e1f7b820bfb6068906483cf71ec7) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Read the v2 package versions the codemod writes into migrated `package.json` files directly from the workspace manifests at build time, replacing the committed generated `versions.ts` (which went stale after every release and made source builds write outdated versions). + +- [#2420](https://github.com/modelcontextprotocol/typescript-sdk/pull/2420) [`7635115`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7635115d0112c3f980b45a9773a4770660af8aae) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Add runtime-neutral Bearer authentication to `@modelcontextprotocol/server`: + `requireBearerAuth` gates web-standard `fetch(request)` hosts (Cloudflare + Workers, Deno, Bun, Hono), built on the exported `verifyBearerToken` and + `bearerAuthChallengeResponse` pieces, with `OAuthTokenVerifier` now defined + here. The Express middleware adapts the same core and is unchanged in + behavior, except that `WWW-Authenticate` challenge values are now RFC 7235 + quoted-string sanitized (quotes and backslashes escaped, control and + non-ASCII characters replaced); `@modelcontextprotocol/express` re-exports + `OAuthTokenVerifier` as before. + ## 2.0.0-beta.2 ### Patch Changes diff --git a/packages/codemod/package.json b/packages/codemod/package.json index f3c19675a6..009d882195 100644 --- a/packages/codemod/package.json +++ b/packages/codemod/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/codemod", - "version": "2.0.0-beta.2", + "version": "2.0.0-beta.3", "description": "Codemod to migrate MCP TypeScript SDK code from v1 to v2", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/core-internal/CHANGELOG.md b/packages/core-internal/CHANGELOG.md index cce2b6f284..9d7fbeccac 100644 --- a/packages/core-internal/CHANGELOG.md +++ b/packages/core-internal/CHANGELOG.md @@ -1,5 +1,23 @@ # @modelcontextprotocol/core-internal +## 2.0.0-beta.2 + +### Minor Changes + +- [#2369](https://github.com/modelcontextprotocol/typescript-sdk/pull/2369) [`24be404`](https://github.com/modelcontextprotocol/typescript-sdk/commit/24be4040d454a9c5983901229068477c7a9ea796) Thanks [@mattzcarey](https://github.com/mattzcarey)! - 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`. + +### 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. + +- [#2453](https://github.com/modelcontextprotocol/typescript-sdk/pull/2453) [`0ab5d14`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0ab5d1471d6c7375878316df2930fca77eee1d2a) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Strip RFC 9110 optional whitespace around inbound `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` values before classifying and validating modern HTTP requests. This keeps valid requests portable across Fetch runtimes that expose raw leading or trailing SP/HTAB through `Headers.get()`. + ## 2.0.0-beta.1 ### Patch Changes diff --git a/packages/core-internal/eslint.config.mjs b/packages/core-internal/eslint.config.mjs index 64a6c212c0..ae8ff4ce39 100644 --- a/packages/core-internal/eslint.config.mjs +++ b/packages/core-internal/eslint.config.mjs @@ -7,7 +7,9 @@ export default [ { // Wire-layer isolation, outbound direction: nothing outside src/wire/ may // reach into a wire revision module. The wire layer's only public surface - // is src/wire/codec.ts (the WireCodec interface) and src/wire/bootstrap.ts. + // is src/wire/codec.ts (the WireCodec interface), src/wire/bootstrap.ts, + // and the leaf result-family module src/wire/resultFamilies.ts (the shared + // tools/call-result ruling, re-exported on the barrel). // test/wire/layeringInvariants.test.ts re-derives the same invariant with // zero exceptions. Type-only imports are exempted at the lint layer (a // type-only crossing is erased at runtime), but the test allows none. diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json index d38e0088d1..f2ca34f5a2 100644 --- a/packages/core-internal/package.json +++ b/packages/core-internal/package.json @@ -1,7 +1,7 @@ { "name": "@modelcontextprotocol/core-internal", "private": true, - "version": "2.0.0-beta.1", + "version": "2.0.0-beta.2", "description": "Model Context Protocol implementation for TypeScript - Core package", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/core-internal/src/index.ts b/packages/core-internal/src/index.ts index 058de16174..3e2f961fbe 100644 --- a/packages/core-internal/src/index.ts +++ b/packages/core-internal/src/index.ts @@ -28,11 +28,15 @@ export * from './util/inMemory'; // 2026-only seam runs in. NOTHING per-revision (registries, codec objects, // per-revision schemas) is ever exported on this barrel — sibling packages // reach the wire layer ONLY through `codecForVersion`'s function-only -// `WireCodec` surface. +// `WireCodec` surface. Sole exemption: the shared result-family ruling +// (`wire/resultFamilies.ts`), era-independent by design — the server's +// authoring normalization and the e2e wire sniffer apply the same ruling +// (and name the same vocabulary) as the 2025 wire seam. export * from './util/schema'; export * from './util/standardSchema'; export * from './util/zodCompat'; export { codecForVersion, MODERN_WIRE_REVISION } from './wire/codec'; +export { normalizeContentlessToolResult, TOOL_RESULT_FOREIGN_FAMILY_KEYS } from './wire/resultFamilies'; // Validator provider classes stay subpath-only. Re-exporting them here, even as // `type`, can make generated client/server root declarations advertise diff --git a/packages/core-internal/src/shared/elicitation.ts b/packages/core-internal/src/shared/elicitation.ts index b322f4f8f2..fc728cdcef 100644 --- a/packages/core-internal/src/shared/elicitation.ts +++ b/packages/core-internal/src/shared/elicitation.ts @@ -1,14 +1,24 @@ import { ProtocolErrorCode } from '../types/enums'; import { ProtocolError } from '../types/errors'; -import { ElicitRequestFormParamsSchema } from '../types/schemas'; -import type { ElicitRequestFormParams } from '../types/types'; -import { parseSchema } from '../util/schema'; +import { + BooleanSchemaSchema, + ElicitRequestFormParamsSchema, + LegacyTitledEnumSchemaSchema, + NumberSchemaSchema, + PrimitiveSchemaDefinitionSchema, + StringSchemaSchema, + TitledMultiSelectEnumSchemaSchema, + TitledSingleSelectEnumSchemaSchema, + UntitledMultiSelectEnumSchemaSchema, + UntitledSingleSelectEnumSchemaSchema +} from '../types/schemas'; +import type { ElicitRequestFormParams, StringSchema } from '../types/types'; +import { parseSchema, shapeKeys } from '../util/schema'; import type { StandardSchemaWithJSON } from '../util/standardSchema'; -import { isStandardSchemaWithJSON, standardSchemaToJsonSchema } from '../util/standardSchema'; +import { isLibraryFormatPattern, isStandardSchema, standardSchemaToJsonSchema } from '../util/standardSchema'; -/** Input accepted by `inputRequired.elicit()`. */ -export type ElicitInputParams = Omit & { - mode?: 'form'; +/** Input accepted by `inputRequired.elicit()`: a wire-ready elicitation JSON Schema or a Standard Schema. */ +export type ElicitInputParams = Omit & { requestedSchema: ElicitRequestFormParams['requestedSchema'] | StandardSchemaWithJSON; }; @@ -28,58 +38,164 @@ function convertStandardElicitationSchema(schema: StandardSchemaWithJSON): Recor } } -const ANNOTATION_ONLY_JSON_SCHEMA_KEYWORDS = new Set(['$comment', 'deprecated', 'examples', 'readOnly', 'writeOnly']); +// JSON Schema metadata-vocabulary keys: positions that cannot carry them drop them silently. +const ANNOTATION_ONLY_JSON_SCHEMA_KEYWORDS = new Set([ + '$comment', + 'deprecated', + 'description', + 'examples', + 'readOnly', + 'title', + 'writeOnly' +]); function isAnnotationOnlyJsonSchemaKeyword(key: string): boolean { return ANNOTATION_ONLY_JSON_SCHEMA_KEYWORDS.has(key) || key.startsWith('x-'); } -/** - * Finds converted keywords that MCP's restricted elicitation schema removed. - * Annotation-only metadata may be dropped; validation constraints may not be - * weakened silently. - */ -function findStrippedConstraintPaths(original: unknown, parsed: unknown, path = ''): string[] { - if (Array.isArray(original) && Array.isArray(parsed)) { - return original.flatMap((item, index) => findStrippedConstraintPaths(item, parsed[index], `${path}[${index}]`)); +// The wire grammar, derived from the wire schemas so it tracks spec revisions. `$schema` +// is spec-declared on the root but reaches the wire type via its catchall. +const ROOT_KEYS = new Set(['$schema', ...Object.keys(ElicitRequestFormParamsSchema.shape.requestedSchema.shape)]); + +const PROPERTY_KEYS_BY_TYPE: Record> = { + string: shapeKeys([ + StringSchemaSchema, + UntitledSingleSelectEnumSchemaSchema, + TitledSingleSelectEnumSchemaSchema, + LegacyTitledEnumSchemaSchema + ]), + number: shapeKeys([NumberSchemaSchema]), + integer: shapeKeys([NumberSchemaSchema]), + boolean: shapeKeys([BooleanSchemaSchema]), + array: shapeKeys([UntitledMultiSelectEnumSchemaSchema, TitledMultiSelectEnumSchemaSchema]) +}; + +const SUPPORTED_STRING_FORMATS: ReadonlySet = new Set(StringSchemaSchema.shape.format.unwrap().options); + +/** Walks one property node: keeps grammar keys, drops the library format pattern, rejects unknown constraints. */ +function walkProperty(node: unknown, path: string, vendor: string, unsupported: string[]): unknown { + if (!isJsonObject(node)) { + return node; + } + // Object.hasOwn: a `type` like 'constructor' must not resolve through the prototype chain. + const allowedKeys = + typeof node.type === 'string' && Object.hasOwn(PROPERTY_KEYS_BY_TYPE, node.type) ? PROPERTY_KEYS_BY_TYPE[node.type] : undefined; + if (allowedKeys === undefined) { + // Unknown `type` — value validation rejects the node and names it. + return node; + } + + const pruned: Record = {}; + for (const [key, value] of Object.entries(node)) { + if (allowedKeys.has(key) || isAnnotationOnlyJsonSchemaKeyword(key)) { + pruned[key] = value; + } else if (key === 'pattern' && node.type === 'string' && typeof node.format === 'string') { + if (!SUPPORTED_STRING_FORMATS.has(node.format)) { + pruned[key] = value; // the unsupported format itself fails value validation + } else if ( + typeof value !== 'string' || + !isLibraryFormatPattern(node.format as NonNullable, value, vendor) + ) { + // A customized pattern must not be silently weakened. + unsupported.push(`${path}.${key}`); + } + } else { + unsupported.push(`${path}.${key}`); + } + } + return pruned; +} + +/** Walks the schema root: keeps the spec root keys, drops annotations, rejects the rest. */ +function walkRequestedSchema(converted: Record, vendor: string): Record { + const pruned: Record = {}; + const unsupported: string[] = []; + for (const [key, value] of Object.entries(converted)) { + if (key === 'properties' && isJsonObject(value)) { + pruned[key] = Object.fromEntries( + Object.entries(value).map(([name, node]) => [name, walkProperty(node, `properties.${name}`, vendor, unsupported)]) + ); + } else if (ROOT_KEYS.has(key)) { + pruned[key] = value; + } else if (!isAnnotationOnlyJsonSchemaKeyword(key)) { + unsupported.push(key); + } + } + if (unsupported.length > 0) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Elicitation requestedSchema contains unsupported JSON Schema constraint(s) after Standard Schema conversion: ${unsupported.join(', ')}` + ); + } + return pruned; +} + +/** Names the properties that fail value validation, instead of surfacing a raw union dump. */ +function describeUnsupportedProperties(pruned: Record, fallback: string): string { + if (!isJsonObject(pruned.properties)) { + return fallback; } + const offenders = Object.entries(pruned.properties) + .filter(([, node]) => !parseSchema(PrimitiveSchemaDefinitionSchema, node).success) + .map(([name]) => `properties.${name}`); + return offenders.length > 0 ? offenders.join(', ') : fallback; +} +// Safety net: value validation strips key combinations no single wire shape carries +// (e.g. `format` beside `enum`); a dropped non-annotation key must reject. +function findDroppedConstraintPaths(original: unknown, parsed: unknown, path = ''): string[] { + if (Array.isArray(original) && Array.isArray(parsed)) { + return original.flatMap((item, index) => findDroppedConstraintPaths(item, parsed[index], `${path}[${index}]`)); + } if (!isJsonObject(original) || !isJsonObject(parsed)) { return []; } - return Object.entries(original).flatMap(([key, value]) => { const childPath = path ? `${path}.${key}` : key; if (!Object.prototype.hasOwnProperty.call(parsed, key)) { return isAnnotationOnlyJsonSchemaKeyword(key) ? [] : [childPath]; } - return findStrippedConstraintPaths(value, parsed[key], childPath); + return findDroppedConstraintPaths(value, parsed[key], childPath); }); } /** Converts an authoring-friendly elicitation input into its wire-ready form. */ export function normalizeElicitInputParams(input: ElicitInputParams): ElicitRequestFormParams { - if (!isStandardSchemaWithJSON(input.requestedSchema)) { + // Route on `~standard.validate`: the converter owns the per-vendor fallback (zod + // 4.0/4.1 has no `~standard.jsonSchema`) — same decision as normalizeRawShapeSchema. + if (!isStandardSchema(input.requestedSchema)) { return { ...input, mode: 'form', requestedSchema: input.requestedSchema }; } - const convertedSchema = convertStandardElicitationSchema(input.requestedSchema); - const normalized = { ...input, mode: 'form' as const, requestedSchema: convertedSchema }; - const parsed = parseSchema(ElicitRequestFormParamsSchema, normalized); + const vendor = input.requestedSchema['~standard'].vendor; + const pruned = walkRequestedSchema(convertStandardElicitationSchema(input.requestedSchema), vendor); + + // Scoped to the converted schema so params-level fields behave as on the raw branch. + const parsed = parseSchema(ElicitRequestFormParamsSchema.shape.requestedSchema, pruned); if (!parsed.success) { throw new ProtocolError( ProtocolErrorCode.InvalidParams, - `Elicitation requestedSchema only supports flat primitive properties (string, number, integer, boolean, and string enums): ${parsed.error.message}` + `Elicitation requestedSchema only supports flat primitive properties (string, number, integer, boolean, and string enums): ${describeUnsupportedProperties(pruned, parsed.error.message)}` + ); + } + + const droppedConstraints = findDroppedConstraintPaths(pruned, parsed.data); + if (droppedConstraints.length > 0) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Elicitation requestedSchema contains unsupported JSON Schema constraint(s) after Standard Schema conversion: ${droppedConstraints.join(', ')}` ); } - const strippedConstraints = findStrippedConstraintPaths(convertedSchema, parsed.data.requestedSchema); - if (strippedConstraints.length > 0) { + // Converters can lose exotic property names from `properties` while keeping them in + // `required` (zod's toJSONSchema does for `__proto__`). + const danglingRequired = (parsed.data.required ?? []).filter(key => !Object.prototype.hasOwnProperty.call(parsed.data.properties, key)); + if (danglingRequired.length > 0) { throw new ProtocolError( ProtocolErrorCode.InvalidParams, - `Elicitation requestedSchema contains unsupported JSON Schema constraint(s) after Standard Schema conversion: ${strippedConstraints.join(', ')}` + `Elicitation requestedSchema lists required properties that are not defined in properties: ${danglingRequired.join(', ')}` ); } - return parsed.data; + return { ...input, mode: 'form', requestedSchema: parsed.data }; } diff --git a/packages/core-internal/src/shared/inputRequired.ts b/packages/core-internal/src/shared/inputRequired.ts index 4a9fe618dd..b2736e23b6 100644 --- a/packages/core-internal/src/shared/inputRequired.ts +++ b/packages/core-internal/src/shared/inputRequired.ts @@ -61,7 +61,14 @@ interface InputRequiredBuilder { */ (spec: InputRequiredSpec): InputRequiredResult; - /** Builds an embedded form-mode elicitation request (`elicitation/create`). */ + /** + * Builds an embedded form-mode elicitation request (`elicitation/create`). + * + * A Standard Schema `requestedSchema` is converted to the restricted wire shape; + * shapes it cannot express throw a `TypeError` before anything is sent. Responses + * are not validated against it — pass the same schema to `acceptedContent()` on + * re-entry for validated, typed content. + */ elicit(params: ElicitInputParams): InputRequest; /** diff --git a/packages/core-internal/src/types/guards.ts b/packages/core-internal/src/types/guards.ts index a0d575054e..eb1ebdf6af 100644 --- a/packages/core-internal/src/types/guards.ts +++ b/packages/core-internal/src/types/guards.ts @@ -84,7 +84,9 @@ export const isJSONRPCResponse = (value: unknown): value is JSONRPCResponse => J * @returns True if the value is a valid {@linkcode CallToolResult}, false otherwise. */ export const isCallToolResult = (value: unknown): value is CallToolResult => { - if (typeof value !== 'object' || value === null || !('content' in value)) return false; + // content === undefined covers an explicit undefined key too: the schema's + // .default([]) would parse it, but the narrowed type requires the array. + if (typeof value !== 'object' || value === null || (value as { content?: unknown }).content === undefined) return false; return CallToolResultSchema.safeParse(value).success; }; diff --git a/packages/core-internal/src/types/schemas.ts b/packages/core-internal/src/types/schemas.ts index 21960a6176..bc64597b98 100644 --- a/packages/core-internal/src/types/schemas.ts +++ b/packages/core-internal/src/types/schemas.ts @@ -1381,8 +1381,11 @@ export const CallToolResultSchema = ResultSchema.extend({ * * If the `Tool` does not define an outputSchema, this field MUST be present in the result. * Required on the wire per the specification (it may be an empty array). + * + * Parse-tolerant: absent `content` defaults to `[]` (v1 parity — deployed + * servers omit it alongside `structuredContent`). */ - content: z.array(ContentBlockSchema), + content: z.array(ContentBlockSchema).default([]), /** * Structured tool output. diff --git a/packages/core-internal/src/util/schema.ts b/packages/core-internal/src/util/schema.ts index 9676674b84..6475b4dd39 100644 --- a/packages/core-internal/src/util/schema.ts +++ b/packages/core-internal/src/util/schema.ts @@ -30,3 +30,10 @@ export function parseSchema( ): { success: true; data: z.output } | { success: false; error: z.core.$ZodError } { return z.safeParse(schema, data); } + +/** + * Union of the declared shape keys across several Zod object schemas. + */ +export function shapeKeys(schemas: Array<{ shape: Record }>): ReadonlySet { + return new Set(schemas.flatMap(schema => Object.keys(schema.shape))); +} diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index f5e1283f26..d904c7f5fa 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -8,6 +8,8 @@ import * as z from 'zod/v4'; +import type { StringSchema } from '../types/types'; + // Standard Schema interfaces — vendored from https://standardschema.dev (spec v1, Jan 2025) export interface StandardTypedV1 { @@ -164,6 +166,9 @@ export function isStandardSchemaWithJSON(schema: unknown): schema is StandardSch let warnedZodFallback = false; +/** JSON Schema draft targeted by every conversion; shared so pattern references above stay in lockstep. */ +export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; + /** * Converts a StandardSchema to JSON Schema for use as an MCP tool/prompt schema. * @@ -179,7 +184,7 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in const std = schema['~standard']; let result: Record; if (std.jsonSchema) { - result = std.jsonSchema[io]({ target: 'draft-2020-12' }); + result = std.jsonSchema[io]({ target: JSON_SCHEMA_CONVERSION_TARGET }); } else if (std.vendor === 'zod') { // zod 4.0–4.1 implements StandardSchemaV1 but not StandardJSONSchemaV1 (`~standard.jsonSchema`). // The SDK already bundles zod 4, so fall back to its converter rather than crashing on tools/list. @@ -198,7 +203,7 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in 'Falling back to z.toJSONSchema(). Upgrade to zod >=4.2.0 to silence this warning.' ); } - result = z.toJSONSchema(schema as unknown as z.ZodType, { target: 'draft-2020-12', io }) as Record; + result = z.toJSONSchema(schema as unknown as z.ZodType, { target: JSON_SCHEMA_CONVERSION_TARGET, io }) as Record; } else { throw new Error( `Schema library "${std.vendor}" does not implement StandardJSONSchemaV1 (\`~standard.jsonSchema\`). ` + @@ -275,6 +280,66 @@ export async function validateStandardSchema( return { success: true, data: (result as StandardSchemaV1.SuccessResult).value as StandardSchemaV1.InferOutput }; } +/* + * Format-companion patterns: libraries realize a string `format` check as a companion + * `pattern` regex, which the elicitation wire schema cannot carry. zod's are derived + * from the resolved zod at runtime (never vendored — in-range releases change them), so + * customized zod patterns are distinguishable and reject; other vendors' realizations + * are unknowable (e.g. ArkType's `string.email`), so their patterns are trusted-and-dropped. + */ + +function zodEmittedPattern(schema: z.ZodType): string | undefined { + const jsonSchema = z.toJSONSchema(schema, { target: JSON_SCHEMA_CONVERSION_TARGET, io: 'input' }) as Record; + return typeof jsonSchema.pattern === 'string' ? jsonSchema.pattern : undefined; +} + +const DATETIME_FRACTION_DIGITS = /\\\.\\d\{(\d+)\}/; + +function datetimeReferenceSchemas(pattern: string): z.ZodType[] { + // Options (offset/local/precision) vary the emission; recovering the fraction-digit + // count keeps the candidate set finite. + const fractionDigits = DATETIME_FRACTION_DIGITS.exec(pattern); + const precisions: Array = [undefined, -1, 0]; + if (fractionDigits) { + precisions.push(Number(fractionDigits[1])); + } + return [false, true].flatMap(local => + [false, true].flatMap(offset => precisions.map(precision => z.iso.datetime({ local, offset, precision }))) + ); +} + +// Exhaustive over the wire's format enum: a new spec format is a compile error here. +function referencePatternsForFormat(format: NonNullable, pattern: string): ReadonlySet { + let referenceSchemas: z.ZodType[]; + switch (format) { + case 'email': { + referenceSchemas = [z.email()]; + break; + } + case 'uri': { + referenceSchemas = [z.url()]; + break; + } + case 'date': { + referenceSchemas = [z.iso.date()]; + break; + } + case 'date-time': { + referenceSchemas = datetimeReferenceSchemas(pattern); + break; + } + } + return new Set(referenceSchemas.map(schema => zodEmittedPattern(schema)).filter((emitted): emitted is string => emitted !== undefined)); +} + +/** Whether `pattern` is the library's own realization of `format` (droppable) rather than a user customization. */ +export function isLibraryFormatPattern(format: NonNullable, pattern: string, vendor: string): boolean { + if (vendor !== 'zod') { + return true; + } + return referencePatternsForFormat(format, pattern).has(pattern); +} + // Prompt argument extraction export function promptArgumentsFromStandardSchema( diff --git a/packages/core-internal/src/wire/resultFamilies.ts b/packages/core-internal/src/wire/resultFamilies.ts new file mode 100644 index 0000000000..10b0b9ee5e --- /dev/null +++ b/packages/core-internal/src/wire/resultFamilies.ts @@ -0,0 +1,24 @@ +/** + * Result-family keys that must never default into a `{content: []}` tools/call + * success. Shared by the 2025 wire-seam schema and server normalization. + * Leaf module (like `textFallback.ts`): imported by registry/server paths, so + * it must NOT import from `./codec.js` — that would close a runtime cycle. + */ +export const TOOL_RESULT_FOREIGN_FAMILY_KEYS = ['task', 'inputRequests', 'requestState'] as const; + +/** + * Single owner of the v1-parity ruling: a plain-object tool result without `content` (and + * without foreign-family keys) gains `content: []`. Shared by the 2025 wire seam and server-side handler normalization. + */ +export function normalizeContentlessToolResult(value: unknown): unknown { + if ( + value === null || + typeof value !== 'object' || + Array.isArray(value) || + (value as { content?: unknown }).content !== undefined || + TOOL_RESULT_FOREIGN_FAMILY_KEYS.some(key => key in value) + ) { + return value; + } + return { ...value, content: [] }; +} diff --git a/packages/core-internal/src/wire/rev2025-11-25/codec.ts b/packages/core-internal/src/wire/rev2025-11-25/codec.ts index ed30e9c67b..de39f8b21a 100644 --- a/packages/core-internal/src/wire/rev2025-11-25/codec.ts +++ b/packages/core-internal/src/wire/rev2025-11-25/codec.ts @@ -105,8 +105,9 @@ export const rev2025Codec: WireCodec = { decodeResult(_method: string, raw: unknown): DecodedResult { // Strip-on-lift (Q1-SD3 ii): a foreign `resultType` on the 2025 leg is - // dropped before validation, whatever its value. There is no - // discrimination on this era — `resultType` carries no meaning here. + // dropped before validation, whatever its value. Validation judges the + // husk — the registry wire-seam schema on the plain path, the caller's + // schema on the explicit path (task interop). if (isPlainObject(raw) && 'resultType' in raw) { const stripped = { ...raw }; delete stripped['resultType']; diff --git a/packages/core-internal/src/wire/rev2025-11-25/registry.ts b/packages/core-internal/src/wire/rev2025-11-25/registry.ts index f2878d429e..fb582559f9 100644 --- a/packages/core-internal/src/wire/rev2025-11-25/registry.ts +++ b/packages/core-internal/src/wire/rev2025-11-25/registry.ts @@ -21,9 +21,10 @@ * shells, `resultType`, the `_meta` envelope) has NO entry and NO code path * here — the inverse-leak guarantee is physical absence, not discipline. */ -import type * as z from 'zod/v4'; +import * as z from 'zod/v4'; import type { NotificationMethod, NotificationTypeMap, RequestMethod, RequestTypeMap, ResultTypeMap } from '../../types/types'; +import { normalizeContentlessToolResult, TOOL_RESULT_FOREIGN_FAMILY_KEYS } from '../resultFamilies'; import type { ClientNotificationSchema, ClientRequestSchema, ServerNotificationSchema, ServerRequestSchema } from './schemas'; import { CallToolRequestSchema, @@ -106,6 +107,31 @@ type Rev2025TypedRequestMethod = Extract; // no key may fall outside it (no `tasks/*` entries — the task methods are // 2025-11-25 wire vocabulary with no SDK runtime; callers needing task // interop pass an explicit schema). +/** + * Wire seam: owns both halves of the v1-parity ruling — the guard (a content-less body + * carrying another result family's keys fails loudly; the era is frozen so the key list is + * complete) and the tolerance (`content` defaults to `[]`). The era file stays twin-conformant. + */ +export const CallToolResultWireSchema = z + .unknown() + .superRefine((value, ctx) => { + // content === undefined covers both an absent key and an explicit + // undefined from server-side authoring objects. + if (typeof value !== 'object' || value === null || Array.isArray(value) || (value as Record).content !== undefined) + return; + for (const key of TOOL_RESULT_FOREIGN_FAMILY_KEYS) { + if (key in value) { + ctx.addIssue({ + code: 'custom', + message: `content is required when the body carries '${key}' — another result family cannot default into an empty tools/call success` + }); + return; + } + } + }) + .transform(normalizeContentlessToolResult) + .pipe(CallToolResultSchema); + const resultSchemas: { readonly [M in Rev2025TypedRequestMethod]: z.ZodType } = { ping: EmptyResultSchema, initialize: InitializeResultSchema, @@ -118,7 +144,7 @@ const resultSchemas: { readonly [M in Rev2025TypedRequestMethod]: z.ZodType { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +/** + * v1 parse-parity for `CallToolResult.content` on the legacy era: absent + * content defaults to []; another result family's content-less body still + * fails loudly via the registry wire-seam schema. + */ +describe('CallToolResult content default (v1 parity)', () => { + async function respondWith(body: Record, resultSchema?: Parameters['request']>[1]) { + const protocol = new TestProtocolImpl(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + serverTransport.onmessage = message => { + if (isJSONRPCRequest(message)) { + void serverTransport.send({ + jsonrpc: '2.0', + id: (message as JSONRPCRequest).id, + result: body + }); + } + }; + await serverTransport.start(); + await protocol.connect(clientTransport); + try { + return resultSchema === undefined + ? await protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }) + : await protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }, resultSchema); + } finally { + await protocol.close().catch(() => {}); + } + } + + it('a structured-only result resolves with content: []', async () => { + const result = (await respondWith({ structuredContent: { ok: true } })) as { + content: unknown; + structuredContent: unknown; + }; + expect(result.content).toEqual([]); + expect(result.structuredContent).toEqual({ ok: true }); + }); + + it('an entirely empty result resolves with content: []', async () => { + const result = (await respondWith({})) as { content: unknown }; + expect(result.content).toEqual([]); + }); + + it('a task-shaped body without content still fails loudly (wire-seam guard)', async () => { + await expect(respondWith({ task: { taskId: 't-1', status: 'working' } })).rejects.toBeInstanceOf(SdkError); + }); + + it('task interop via an explicit result schema still works — the guard never touches that overload', async () => { + const { CreateTaskResultSchema } = await import('../../src/wire/rev2025-11-25/schemas'); + const body = { + task: { + taskId: '786af6b0-2779-48ed-9cc1-b8a8a25b8a86', + status: 'working', + createdAt: '2025-11-25T10:30:00Z', + lastUpdatedAt: '2025-11-25T10:30:05Z', + ttl: 60000, + pollInterval: 5000 + } + }; + const result = (await respondWith(body, CreateTaskResultSchema)) as { task: { taskId: string } }; + expect(result.task.taskId).toBe('786af6b0-2779-48ed-9cc1-b8a8a25b8a86'); + }); + + it('explicit-schema task interop resolves even when the body also stamps a foreign resultType', async () => { + const { CreateTaskResultSchema } = await import('../../src/wire/rev2025-11-25/schemas'); + const body = { + resultType: 'complete', + task: { + taskId: '786af6b0-2779-48ed-9cc1-b8a8a25b8a86', + status: 'working', + createdAt: '2025-11-25T10:30:00Z', + lastUpdatedAt: '2025-11-25T10:30:05Z', + ttl: 60000, + pollInterval: 5000 + } + }; + const result = (await respondWith(body, CreateTaskResultSchema)) as { task: { taskId: string } }; + expect(result.task.taskId).toBe('786af6b0-2779-48ed-9cc1-b8a8a25b8a86'); + }); + + it('the wire-seam guard treats an explicit content: undefined like an absent key', async () => { + const { getResultSchema } = await import('../../src/wire/rev2025-11-25/registry'); + const wireSeam = getResultSchema('tools/call')!; + expect(wireSeam.safeParse({ task: { taskId: 't-1', status: 'working' }, content: undefined }).success).toBe(false); + }); + + it('an input_required-shaped body without content still fails loudly (wire-seam guard)', async () => { + await expect(respondWith({ inputRequests: { r1: { method: 'elicitation/create' } } })).rejects.toBeInstanceOf(SdkError); + await expect(respondWith({ requestState: 'opaque-token' })).rejects.toBeInstanceOf(SdkError); + }); +}); diff --git a/packages/core-internal/test/shared/inputRequired.test.ts b/packages/core-internal/test/shared/inputRequired.test.ts index b59a9f02da..bdc8187ac8 100644 --- a/packages/core-internal/test/shared/inputRequired.test.ts +++ b/packages/core-internal/test/shared/inputRequired.test.ts @@ -56,7 +56,7 @@ describe('inputRequired() builder', () => { const request = inputRequired.elicit({ message: 'Registration details?', requestedSchema: z.object({ - email: z.string().meta({ title: 'Email', format: 'email' }), + email: z.email().meta({ title: 'Email' }), count: z.number().min(1).max(5), role: z.enum(['admin', 'member']) }) @@ -126,12 +126,13 @@ describe('inputRequired() builder', () => { }); test('elicit rejects validation constraints the restricted wire schema cannot express', () => { - expect(() => + const rejectPattern = () => inputRequired.elicit({ message: 'Code?', requestedSchema: z.object({ code: z.string().regex(/^[A-Z]{3}$/) }) - }) - ).toThrow(/properties\.code\.pattern/); + }); + expect(rejectPattern).toThrow(TypeError); + expect(rejectPattern).toThrow(/properties\.code\.pattern/); expect(() => inputRequired.elicit({ @@ -139,6 +140,229 @@ describe('inputRequired() builder', () => { requestedSchema: z.object({ address: z.object({ city: z.string() }) }) }) ).toThrow(/flat primitive properties/); + + // A customized pattern layered on a format cannot ride the wire; silently sending + // `format` alone would weaken the advertised constraint, so it rejects. + expect(() => + inputRequired.elicit({ + message: 'Email?', + requestedSchema: z.object({ email: z.email({ pattern: /@corp\.com$/ }) }) + }) + ).toThrow(/properties\.email\.pattern/); + + // Literal unions are the idiomatic zod spelling of an enum, but they convert to + // `anyOf`/`const`, which matches no wire enum variant — pinned so a change here + // surfaces in CI rather than user reports. (`z.literal(['a', 'b'])` and `z.enum` + // both convert fine.) + expect(() => + inputRequired.elicit({ + message: 'Role?', + requestedSchema: z.object({ role: z.union([z.literal('admin'), z.literal('member')]) }) + }) + ).toThrow(TypeError); + }); + + test.each([ + ['z.email()', z.email(), 'email'], + ['z.url()', z.url(), 'uri'], + ['z.iso.date()', z.iso.date(), 'date'], + ['z.iso.datetime()', z.iso.datetime(), 'date-time'], + ['z.iso.datetime({ offset: true, precision: 3 })', z.iso.datetime({ offset: true, precision: 3 }), 'date-time'], + ['z.string().meta({ format: "email" }) (annotation-only format)', z.string().meta({ format: 'email' }), 'email'] + ])('elicit keeps the %s format and drops the zod-emitted pattern', (_label, fieldSchema, format) => { + const request = inputRequired.elicit({ + message: 'Value?', + requestedSchema: z.object({ value: fieldSchema }) + }); + + const valueSchema = (request.params as { requestedSchema: { properties: Record> } }).requestedSchema + .properties.value!; + expect(valueSchema.format).toBe(format); + expect(valueSchema.pattern).toBeUndefined(); + }); + + test('elicit keeps multi-select enums, optional fields, and defaults, and drops annotations the wire cannot place', () => { + const request = inputRequired.elicit({ + message: 'Preferences?', + requestedSchema: z.object({ + roles: z.array(z.enum(['admin', 'member']).meta({ title: 'Role' })), + plan: z.literal(['free', 'pro']), + nickname: z.string().optional(), + newsletter: z.boolean().default(false) + }) + }); + + expect((request.params as { requestedSchema: unknown }).requestedSchema).toEqual({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + roles: { type: 'array', items: { type: 'string', enum: ['admin', 'member'] } }, + plan: { type: 'string', enum: ['free', 'pro'] }, + nickname: { type: 'string' }, + newsletter: { type: 'boolean', default: false } + }, + required: ['roles', 'plan'] + }); + }); + + test("elicit trusts a non-zod vendor's format-companion pattern and drops it", () => { + // ArkType's `string.email` bakes in its own format regex; without vendor + // knowledge a companion pattern beside the retained format is the library's + // realization of it, not a customization. + const arkTypeLike = { + '~standard': { + version: 1, + vendor: 'arktype', + validate: (value: unknown) => ({ value }), + jsonSchema: { + input: () => ({ + type: 'object', + properties: { + email: { type: 'string', format: 'email', pattern: String.raw`^[\w%+.-]+@[\d.A-Za-z-]+\.[A-Za-z]{2,}$` } + }, + required: ['email'] + }), + output: () => ({}) + } + } + }; + const request = inputRequired.elicit({ message: 'Email?', requestedSchema: arkTypeLike as never }); + const emailSchema = (request.params as { requestedSchema: { properties: Record> } }).requestedSchema + .properties.email!; + expect(emailSchema.format).toBe('email'); + expect(emailSchema.pattern).toBeUndefined(); + }); + + test('elicit rejects schemas whose converted required entries have no matching property', () => { + // zod's toJSONSchema loses a `__proto__` property from `properties` while still + // listing it in `required` — reject rather than ship the corrupt schema. + const act = () => + inputRequired.elicit({ + message: 'Name?', + requestedSchema: z.object({ ['__proto__']: z.string() }) + }); + expect(act).toThrow(TypeError); + expect(act).toThrow(/required properties that are not defined/); + }); + + test('elicit keeps unknown params extension keys on both branches', () => { + const rawRequest = inputRequired.elicit({ + message: 'OK?', + requestedSchema: { type: 'object', properties: {} }, + myExtension: 'keep-me' + } as never); + const convertedRequest = inputRequired.elicit({ + message: 'OK?', + requestedSchema: z.object({}), + myExtension: 'keep-me' + } as never); + + expect((rawRequest.params as { myExtension?: string }).myExtension).toBe('keep-me'); + expect((convertedRequest.params as { myExtension?: string }).myExtension).toBe('keep-me'); + }); + + test('elicit rejects root keywords outside the spec requestedSchema shape', () => { + const act = () => + inputRequired.elicit({ + message: 'Name?', + requestedSchema: z.strictObject({ name: z.string() }) + }); + expect(act).toThrow(TypeError); + expect(act).toThrow(/unsupported JSON Schema constraint\(s\).*additionalProperties/); + + expect(() => + inputRequired.elicit({ + message: 'Name?', + requestedSchema: { + '~standard': { + version: 1, + vendor: 'test', + validate: (value: unknown) => ({ value }), + jsonSchema: { + input: () => ({ + $defs: { Name: { type: 'string' } }, + type: 'object', + properties: { name: { $ref: '#/$defs/Name' } }, + required: ['name'] + }), + output: () => ({}) + } + } + } as never + }) + ).toThrow(/\$defs/); + }); + + test('elicit drops annotation-only root keywords', () => { + const request = inputRequired.elicit({ + message: 'Name?', + requestedSchema: z.object({ name: z.string() }).meta({ title: 'User', description: 'User info' }) + }); + + expect((request.params as { requestedSchema: unknown }).requestedSchema).toEqual({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + }); + }); + + test('elicit converts function-typed Standard Schemas (ArkType-style)', () => { + const jsonSchema = { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }; + const functionSchema = Object.assign(() => {}, { + '~standard': { + version: 1 as const, + vendor: 'test', + validate: (value: unknown) => ({ value }), + jsonSchema: { input: () => jsonSchema, output: () => ({}) } + } + }); + + const request = inputRequired.elicit({ message: 'Name?', requestedSchema: functionSchema as never }); + expect((request.params as { requestedSchema: unknown }).requestedSchema).toEqual(jsonSchema); + }); + + test('elicit converts validate-only schemas via the converter rather than the raw branch', () => { + // Simulates zod before 4.2 (validate without `~standard.jsonSchema`): routing must + // go to the converter, which owns per-vendor handling — a non-zod vendor without + // jsonSchema fails closed there instead of shipping the schema object on the wire. + const validateOnly = { + '~standard': { + version: 1, + vendor: 'not-zod', + validate: (value: unknown) => ({ value }) + } + }; + const act = () => inputRequired.elicit({ message: 'Name?', requestedSchema: validateOnly as never }); + expect(act).toThrow(TypeError); + expect(act).toThrow(/does not implement StandardJSONSchemaV1/); + }); + + test('elicit passes a plain JSON schema containing a literal "~standard" key through the raw branch', () => { + const rawSchema = { + type: 'object' as const, + properties: { name: { type: 'string' as const } }, + '~standard': 'extension-data' + }; + const request = inputRequired.elicit({ message: 'Name?', requestedSchema: rawSchema as never }); + expect((request.params as { requestedSchema: unknown }).requestedSchema).toBe(rawSchema); + }); + + test('elicit rejects a property type shadowing an Object.prototype member with the normal message', () => { + const schema = { + '~standard': { + version: 1, + vendor: 'test', + validate: (value: unknown) => ({ value }), + jsonSchema: { + input: () => ({ type: 'object', properties: { x: { type: 'constructor' } }, required: ['x'] }), + output: () => ({}) + } + } + }; + expect(() => inputRequired.elicit({ message: 'x', requestedSchema: schema as never })).toThrow( + /flat primitive properties.*properties\.x/ + ); }); }); diff --git a/packages/core-internal/test/shared/rawResultTypeFirst.test.ts b/packages/core-internal/test/shared/rawResultTypeFirst.test.ts index 3e44c48ed7..87519c1562 100644 --- a/packages/core-internal/test/shared/rawResultTypeFirst.test.ts +++ b/packages/core-internal/test/shared/rawResultTypeFirst.test.ts @@ -137,12 +137,9 @@ describe('raw-first resultType discrimination — 2026 era (codec decode step 1) describe('raw-first resultType handling — 2025 era (strip-on-lift, Q1-SD3 ii)', () => { test('a foreign input_required body is stripped, then validation judges the content — never a silent success', async () => { - // BEHAVIOR MIGRATION (ledgered): pre-split, the era-blind funnel arm - // rejected this with UnsupportedResultType on every leg. On the 2025 - // era resultType carries no meaning — the ruled posture strips the - // foreign key and lets validation decide. The body has no content, - // so it fails the (default-free) tools/call result schema LOUDLY — - // the V-1 invariant (never a hollow success) holds. + // BEHAVIOR MIGRATION (ledgered): the strip drops the foreign key and + // the wire-seam schema refuses to default a husk carrying + // input_required keys — V-1 (never a hollow success) holds at the seam. const protocol = await wireWithRawResult(INPUT_REQUIRED_BODY); const outcome = await settle(protocol); diff --git a/packages/core-internal/test/shared/typedMapAlignment.test.ts b/packages/core-internal/test/shared/typedMapAlignment.test.ts index eacf493e4d..a8ad903651 100644 --- a/packages/core-internal/test/shared/typedMapAlignment.test.ts +++ b/packages/core-internal/test/shared/typedMapAlignment.test.ts @@ -93,18 +93,11 @@ describe('task-shaped result bodies against the narrowed runtime map', () => { await protocol.close(); }); - test('tools/call: a CreateTaskResult body is now a typed invalid-result error too (content-default removal flip)', async () => { - // FLIPPED PIN (Q1 increment 2, ledgered with the content-default - // removal — changeset: codec-split-wire-break). The previous "Honest - // pin, not an endorsement" recorded that CallToolResultSchema's - // content.default([]) swallowed ANY object — including a task body — - // as a content-empty success, which made the old union member - // unreachable and the map narrowing observationally invisible for - // tools/call. With `content` now REQUIRED at the wire boundary the - // masking surface is gone: a task body has no `content`, fails the - // plain schema, and surfaces as the same typed invalid-result error - // as sampling/elicit. The result-schema-strictness question the old - // pin deferred is hereby resolved: loud rejection. + test('tools/call: a CreateTaskResult body on the plain path is a typed invalid-result error (wire-seam guard)', async () => { + // FLIPPED PIN, twice-ledgered (changesets: codec-split-wire-break, + // calltoolresult-content-default). The wire-seam schema restores the + // v1 default for plain results but refuses to default a body carrying + // another result family's keys — a task body fails it loudly. const protocol = await wireWithRawResult(CREATE_TASK_RESULT_BODY); const rejection = await protocol diff --git a/packages/core-internal/test/types.test.ts b/packages/core-internal/test/types.test.ts index ba7adca0fb..e836086bfc 100644 --- a/packages/core-internal/test/types.test.ts +++ b/packages/core-internal/test/types.test.ts @@ -296,12 +296,14 @@ describe('Types', () => { } }); - test('requires content: the empty-object result no longer parses (deliberate flip)', () => { - // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): content.default([]) - // was removed from the wire schema (the T6 silent-empty-success - // masking root). Content is spec-required in every revision. - // Changeset: codec-split-wire-break. - expect(CallToolResultSchema.safeParse({}).success).toBe(false); + test('tolerates absent content: the empty-object result parses with content [] (v1 parity restored)', () => { + // BEHAVIOR MIGRATION (reversal, ledgered): content.default([]) is + // back on the neutral layer + 2025 era; T6 closed at the wire seam. + const empty = CallToolResultSchema.safeParse({}); + expect(empty.success).toBe(true); + if (empty.success) { + expect(empty.data.content).toEqual([]); + } const result = CallToolResultSchema.safeParse({ content: [] }); expect(result.success).toBe(true); if (result.success) { diff --git a/packages/core-internal/test/types/guards.test.ts b/packages/core-internal/test/types/guards.test.ts index fe96b64dd3..5db5f3bdb0 100644 --- a/packages/core-internal/test/types/guards.test.ts +++ b/packages/core-internal/test/types/guards.test.ts @@ -81,6 +81,10 @@ describe('isCallToolResult', () => { expect(isCallToolResult({})).toBe(false); }); + it('returns false for an explicit content: undefined (the schema default parses it, but the narrowed type requires the array)', () => { + expect(isCallToolResult({ content: undefined, structuredContent: { ok: true } })).toBe(false); + }); + it('returns true for a result with content', () => { expect( isCallToolResult({ diff --git a/packages/core-internal/test/types/registryPins.test.ts b/packages/core-internal/test/types/registryPins.test.ts index 9af70927f2..6e8a9d5c18 100644 --- a/packages/core-internal/test/types/registryPins.test.ts +++ b/packages/core-internal/test/types/registryPins.test.ts @@ -22,7 +22,7 @@ import { describe, expect, it } from 'vitest'; // Post-relocation home (Q1 increment-2 step 1): the pinned contents are // unchanged — only the module housing the registries moved. -import { getNotificationSchema, getRequestSchema, getResultSchema } from '../../src/wire/rev2025-11-25/registry'; +import { getNotificationSchema, getRequestSchema, CallToolResultWireSchema, getResultSchema } from '../../src/wire/rev2025-11-25/registry'; // The 2025 wire schemas are fully self-contained in the era's schema module: // every per-method schema the registry serves is a FROZEN 2025-11-25 copy so // the public/neutral layer can evolve (e.g. SEP-2106 widening) without @@ -130,7 +130,9 @@ const EXPECTED_RESULT_SCHEMAS = { 'resources/read': ReadResourceResultSchema, 'resources/subscribe': EmptyResultSchema, 'resources/unsubscribe': EmptyResultSchema, - 'tools/call': CallToolResultSchema, + // The wire-seam wrapper (content-default guard) IS the pinned entry — + // identity to the wrapper, which pipes into the plain schema. + 'tools/call': CallToolResultWireSchema, 'tools/list': ListToolsResultSchema, 'sampling/createMessage': CreateMessageResultWithToolsSchema, 'elicitation/create': ElicitResultSchema, diff --git a/packages/core-internal/test/types/schemaBoundaryPins.test.ts b/packages/core-internal/test/types/schemaBoundaryPins.test.ts index 5dd6a11727..d28a7963e0 100644 --- a/packages/core-internal/test/types/schemaBoundaryPins.test.ts +++ b/packages/core-internal/test/types/schemaBoundaryPins.test.ts @@ -34,6 +34,8 @@ import { ListToolsResultSchema as Wire2026ListToolsResultSchema, RequestMetaEnvelopeSchema } from '../../src/wire/rev2026-07-28/schemas'; +import { getResultSchema as getRev2025ResultSchema } from '../../src/wire/rev2025-11-25/registry'; +import { CallToolResultSchema as Wire2025CallToolResultSchema } from '../../src/wire/rev2025-11-25/schemas'; import type { CallToolResult, CompleteResult, @@ -139,18 +141,17 @@ describe('typed result schemas are loose', () => { expect((parsed as Record).ttlMs).toBe(5); }); - test('CallToolResult requires content on the wire (the silent-empty-success default is gone)', () => { - // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): `content.default([])` - // was removed from the wire schema. The default was the T6 width-leak - // root: a task-shaped (or otherwise content-less) body parsed as a - // silent `{content: []}` success. Content is required by the spec in - // every revision; a content-less body now fails the parse LOUDLY. - // Changeset: codec-split-wire-break; docs/migration/upgrade-to-v2.md - // "Wire tightening (every era)". - expect(CallToolResultSchema.safeParse({ structuredContent: { ok: true } }).success).toBe(false); - const parsed = CallToolResultSchema.parse({ content: [], structuredContent: { ok: true } }); + test('CallToolResult tolerates absent content on the wire (defaults to [], v1 parity)', () => { + // BEHAVIOR MIGRATION (reversal, ledgered): `content.default([])` was + // removed in the codec split (T6 width-leak root: a task-shaped body + // parsed as a silent success), then RESTORED for ecosystem parity — + // real deployments omit `content` alongside `structuredContent`. The + // T6 leak stays closed at the 2025 wire-seam schema; 2026 stays strict. + const parsed = CallToolResultSchema.parse({ structuredContent: { ok: true } }); expect(parsed.content).toEqual([]); expect(parsed.structuredContent).toEqual({ ok: true }); + const explicit = CallToolResultSchema.parse({ content: [], structuredContent: { ok: true } }); + expect(explicit.content).toEqual([]); }); test('CallToolResult preserves isError and sibling members through the parse', () => { @@ -167,6 +168,15 @@ describe('typed result schemas are loose', () => { }); }); +describe('2025 wire layering: era file spec-strict, era seam tolerant', () => { + test('the era-schema file rejects a content-less CallToolResult body; the registry seam defaults it', () => { + // Layering rule: era-schema files stay spec-verbatim (the CallToolResult twin requires content); the seam's v1-parity tolerance defaults CallToolResult-family bodies only — foreign-family results (task/inputRequests/requestState) are never defaulted. + expect(Wire2025CallToolResultSchema.safeParse({ structuredContent: {} }).success).toBe(false); + const seamParsed = getRev2025ResultSchema('tools/call')!.parse({ structuredContent: {} }) as { content: unknown }; + expect(seamParsed.content).toEqual([]); + }); +}); + describe('completion result boundary', () => { test('the completion object is loose: unknown sibling fields are preserved', () => { const parsed = CompleteResultSchema.parse({ completion: { values: ['alpha'], extraField: 'kept' } }); diff --git a/packages/core-internal/test/types/specTypeSchema.test.ts b/packages/core-internal/test/types/specTypeSchema.test.ts index 3266b09521..d6c775b4e3 100644 --- a/packages/core-internal/test/types/specTypeSchema.test.ts +++ b/packages/core-internal/test/types/specTypeSchema.test.ts @@ -90,20 +90,20 @@ describe('isSpecType', () => { } }); - it('CallToolResult requires content at the boundary (the wire default was removed)', () => { - // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): CallToolResultSchema - // lost `content.default([])` — the silent-empty-success masking root. - // The guard's input shape now requires content, matching the spec in - // every revision. Changeset: codec-split-wire-break. + it('CallToolResult tolerates absent content at the boundary (default restored, v1 parity)', () => { + // BEHAVIOR MIGRATION (reversal, ledgered): the guard accepts a + // content-less body as v1's did; the task-husk leak is closed at the + // 2025 wire-seam schema instead. const empty: unknown = {}; - expect(isSpecType.CallToolResult(empty)).toBe(false); + expect(isSpecType.CallToolResult(empty)).toBe(true); const v: unknown = { content: [] }; expect(isSpecType.CallToolResult(v)).toBe(true); if (isSpecType.CallToolResult(v)) { - expectTypeOf(v.content).toEqualTypeOf(); - expectTypeOf(v.content).not.toEqualTypeOf(); + // The guard narrows to the INPUT type: content optional pre-parse. + expectTypeOf(v.content).toEqualTypeOf(); } - void (0 as unknown as CallToolResult); + // The parsed/public type keeps content required (z.output). + expectTypeOf().toEqualTypeOf(); }); it('JSONValue / JSONObject — narrows to the JSON type, not unknown', () => { diff --git a/packages/core-internal/test/wire/eraGates.test.ts b/packages/core-internal/test/wire/eraGates.test.ts index 8ecd642a1e..b03202e011 100644 --- a/packages/core-internal/test/wire/eraGates.test.ts +++ b/packages/core-internal/test/wire/eraGates.test.ts @@ -574,27 +574,19 @@ describe('T6 width-leak killed at both roots', () => { expect(decoded.kind).toBe('invalid'); }); - test('2025 era: with the content default gone, a bare task-shaped body fails the plain schema loudly', async () => { + test('2025 era: a bare task-shaped body fails the wire-seam schema — the content default cannot mask it', async () => { const { rev2025Codec } = await import('../../src/wire/rev2025-11-25/codec'); - const { CallToolResultSchema } = await import('../../src/wire/rev2025-11-25/schemas'); const decoded = rev2025Codec.decodeResult('tools/call', { task: { taskId: 't-1', status: 'working' } }); + // Decode passes bodies without resultType through (explicit-schema + // task interop must work); the wire-seam schema does the refusing. expect(decoded.kind).toBe('complete'); - if (decoded.kind === 'complete') { - // The plain schema (which IS the registry entry — the result map - // is aligned to the typed map, no task-widened union): no - // default([]) means no silent {content: []} masking. - expect(CallToolResultSchema.safeParse(decoded.result).success).toBe(false); - } - // The GENERIC path agrees: the registry serves the same plain schema, - // so even a fully conforming CreateTaskResult body is a loud schema - // failure (surfaced as a typed INVALID_RESULT — see - // test/shared/typedMapAlignment.test.ts). Task interop is the - // explicit-schema overload, never a silent union member. + // The wire-seam schema rejects even a fully conforming + // CreateTaskResult on the plain path (typed INVALID_RESULT); task + // interop is the explicit-schema overload only. const { getResultSchema } = await import('../../src/wire/rev2025-11-25/registry'); - const plain = getResultSchema('tools/call'); - expect(plain).toBe(CallToolResultSchema); + const wireSeam = getResultSchema('tools/call'); expect( - plain!.safeParse({ + wireSeam!.safeParse({ task: { taskId: '786af6b0-2779-48ed-9cc1-b8a8a25b8a86', status: 'working', @@ -605,5 +597,7 @@ describe('T6 width-leak killed at both roots', () => { } }).success ).toBe(false); + // While a plain content-less tool result still defaults: + expect(wireSeam!.safeParse({ structuredContent: { ok: true } }).success).toBe(true); }); }); diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 5d8653d168..92c48da050 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,17 @@ # @modelcontextprotocol/core +## 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. + ## 2.0.0-beta.2 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index 66e2802e2d..c6fb199708 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/core", - "version": "2.0.0-beta.2", + "version": "2.0.0-beta.3", "description": "Model Context Protocol for TypeScript — public Zod schemas (spec + OAuth/OpenID)", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/middleware/express/CHANGELOG.md b/packages/middleware/express/CHANGELOG.md index dd7b7f653a..098b2f9ca6 100644 --- a/packages/middleware/express/CHANGELOG.md +++ b/packages/middleware/express/CHANGELOG.md @@ -1,5 +1,34 @@ # @modelcontextprotocol/express +## 2.0.0-beta.3 + +### Patch Changes + +- [#2420](https://github.com/modelcontextprotocol/typescript-sdk/pull/2420) [`7635115`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7635115d0112c3f980b45a9773a4770660af8aae) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Add runtime-neutral Bearer authentication to `@modelcontextprotocol/server`: + `requireBearerAuth` gates web-standard `fetch(request)` hosts (Cloudflare + Workers, Deno, Bun, Hono), built on the exported `verifyBearerToken` and + `bearerAuthChallengeResponse` pieces, with `OAuthTokenVerifier` now defined + here. The Express middleware adapts the same core and is unchanged in + behavior, except that `WWW-Authenticate` challenge values are now RFC 7235 + quoted-string sanitized (quotes and backslashes escaped, control and + non-ASCII characters replaced); `@modelcontextprotocol/express` re-exports + `OAuthTokenVerifier` as before. + +- [#2422](https://github.com/modelcontextprotocol/typescript-sdk/pull/2422) [`61866d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/61866d7a5ff4475663ceb525c88447c497c1b92a) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Add runtime-neutral OAuth discovery serving to `@modelcontextprotocol/server`: + `oauthMetadataResponse` serves the RFC 9728 Protected Resource Metadata and + RFC 8414 Authorization Server metadata documents from web-standard + `fetch(request)` hosts, built on the exported + `buildOAuthProtectedResourceMetadata`, with + `getOAuthProtectedResourceMetadataUrl` now defined here. The Express metadata + router adapts the same core and is unchanged in behavior; the insecure-issuer + escape hatch is an explicit `dangerouslyAllowInsecureIssuerUrl` option in the + neutral core instead of a module-scope environment read. The web-standard + matcher validates lazily so unmatched traffic always falls through, tolerates + a trailing slash, supports HEAD, and marks reflected CORS preflights with + `Vary`. +- Updated dependencies [[`44797d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/44797d77792953d0ce70b68922bb6bb69e697c32), [`1b90c96`](https://github.com/modelcontextprotocol/typescript-sdk/commit/1b90c96d11fd17016d2977cae9dd661de3fb84df), [`561c6d8`](https://github.com/modelcontextprotocol/typescript-sdk/commit/561c6d83456ef98d6c713bbda9837e64337f22c9), [`ce2f65d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/ce2f65db0e019506f4d2526466ec8cc7106de98e), [`7e69735`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7e697354de95111ca2c70a12ac9f5d3ec96b56c3), [`e8de519`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e8de519d3129f46b7528d2999b7641f55be1f091), [`0ab5d14`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0ab5d1471d6c7375878316df2930fca77eee1d2a), [`24be404`](https://github.com/modelcontextprotocol/typescript-sdk/commit/24be4040d454a9c5983901229068477c7a9ea796), [`7635115`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7635115d0112c3f980b45a9773a4770660af8aae), [`61866d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/61866d7a5ff4475663ceb525c88447c497c1b92a)]: + - @modelcontextprotocol/server@2.0.0-beta.3 + ## 2.0.0-beta.2 ### Patch Changes diff --git a/packages/middleware/express/package.json b/packages/middleware/express/package.json index f1c736a2b0..0be07f6e07 100644 --- a/packages/middleware/express/package.json +++ b/packages/middleware/express/package.json @@ -1,7 +1,7 @@ { "name": "@modelcontextprotocol/express", "private": false, - "version": "2.0.0-beta.2", + "version": "2.0.0-beta.3", "description": "Express adapters for the Model Context Protocol TypeScript server SDK - Express middleware", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/middleware/fastify/CHANGELOG.md b/packages/middleware/fastify/CHANGELOG.md index 71f5747525..21875e7323 100644 --- a/packages/middleware/fastify/CHANGELOG.md +++ b/packages/middleware/fastify/CHANGELOG.md @@ -1,5 +1,12 @@ # @modelcontextprotocol/fastify +## 2.0.0-beta.3 + +### Patch Changes + +- Updated dependencies [[`44797d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/44797d77792953d0ce70b68922bb6bb69e697c32), [`1b90c96`](https://github.com/modelcontextprotocol/typescript-sdk/commit/1b90c96d11fd17016d2977cae9dd661de3fb84df), [`561c6d8`](https://github.com/modelcontextprotocol/typescript-sdk/commit/561c6d83456ef98d6c713bbda9837e64337f22c9), [`ce2f65d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/ce2f65db0e019506f4d2526466ec8cc7106de98e), [`7e69735`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7e697354de95111ca2c70a12ac9f5d3ec96b56c3), [`e8de519`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e8de519d3129f46b7528d2999b7641f55be1f091), [`0ab5d14`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0ab5d1471d6c7375878316df2930fca77eee1d2a), [`24be404`](https://github.com/modelcontextprotocol/typescript-sdk/commit/24be4040d454a9c5983901229068477c7a9ea796), [`7635115`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7635115d0112c3f980b45a9773a4770660af8aae), [`61866d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/61866d7a5ff4475663ceb525c88447c497c1b92a)]: + - @modelcontextprotocol/server@2.0.0-beta.3 + ## 2.0.0-beta.2 ### Patch Changes diff --git a/packages/middleware/fastify/package.json b/packages/middleware/fastify/package.json index 7f430b518c..fb5472363e 100644 --- a/packages/middleware/fastify/package.json +++ b/packages/middleware/fastify/package.json @@ -1,7 +1,7 @@ { "name": "@modelcontextprotocol/fastify", "private": false, - "version": "2.0.0-beta.2", + "version": "2.0.0-beta.3", "description": "Fastify adapters for the Model Context Protocol TypeScript server SDK - Fastify middleware", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/middleware/hono/CHANGELOG.md b/packages/middleware/hono/CHANGELOG.md index d7e9f48280..4098eeff21 100644 --- a/packages/middleware/hono/CHANGELOG.md +++ b/packages/middleware/hono/CHANGELOG.md @@ -1,5 +1,29 @@ # @modelcontextprotocol/hono +## 2.0.0-beta.3 + +### Patch Changes + +- [#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. + +- Updated dependencies [[`44797d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/44797d77792953d0ce70b68922bb6bb69e697c32), [`1b90c96`](https://github.com/modelcontextprotocol/typescript-sdk/commit/1b90c96d11fd17016d2977cae9dd661de3fb84df), [`561c6d8`](https://github.com/modelcontextprotocol/typescript-sdk/commit/561c6d83456ef98d6c713bbda9837e64337f22c9), [`ce2f65d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/ce2f65db0e019506f4d2526466ec8cc7106de98e), [`7e69735`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7e697354de95111ca2c70a12ac9f5d3ec96b56c3), [`e8de519`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e8de519d3129f46b7528d2999b7641f55be1f091), [`0ab5d14`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0ab5d1471d6c7375878316df2930fca77eee1d2a), [`24be404`](https://github.com/modelcontextprotocol/typescript-sdk/commit/24be4040d454a9c5983901229068477c7a9ea796), [`7635115`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7635115d0112c3f980b45a9773a4770660af8aae), [`61866d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/61866d7a5ff4475663ceb525c88447c497c1b92a)]: + - @modelcontextprotocol/server@2.0.0-beta.3 + ## 2.0.0-beta.2 ### Patch Changes diff --git a/packages/middleware/hono/package.json b/packages/middleware/hono/package.json index 3c280c0ecb..e3dddfc711 100644 --- a/packages/middleware/hono/package.json +++ b/packages/middleware/hono/package.json @@ -1,7 +1,7 @@ { "name": "@modelcontextprotocol/hono", "private": false, - "version": "2.0.0-beta.2", + "version": "2.0.0-beta.3", "description": "Hono adapters for the Model Context Protocol TypeScript server SDK - Hono middleware", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/middleware/node/CHANGELOG.md b/packages/middleware/node/CHANGELOG.md index ff35d59b7e..09ba175f30 100644 --- a/packages/middleware/node/CHANGELOG.md +++ b/packages/middleware/node/CHANGELOG.md @@ -1,5 +1,31 @@ # @modelcontextprotocol/node +## 2.0.0-beta.3 + +### Patch Changes + +- [#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. + +- [#2445](https://github.com/modelcontextprotocol/typescript-sdk/pull/2445) [`78fabea`](https://github.com/modelcontextprotocol/typescript-sdk/commit/78fabea44557bd49f5a050b92e57ccd22dab14ad) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Document composing the host and origin validation guards in front of `toNodeHandler` for hand-wired `node:http` servers, matching the protected wiring the examples and serving guide now demonstrate. + +- Updated dependencies [[`44797d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/44797d77792953d0ce70b68922bb6bb69e697c32), [`1b90c96`](https://github.com/modelcontextprotocol/typescript-sdk/commit/1b90c96d11fd17016d2977cae9dd661de3fb84df), [`561c6d8`](https://github.com/modelcontextprotocol/typescript-sdk/commit/561c6d83456ef98d6c713bbda9837e64337f22c9), [`ce2f65d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/ce2f65db0e019506f4d2526466ec8cc7106de98e), [`7e69735`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7e697354de95111ca2c70a12ac9f5d3ec96b56c3), [`e8de519`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e8de519d3129f46b7528d2999b7641f55be1f091), [`0ab5d14`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0ab5d1471d6c7375878316df2930fca77eee1d2a), [`24be404`](https://github.com/modelcontextprotocol/typescript-sdk/commit/24be4040d454a9c5983901229068477c7a9ea796), [`7635115`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7635115d0112c3f980b45a9773a4770660af8aae), [`61866d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/61866d7a5ff4475663ceb525c88447c497c1b92a)]: + - @modelcontextprotocol/server@2.0.0-beta.3 + ## 2.0.0-beta.2 ### Patch Changes diff --git a/packages/middleware/node/package.json b/packages/middleware/node/package.json index aad6227dda..e0c9a5fa7d 100644 --- a/packages/middleware/node/package.json +++ b/packages/middleware/node/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/node", - "version": "2.0.0-beta.2", + "version": "2.0.0-beta.3", "description": "Model Context Protocol implementation for TypeScript - Node.js middleware", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index 9467763c06..7cd7c31b97 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -1,5 +1,71 @@ # @modelcontextprotocol/server +## 2.0.0-beta.3 + +### Minor Changes + +- [#2369](https://github.com/modelcontextprotocol/typescript-sdk/pull/2369) [`24be404`](https://github.com/modelcontextprotocol/typescript-sdk/commit/24be4040d454a9c5983901229068477c7a9ea796) Thanks [@mattzcarey](https://github.com/mattzcarey)! - 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`. + +- [#2420](https://github.com/modelcontextprotocol/typescript-sdk/pull/2420) [`7635115`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7635115d0112c3f980b45a9773a4770660af8aae) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Add runtime-neutral Bearer authentication to `@modelcontextprotocol/server`: + `requireBearerAuth` gates web-standard `fetch(request)` hosts (Cloudflare + Workers, Deno, Bun, Hono), built on the exported `verifyBearerToken` and + `bearerAuthChallengeResponse` pieces, with `OAuthTokenVerifier` now defined + here. The Express middleware adapts the same core and is unchanged in + behavior, except that `WWW-Authenticate` challenge values are now RFC 7235 + quoted-string sanitized (quotes and backslashes escaped, control and + non-ASCII characters replaced); `@modelcontextprotocol/express` re-exports + `OAuthTokenVerifier` as before. + +- [#2422](https://github.com/modelcontextprotocol/typescript-sdk/pull/2422) [`61866d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/61866d7a5ff4475663ceb525c88447c497c1b92a) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Add runtime-neutral OAuth discovery serving to `@modelcontextprotocol/server`: + `oauthMetadataResponse` serves the RFC 9728 Protected Resource Metadata and + RFC 8414 Authorization Server metadata documents from web-standard + `fetch(request)` hosts, built on the exported + `buildOAuthProtectedResourceMetadata`, with + `getOAuthProtectedResourceMetadataUrl` now defined here. The Express metadata + router adapts the same core and is unchanged in behavior; the insecure-issuer + escape hatch is an explicit `dangerouslyAllowInsecureIssuerUrl` option in the + neutral core instead of a module-scope environment read. The web-standard + matcher validates lazily so unmatched traffic always falls through, tolerates + a trailing slash, supports HEAD, and marks reflected CORS preflights with + `Vary`. + +### 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. + +- [#2451](https://github.com/modelcontextprotocol/typescript-sdk/pull/2451) [`7e69735`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7e697354de95111ca2c70a12ac9f5d3ec96b56c3) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Return JSON-RPC Invalid Params with the original URI and an `invalid_uri` reason when `resources/read` receives a syntactically malformed URI. + +- [#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. + +- [#2453](https://github.com/modelcontextprotocol/typescript-sdk/pull/2453) [`0ab5d14`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0ab5d1471d6c7375878316df2930fca77eee1d2a) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Strip RFC 9110 optional whitespace around inbound `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` values before classifying and validating modern HTTP requests. This keeps valid requests portable across Fetch runtimes that expose raw leading or trailing SP/HTAB through `Headers.get()`. + ## 2.0.0-beta.2 ### Patch Changes diff --git a/packages/server/package.json b/packages/server/package.json index d77e533db8..d4a75c70fc 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/server", - "version": "2.0.0-beta.2", + "version": "2.0.0-beta.3", "description": "Model Context Protocol implementation for TypeScript - Server package", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 4151849494..d69e01c1b2 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -52,6 +52,7 @@ import { missingClientCapabilities, MissingRequiredClientCapabilityError, modernProtocolVersions, + normalizeContentlessToolResult, parseSchema, Protocol, ProtocolError, @@ -524,7 +525,12 @@ export class Server extends Protocol { return result; } - const validationResult = codec.validateResult('tools/call', result); + // v1-parity authoring affordance, era-independent: a content-less + // handler result normalizes to content: [] before era validation. + // Other result families' bodies stay un-normalized and fail loudly. + const normalizedResult = normalizeContentlessToolResult(result); + + const validationResult = codec.validateResult('tools/call', normalizedResult); if (!validationResult.ok) { throw new ProtocolError( validationResult.reason === 'not-in-era' ? ProtocolErrorCode.InternalError : ProtocolErrorCode.InvalidParams, diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index f6c40602ae..0a96a0bb66 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -154,13 +154,10 @@ describe('Server', () => { }); }); - describe('tools/call handler-result validation (required content)', () => { - // Server-side pin for the documented wire break (docs/migration/upgrade-to-v2.md, - // "Wire tightening (every era)"): with the - // content.default([]) affordance removed, a handler result without - // `content` is rejected with -32602 `Invalid tools/call result` — - // never silently defaulted onto the wire — while an authored-content - // result passes through the wrapped handler untouched. + describe('tools/call handler-result validation (content default)', () => { + // Pin for the v1-parity authoring affordance: content-less handler + // results normalize to content: [] before era validation, on every + // leg; other result families are not normalized. async function callToolOnServer(result: CallToolResult): Promise { const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { tools: {} } }); server.setRequestHandler('tools/call', () => result); @@ -193,13 +190,42 @@ describe('Server', () => { return response; } - it('rejects a structured-only handler result (no content) with -32602 Invalid tools/call result', async () => { + it('defaults a structured-only handler result (no content) to content: [] on the wire (v1 parity)', async () => { + // Runtime defaults structured-only results (v1 parity); the wire + // stays spec-valid with content: []. const response = await callToolOnServer({ structuredContent: { ok: true } } as unknown as CallToolResult); + const result = (response as { result?: { content?: unknown; structuredContent?: unknown } }).result; + expect(result).toBeDefined(); + expect(result!.content).toEqual([]); + expect(result!.structuredContent).toEqual({ ok: true }); + }); + + it('does not normalize an array handler result — rejected loudly', async () => { + const response = await callToolOnServer([{ type: 'text', text: 'hi' }] as unknown as CallToolResult); + const error = (response as { error?: { code: number } }).error; + expect(error).toBeDefined(); + expect(error!.code).toBe(-32602); + }); + + it('does not normalize a foreign-family body with an explicit content: undefined', async () => { + const response = await callToolOnServer({ + task: { taskId: 't-1', status: 'working' }, + content: undefined + } as unknown as CallToolResult); + const error = (response as { error?: { code: number } }).error; + expect(error).toBeDefined(); + expect(error!.code).toBe(-32602); + }); + + it('does not normalize a body carrying another result family — rejected loudly', async () => { + const response = await callToolOnServer({ + inputRequests: { r1: { method: 'elicitation/create' } } + } as unknown as CallToolResult); + const error = (response as { error?: { code: number; message: string } }).error; expect(error).toBeDefined(); expect(error!.code).toBe(-32602); - expect(error!.message).toContain('Invalid tools/call result'); }); it('passes an authored-content result through to the wire', async () => { diff --git a/test/e2e/helpers/wire-sniffer.ts b/test/e2e/helpers/wire-sniffer.ts index 4e2c26bea5..912e2e047e 100644 --- a/test/e2e/helpers/wire-sniffer.ts +++ b/test/e2e/helpers/wire-sniffer.ts @@ -4,7 +4,8 @@ import { ClientResultSchema, ServerNotificationSchema, ServerRequestSchema, - ServerResultSchema + ServerResultSchema, + TOOL_RESULT_FOREIGN_FAMILY_KEYS } from '@modelcontextprotocol/core-internal'; import type { Transport } from '@modelcontextprotocol/server'; import { @@ -16,6 +17,10 @@ import { isSpecType } from '@modelcontextprotocol/server'; +function isPlainRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + export type WireParty = 'client' | 'server'; export interface SnifferOptions { @@ -100,7 +105,28 @@ export function assertWireMessage(msg: unknown, party: WireParty, opts: SnifferO // (InputRequiredResultSchema lives alongside, never widening it). // Era-gated: only cells wired for the modern era opt in, so an // input_required on a 2025-era cell's wire is still flagged. - if (party === 'server' && opts.allowInputRequiredResults === true && isInputRequiredResult(result)) return; + if (party === 'server' && isInputRequiredResult(result)) { + if (opts.allowInputRequiredResults === true) return; + fail(party, `input_required result on a cell not opted into multi-round-trip`, msg); + } + // With content.default([]) restored, the neutral union accepts any + // object via the CallToolResult member — so union conformance below + // is a weak check, and the other result families are asserted here + // explicitly, by vocabulary. Cells that opt into vendor-extension + // methods skip this check (the sniffer is method-blind for results; + // before the default was restored these bodies failed the union parse + // and were excused by the same flag). + if (party === 'server' && !opts.allowCustomMethods && isPlainRecord(result) && result.content === undefined) { + for (const key of TOOL_RESULT_FOREIGN_FAMILY_KEYS) { + if (key in result) { + fail( + party, + `content-less result carrying '${key}' — another result family must not read as an empty tools/call success`, + msg + ); + } + } + } const r = schemas.result.safeParse(result); if (!r.success) { // A result for a vendor-extension request legitimately won't match the spec union. diff --git a/test/e2e/scenarios/raw-result-type.test.ts b/test/e2e/scenarios/raw-result-type.test.ts index eb42e73f30..38db4c9a25 100644 --- a/test/e2e/scenarios/raw-result-type.test.ts +++ b/test/e2e/scenarios/raw-result-type.test.ts @@ -14,10 +14,10 @@ * ABSENT `resultType` is a spec violation surfaced as a typed error * naming it. * - Negotiated legacy (2025 era): `resultType` is FOREIGN vocabulary — - * strip-on-lift (Q1-SD3 ii; a deliberate, ledgered change from the - * pre-split era-blind rejection — changeset: codec-split-wire-break). The - * stripped body then fails the (default-free) result schema loudly - * because it has no content. + * strip-on-lift (Q1-SD3 ii). The stripped husk still carries the + * input_required family keys, so the wire-seam schema rejects it loudly + * (a content-less body without family keys would default to content: [] + * — changeset: calltoolresult-content-default). * * Either way the V-1 invariant holds: never an empty-content success. */ @@ -115,8 +115,8 @@ verifies('typescript:client:raw-result-type-first', async ({ transport }: TestAr try { const outcome = await callToolOutcome(client); - // Strip-on-lift (Q1-SD3 ii, ledgered): the foreign resultType is - // dropped; the body has no content, so validation fails LOUDLY. + // Strip-on-lift: the foreign resultType is dropped; the husk's + // input_required family keys fail the wire-seam schema LOUDLY. // Never an empty-content success. expect('resolved' in outcome, `must not resolve: ${JSON.stringify(outcome)}`).toBe(false); const rejection = (outcome as { rejected: unknown }).rejected; diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index 5cfa15abf2..92140b531f 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -1828,8 +1828,6 @@ describe('outputSchema validation', () => { server.setRequestHandler('tools/call', async request => { if (request.params.name === 'test-tool') { return { - // content is spec-required (the wire default([]) was removed - // - ledgered; changeset codec-split-wire-break) content: [], structuredContent: { result: 'success', count: 42 } }; diff --git a/test/integration/test/standardSchema.test.ts b/test/integration/test/standardSchema.test.ts index 92f5a6c78c..b9ab0284f3 100644 --- a/test/integration/test/standardSchema.test.ts +++ b/test/integration/test/standardSchema.test.ts @@ -6,7 +6,7 @@ import { Client } from '@modelcontextprotocol/client'; import type { TextContent } from '@modelcontextprotocol/core-internal'; import { InMemoryTransport } from '@modelcontextprotocol/core-internal'; -import { completable, fromJsonSchema as serverFromJsonSchema, McpServer } from '@modelcontextprotocol/server'; +import { completable, fromJsonSchema as serverFromJsonSchema, inputRequired, McpServer } from '@modelcontextprotocol/server'; import { toStandardJsonSchema } from '@valibot/to-json-schema'; import { type } from 'arktype'; import * as v from 'valibot'; @@ -714,3 +714,40 @@ describe('Standard Schema Support', () => { }); }); }); + +describe('Standard Schema elicitation conversion', () => { + test('converts ArkType format schemas, dropping the library format-companion pattern', () => { + const schema = type({ email: 'string.email' }); + + const request = inputRequired.elicit({ message: 'Email?', requestedSchema: schema }); + + const emailSchema = (request.params as { requestedSchema: { properties: Record> } }).requestedSchema + .properties.email!; + expect(emailSchema.type).toBe('string'); + expect(emailSchema.format).toBe('email'); + expect(emailSchema.pattern).toBeUndefined(); + }); + + test('converts Valibot schemas via toStandardJsonSchema', () => { + const schema = toStandardJsonSchema( + v.object({ + email: v.pipe(v.string(), v.email()), + count: v.number() + }) + ); + + const request = inputRequired.elicit({ message: 'Details?', requestedSchema: schema }); + + const requestedSchema = (request.params as { requestedSchema: { properties: Record> } }) + .requestedSchema; + expect(requestedSchema.properties.email!.format).toBe('email'); + expect(requestedSchema.properties.email!.pattern).toBeUndefined(); + expect(requestedSchema.properties.count!.type).toBe('number'); + }); + + test('rejects ArkType schemas the restricted wire schema cannot express', () => { + const nested = type({ address: { city: 'string' } }); + + expect(() => inputRequired.elicit({ message: 'Address?', requestedSchema: nested })).toThrow(TypeError); + }); +});