From ac7579b78fafb279dcbbe5401dca910b80fb1b83 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 12 Jun 2026 15:14:40 +0000 Subject: [PATCH 01/19] feat(core): add protocol-era helpers and a typed era-negotiation failure code Protocol revisions now split into two eras: legacy (2025-11-25 family and earlier, initialize handshake) and modern (2026-07-28+, server/discover + per-request _meta envelope). A new pure module provides the era predicate and era-subset helpers so era-bound operations only ever consult their own era's slice of a supported-versions list, plus a separate modern-versions constant that never feeds the legacy initialize list. Adds SdkErrorCode.EraNegotiationFailed for connect-time negotiation failures that are neither a usable modern era nor a definitive legacy signal. The SdkErrorCode ABI pin is updated deliberately, together with the changeset that ships the negotiation surface. The helpers are internal-barrel only; the public surface change is the enum member. --- packages/core/src/errors/sdkErrors.ts | 9 +++ packages/core/src/index.ts | 1 + packages/core/src/shared/protocolEras.ts | 62 +++++++++++++++++++ .../core/test/shared/protocolEras.test.ts | 41 ++++++++++++ .../core/test/types/errorSurfacePins.test.ts | 1 + 5 files changed, 114 insertions(+) create mode 100644 packages/core/src/shared/protocolEras.ts create mode 100644 packages/core/test/shared/protocolEras.test.ts diff --git a/packages/core/src/errors/sdkErrors.ts b/packages/core/src/errors/sdkErrors.ts index 1f77d1faca..eec7596cc5 100644 --- a/packages/core/src/errors/sdkErrors.ts +++ b/packages/core/src/errors/sdkErrors.ts @@ -42,6 +42,15 @@ export enum SdkErrorCode { * `data.method` / `data.era`. */ MethodNotSupportedByProtocolVersion = 'METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION', + /** + * Protocol-era negotiation at connect time failed without producing either a + * usable modern (2026-07-28+) era or a definitive legacy fallback signal — + * e.g. the negotiation mode forbids falling back (`pin`), or the probe hit a + * network failure (a typed connect error, never an era verdict). + * + * Negotiation-phase only: this code is never used once an era is established. + */ + EraNegotiationFailed = 'ERA_NEGOTIATION_FAILED', // Transport errors ClientHttpNotImplemented = 'CLIENT_HTTP_NOT_IMPLEMENTED', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fc022586f5..f5c11a5e0c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,7 @@ export * from './shared/auth.js'; export * from './shared/authUtils.js'; export * from './shared/metadataUtils.js'; export * from './shared/protocol.js'; +export * from './shared/protocolEras.js'; export * from './shared/stdio.js'; export * from './shared/toolNameValidation.js'; export * from './shared/transport.js'; diff --git a/packages/core/src/shared/protocolEras.ts b/packages/core/src/shared/protocolEras.ts new file mode 100644 index 0000000000..403d816f72 --- /dev/null +++ b/packages/core/src/shared/protocolEras.ts @@ -0,0 +1,62 @@ +/** + * Protocol-era helpers (pure module). + * + * The MCP wire protocol splits into two eras: + * + * - **legacy** — the 2025-11-25 family of revisions and earlier. Connections are + * established with the `initialize` handshake; the protocol version is negotiated + * once per connection. + * - **modern** — protocol revision 2026-07-28 and later. There is no `initialize` + * handshake; servers advertise their supported versions via `server/discover` and + * every request carries a per-request `_meta` envelope. + * + * Era-aware supported-version list semantics: an operation that belongs to one era + * must only ever consult that era's subset of a supported-versions list. In + * particular, the `initialize` handshake (a legacy-era operation) must never accept + * or counter-offer a modern revision — see {@linkcode legacyProtocolVersions} — and + * the `server/discover` advertisement must only ever contain modern revisions — see + * {@linkcode modernProtocolVersions}. This keeps modern version strings out of + * 2025-era exchanges even when a single supported-versions list spans both eras. + */ + +/** + * The first protocol revision of the modern (2026-07-28) era. + * + * Revision identifiers are ISO dates, so lexicographic comparison orders them + * chronologically. + */ +export const FIRST_MODERN_PROTOCOL_VERSION = '2026-07-28'; + +/** + * Modern-era protocol revisions this SDK can negotiate via `server/discover`. + * + * Deliberately separate from {@linkcode SUPPORTED_PROTOCOL_VERSIONS} (the legacy + * `initialize` list): the two lists feed era-disjoint code paths, so adding a + * revision here can never leak a modern version string into a 2025-era handshake. + * + * Internal — not part of the public API surface. + */ +export const SUPPORTED_MODERN_PROTOCOL_VERSIONS = [FIRST_MODERN_PROTOCOL_VERSION]; + +/** + * Whether the given protocol revision belongs to the modern (2026-07-28+) era. + */ +export function isModernProtocolVersion(version: string): boolean { + return version >= FIRST_MODERN_PROTOCOL_VERSION; +} + +/** + * The legacy-era (pre-2026-07-28) subset of a supported-versions list, in the + * list's own preference order. + */ +export function legacyProtocolVersions(versions: readonly string[]): string[] { + return versions.filter(version => !isModernProtocolVersion(version)); +} + +/** + * The modern-era (2026-07-28+) subset of a supported-versions list, in the list's + * own preference order. + */ +export function modernProtocolVersions(versions: readonly string[]): string[] { + return versions.filter(version => isModernProtocolVersion(version)); +} diff --git a/packages/core/test/shared/protocolEras.test.ts b/packages/core/test/shared/protocolEras.test.ts new file mode 100644 index 0000000000..c01c97fdad --- /dev/null +++ b/packages/core/test/shared/protocolEras.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'vitest'; + +import { + FIRST_MODERN_PROTOCOL_VERSION, + isModernProtocolVersion, + legacyProtocolVersions, + modernProtocolVersions, + SUPPORTED_MODERN_PROTOCOL_VERSIONS +} from '../../src/shared/protocolEras.js'; +import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from '../../src/types/constants.js'; + +describe('protocol era helpers', () => { + test('every released (legacy-list) version is classified legacy', () => { + for (const version of SUPPORTED_PROTOCOL_VERSIONS) { + expect(isModernProtocolVersion(version)).toBe(false); + } + expect(legacyProtocolVersions(SUPPORTED_PROTOCOL_VERSIONS)).toEqual(SUPPORTED_PROTOCOL_VERSIONS); + expect(modernProtocolVersions(SUPPORTED_PROTOCOL_VERSIONS)).toEqual([]); + }); + + test('the 2026-07-28 revision and later are classified modern', () => { + expect(isModernProtocolVersion('2026-07-28')).toBe(true); + expect(isModernProtocolVersion('2027-01-01')).toBe(true); + expect(FIRST_MODERN_PROTOCOL_VERSION).toBe('2026-07-28'); + }); + + test('subsetting preserves the list preference order', () => { + const mixed = ['2026-07-28', LATEST_PROTOCOL_VERSION, '2025-06-18']; + expect(modernProtocolVersions(mixed)).toEqual(['2026-07-28']); + expect(legacyProtocolVersions(mixed)).toEqual([LATEST_PROTOCOL_VERSION, '2025-06-18']); + }); + + test('era-disjoint constants: the modern list never feeds the legacy initialize list', () => { + // Ordering guard (counter-offer leak, server.ts counter-offer site): the + // legacy SUPPORTED_PROTOCOL_VERSIONS constant must not contain modern + // revisions; modern negotiation reads SUPPORTED_MODERN_PROTOCOL_VERSIONS, + // which must contain only modern revisions. + expect(SUPPORTED_PROTOCOL_VERSIONS.some(isModernProtocolVersion)).toBe(false); + expect(SUPPORTED_MODERN_PROTOCOL_VERSIONS.every(isModernProtocolVersion)).toBe(true); + }); +}); diff --git a/packages/core/test/types/errorSurfacePins.test.ts b/packages/core/test/types/errorSurfacePins.test.ts index bb5fb64325..46003004e4 100644 --- a/packages/core/test/types/errorSurfacePins.test.ts +++ b/packages/core/test/types/errorSurfacePins.test.ts @@ -76,6 +76,7 @@ describe('SdkErrorCode', () => { InvalidResult: 'INVALID_RESULT', UnsupportedResultType: 'UNSUPPORTED_RESULT_TYPE', MethodNotSupportedByProtocolVersion: 'METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION', + EraNegotiationFailed: 'ERA_NEGOTIATION_FAILED', ClientHttpNotImplemented: 'CLIENT_HTTP_NOT_IMPLEMENTED', ClientHttpAuthentication: 'CLIENT_HTTP_AUTHENTICATION', ClientHttpForbidden: 'CLIENT_HTTP_FORBIDDEN', From 96025b5bd5d221f2cd87f32423f97bdfb90d679f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 12 Jun 2026 15:14:55 +0000 Subject: [PATCH 02/19] feat(client): add the probe-outcome classifier pure module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One pure module implements the merged negotiation fallback table, row by row: - DiscoverResult with overlap: modern, select from supportedVersions - DiscoverResult without overlap: initialize on the same connection when fallback is possible, else a typed UnsupportedProtocolVersionError with synthesized data - -32004 with a mutual modern version: select-and-continue (corrective verdict; the caller runs it exactly once and arms a loop guard on the second rejection) — never a fallback - -32004 with a disjoint-but-modern list: typed error, never initialize - -32004 with a legacy-only list: initialize; modern-only clients get the typed error carrying data.supported - -32601 (any status, including 200-bodied): legacy — never modern evidence - 400/-32000 with the deployed unsupported-version literal, and 400/-32000 free-text (server-not-initialized / session-required): legacy - plain-text or unparseable 400, code 0, empty body, 406, any unrecognized shape: legacy (conservative default) - -32001/-32003: deliberately not probe-recognized in either direction (deployed -32001 session-404 overload; draft ladder cells still pending upstream derivation) — they fall into the conservative default - network outage: typed connect error; probe timeout after retries: typed timeout error — neither is ever converted to an era verdict - browser-only exception: an opaque CORS/preflight TypeError during the probe phase classifies as legacy (the fallback handshake carries no custom headers); Node network failures stay typed errors Verdicts are scoped to the negotiation phase only. HTTP-rejected probes are classified by their JSON-RPC error body, so the 400-bodied and in-band forms of each row land identically. Tests cover every row, keyed to the table. --- packages/client/src/client/probeClassifier.ts | 281 +++++++++++++++++ .../test/client/probeClassifier.test.ts | 295 ++++++++++++++++++ 2 files changed, 576 insertions(+) create mode 100644 packages/client/src/client/probeClassifier.ts create mode 100644 packages/client/test/client/probeClassifier.test.ts diff --git a/packages/client/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts new file mode 100644 index 0000000000..fefc3c991a --- /dev/null +++ b/packages/client/src/client/probeClassifier.ts @@ -0,0 +1,281 @@ +/** + * Probe outcome classifier — the merged fallback table (pure module). + * + * Classifies the outcome of the version-negotiation probe (`server/discover` sent + * at connect time) into one of four verdicts: modern era (select a version), a + * spec-mandated corrective continuation (`-32004` with a mutual modern version), + * legacy fallback (perform the plain 2025 `initialize` handshake on the same + * connection), or a typed connect error. + * + * The classifier is deliberately **conservative**: anything it does not positively + * recognize as modern resolves to the legacy fallback — with two exceptions that + * are never era verdicts: network outage and timeout reject with typed connect + * errors (era fallback happens only on definitive legacy signals; well-behaved + * legacy servers always produce one fast). + * + * **Scope: negotiation phase only.** These verdicts apply exclusively to the + * connect-time probe exchange. Once a connection's era is established as modern, a + * later unrecognized failure surfaces to the caller and is never re-classified + * into a silent demotion to `initialize`; it marks the era record stale so the + * NEXT `connect()` re-runs negotiation. + * + * `-32001` and `-32003` are deliberately NOT probe-recognized in either direction: + * deployed servers still overload `-32001` for session-404 bodies and the draft + * error-code ladder for these cells is still being derived upstream (conformance + * #336), so both fall into the conservative "unrecognized → legacy" default. A + * conformant modern server never answers a well-formed discover with either code, + * so nothing is lost. + */ +import type { DiscoverResult } from '@modelcontextprotocol/core'; +import { + DiscoverResultSchema, + modernProtocolVersions, + SdkError, + SdkErrorCode, + UnsupportedProtocolVersionError +} from '@modelcontextprotocol/core'; + +/** + * The runtime environment the probe executed in. Only consulted for the + * network-failure row (F-7): in a browser, a CORS-preflight rejection against a + * deployed 2025 server surfaces as an opaque `TypeError` indistinguishable from an + * outage — but the legacy fallback carries no custom headers (no preflight), so it + * is treated as a definitive-enough legacy signal. In Node there is no CORS layer, + * so a network failure stays a typed connect error. + */ +export type ProbeEnvironment = 'node' | 'browser'; + +/** + * A normalized probe outcome, produced by the connect-time wiring from the raw + * transport exchange. Wire-real inputs only — the wiring maps transport-thrown + * HTTP errors, network errors, in-band JSON-RPC responses, and timeouts onto + * these shapes. + */ +export type ProbeOutcome = + /** The probe request was answered with a JSON-RPC result. */ + | { kind: 'result'; result: unknown } + /** The probe request was answered with a JSON-RPC error (any HTTP status, including 200-bodied errors and stdio in-band errors). */ + | { kind: 'rpc-error'; code: number; message: string; data?: unknown } + /** The HTTP layer rejected the probe POST (non-2xx); `body` is the raw response text, when available. */ + | { kind: 'http-error'; status: number; body?: string } + /** The probe send failed below HTTP (connection refused, DNS, reset, opaque fetch failure). */ + | { kind: 'network-error'; error: unknown } + /** No response arrived within the probe timeout, after all timeout re-sends. */ + | { kind: 'timeout'; timeoutMs: number; attempts: number }; + +export interface ProbeClassifierContext { + /** + * Modern-era protocol versions this client can negotiate, in preference order. + * Never empty. + */ + clientModernVersions: readonly string[]; + /** + * The version the probe carried in its `_meta` envelope (used to synthesize + * `data.requested` on typed errors when the server omitted it). + */ + requestedVersion: string; + /** + * Whether a legacy `initialize` fallback is possible. `false` for a + * modern-only client and for `pin` mode (no fallback, loud failure): rows + * whose action would be "initialize on the same connection" yield a typed + * `UnsupportedProtocolVersionError` (with synthesized data when needed) + * instead. + * + * Note this only affects the two *modern-evidence* rows (DiscoverResult with + * no overlap; `-32004` with a legacy-only list). The plain conservative rows + * (`-32601`, legacy 400 shapes, unrecognized) always return `legacy`; the + * caller maps that verdict per its negotiation mode. + */ + fallbackAvailable: boolean; + /** See {@linkcode ProbeEnvironment}. */ + environment: ProbeEnvironment; +} + +export type ProbeVerdict = + /** Definitive modern evidence: select `version` and continue without `initialize`. */ + | { kind: 'modern'; version: string; discover: DiscoverResult } + /** + * `-32004` with a mutual modern version: select-and-continue (re-send the + * probe at `version`). Spec-mandated corrective continuation — the caller + * runs it exactly once (even when `version` equals the just-rejected one) + * and arms a loop guard on the second rejection, throwing `error`. + */ + | { kind: 'corrective'; version: string; error: UnsupportedProtocolVersionError } + /** Definitive legacy signal or unrecognized shape: perform the plain legacy `initialize` handshake on the same connection. */ + | { kind: 'legacy' } + /** Typed connect error — never converted to an era verdict. */ + | { kind: 'error'; error: Error }; + +/** The `-32004` UnsupportedProtocolVersion protocol error code (negotiation-phase recognition). */ +const UNSUPPORTED_PROTOCOL_VERSION = -32_004; +/** Codes deliberately not probe-recognized (overloaded on deployed servers / ladder underived pending conformance #336). */ +const NOT_PROBE_RECOGNIZED = new Set([-32_001, -32_003]); + +/** + * Classify a single probe outcome. Pure: no I/O, no state — loop-guard and + * retry state live in the caller. + */ +export function classifyProbeOutcome(outcome: ProbeOutcome, context: ProbeClassifierContext): ProbeVerdict { + switch (outcome.kind) { + case 'result': { + return classifyResult(outcome.result, context); + } + case 'rpc-error': { + return classifyRpcError(outcome, context); + } + case 'http-error': { + return classifyHttpError(outcome, context); + } + case 'network-error': { + return classifyNetworkError(outcome.error, context); + } + case 'timeout': { + // Q12: timeout (standard request timeout, after all `maxRetries` + // re-sends) is a typed connect error, NEVER a legacy verdict. + return { + kind: 'error', + error: new SdkError( + SdkErrorCode.RequestTimeout, + `Version negotiation probe timed out after ${outcome.attempts} attempt(s)`, + { timeout: outcome.timeoutMs, attempts: outcome.attempts } + ) + }; + } + } +} + +function classifyResult(result: unknown, context: ProbeClassifierContext): ProbeVerdict { + const parsed = DiscoverResultSchema.safeParse(result); + if (!parsed.success) { + // 200-processed era-ambiguous first requests / any unrecognized result + // shape: not modern evidence — conservative legacy fallback. + return { kind: 'legacy' }; + } + const supportedVersions = parsed.data.supportedVersions; + const overlap = context.clientModernVersions.find(version => supportedVersions.includes(version)); + if (overlap !== undefined) { + return { kind: 'modern', version: overlap, discover: parsed.data }; + } + // DiscoverResult with NO overlap is still modern evidence — but on a dual-era + // server it drives era SELECTION: initialize on the SAME connection when + // fallback is possible; otherwise a typed error with synthesized data. + if (context.fallbackAvailable) { + return { kind: 'legacy' }; + } + return { + kind: 'error', + error: new UnsupportedProtocolVersionError({ supported: [...supportedVersions], requested: context.requestedVersion }) + }; +} + +function classifyRpcError(outcome: { code: number; message: string; data?: unknown }, context: ProbeClassifierContext): ProbeVerdict { + const { code, message, data } = outcome; + + if (code === UNSUPPORTED_PROTOCOL_VERSION) { + const supported = parseSupportedList(data); + if (supported === undefined) { + // -32004 without a valid `data.supported` list is not actionable + // modern evidence — conservative legacy fallback. + return { kind: 'legacy' }; + } + const requested = parseRequested(data) ?? context.requestedVersion; + const error = new UnsupportedProtocolVersionError({ supported, requested }, message); + const supportedModern = modernProtocolVersions(supported); + const mutual = context.clientModernVersions.find(version => supportedModern.includes(version)); + if (mutual !== undefined) { + // Mutual modern version: select-and-continue. MUST NOT fall back — + // a server that speaks -32004 with a version list is modern by + // definition (spec: "Do not fall back"). + return { kind: 'corrective', version: mutual, error }; + } + if (supportedModern.length > 0) { + // Disjoint-but-modern list: typed error, never initialize. + return { kind: 'error', error }; + } + // Legacy-only list: definitive legacy signal → initialize; a modern-only + // client gets the typed error carrying `data.supported` instead. + return context.fallbackAvailable ? { kind: 'legacy' } : { kind: 'error', error }; + } + + if (NOT_PROBE_RECOGNIZED.has(code)) { + // -32001 / -32003: deliberately not probe-recognized in either direction + // (see module doc) — falls into the conservative default. + return { kind: 'legacy' }; + } + + // Everything else is a definitive legacy signal or the conservative default: + // -32601 (method not found — never modern evidence on the probe, including + // 200-bodied errors), -32000 with the deployed "Unsupported protocol + // version" literal, -32000 free-text ("Server not initialized", + // session-required), `code: 0`, and any unrecognized code. + return { kind: 'legacy' }; +} + +function classifyHttpError(outcome: { status: number; body?: string }, context: ProbeClassifierContext): ProbeVerdict { + // HTTP-rejected probes (400/-32000, 400/-32004, …) carry their JSON-RPC error + // in the response body — classify the body exactly like an in-band error. + const rpcError = parseJsonRpcErrorBody(outcome.body); + if (rpcError !== undefined) { + return classifyRpcError(rpcError, context); + } + // Plain-text/unparseable 400, empty body, 406, or any other unrecognized + // status: conservative legacy fallback. + return { kind: 'legacy' }; +} + +function classifyNetworkError(error: unknown, context: ProbeClassifierContext): ProbeVerdict { + if (context.environment === 'browser' && isOpaqueFetchTypeError(error)) { + // F-7 (ruled Q12 exception, PROBE PHASE ONLY): a browser CORS-preflight + // rejection against a deployed 2025 server is an opaque `TypeError`; the + // legacy fallback carries no custom headers, so it proceeds where the + // probe could not. Node outage below stays a typed connect error. + return { kind: 'legacy' }; + } + return { + kind: 'error', + error: new SdkError(SdkErrorCode.EraNegotiationFailed, `Version negotiation probe failed: ${describeError(error)}`, { + cause: error + }) + }; +} + +function isOpaqueFetchTypeError(error: unknown): boolean { + // Cross-realm safe: bundled or sandboxed fetch implementations may not share + // this realm's TypeError identity. + return error instanceof TypeError || (error instanceof Error && error.name === 'TypeError'); +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function parseSupportedList(data: unknown): string[] | undefined { + if (typeof data !== 'object' || data === null) return undefined; + const supported = (data as { supported?: unknown }).supported; + if (!Array.isArray(supported) || supported.length === 0 || !supported.every(v => typeof v === 'string')) { + return undefined; + } + return supported as string[]; +} + +function parseRequested(data: unknown): string | undefined { + if (typeof data !== 'object' || data === null) return undefined; + const requested = (data as { requested?: unknown }).requested; + return typeof requested === 'string' ? requested : undefined; +} + +function parseJsonRpcErrorBody(body: string | undefined): { code: number; message: string; data?: unknown } | undefined { + if (body === undefined || body === '') return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return undefined; + } + if (typeof parsed !== 'object' || parsed === null) return undefined; + const error = (parsed as { error?: unknown }).error; + if (typeof error !== 'object' || error === null) return undefined; + const { code, message, data } = error as { code?: unknown; message?: unknown; data?: unknown }; + if (typeof code !== 'number') return undefined; + return { code, message: typeof message === 'string' ? message : '', data }; +} diff --git a/packages/client/test/client/probeClassifier.test.ts b/packages/client/test/client/probeClassifier.test.ts new file mode 100644 index 0000000000..e14ce37bbd --- /dev/null +++ b/packages/client/test/client/probeClassifier.test.ts @@ -0,0 +1,295 @@ +/** + * Row-by-row tests for the merged probe-outcome classifier table. + * + * Each `describe` block names the row of the adjudicated table it covers. The + * HTTP-shaped fixtures mirror the exact bodies deployed servers emit + * (`createJsonErrorResponse`: `{"jsonrpc":"2.0","error":{...},"id":null}`); the + * end-to-end capture of the same shapes from real server transports lives in + * test/integration/test/client/versionNegotiation.test.ts. + */ +import { SdkError, SdkErrorCode, UnsupportedProtocolVersionError } from '@modelcontextprotocol/core'; +import { describe, expect, test } from 'vitest'; + +import type { ProbeClassifierContext, ProbeOutcome, ProbeVerdict } from '../../src/client/probeClassifier.js'; +import { classifyProbeOutcome } from '../../src/client/probeClassifier.js'; + +const MODERN = '2026-07-28'; +const LEGACY = '2025-11-25'; + +const baseContext: ProbeClassifierContext = { + clientModernVersions: [MODERN], + requestedVersion: MODERN, + fallbackAvailable: true, + environment: 'node' +}; + +function classify(outcome: ProbeOutcome, context: Partial = {}): ProbeVerdict { + return classifyProbeOutcome(outcome, { ...baseContext, ...context }); +} + +const discoverResult = (supportedVersions: string[]) => ({ + supportedVersions, + capabilities: { tools: {} }, + serverInfo: { name: 'fixture-server', version: '1.0.0' } +}); + +/** The deployed-fleet 400 body for a JSON-RPC error (server streamableHttp `createJsonErrorResponse`). */ +const httpErrorBody = (code: number, message: string, data?: unknown) => + JSON.stringify({ jsonrpc: '2.0', error: data === undefined ? { code, message } : { code, message, data }, id: null }); + +describe('row: DiscoverResult with version overlap → modern, select from supportedVersions', () => { + test('selects the mutual modern version', () => { + const verdict = classify({ kind: 'result', result: discoverResult([MODERN, '2027-01-01']) }); + expect(verdict).toMatchObject({ kind: 'modern', version: MODERN }); + }); + + test('selection follows the client preference order', () => { + const verdict = classify( + { kind: 'result', result: discoverResult(['2027-01-01', MODERN]) }, + { clientModernVersions: ['2027-01-01', MODERN] } + ); + expect(verdict).toMatchObject({ kind: 'modern', version: '2027-01-01' }); + }); + + test('carries the parsed DiscoverResult for connection state', () => { + const verdict = classify({ kind: 'result', result: discoverResult([MODERN]) }); + expect(verdict.kind).toBe('modern'); + if (verdict.kind === 'modern') { + expect(verdict.discover.capabilities).toEqual({ tools: {} }); + expect(verdict.discover.serverInfo.name).toBe('fixture-server'); + } + }); +}); + +describe('row: DiscoverResult with NO overlap → initialize on the same connection, else typed error with synthesized data', () => { + test('fallback possible → legacy (era selection on a dual-era server)', () => { + const verdict = classify({ kind: 'result', result: discoverResult(['2027-12-31']) }); + expect(verdict).toEqual({ kind: 'legacy' }); + }); + + test('fallback impossible → typed UnsupportedProtocolVersionError with synthesized data', () => { + const verdict = classify({ kind: 'result', result: discoverResult(['2027-12-31']) }, { fallbackAvailable: false }); + expect(verdict.kind).toBe('error'); + if (verdict.kind === 'error') { + expect(verdict.error).toBeInstanceOf(UnsupportedProtocolVersionError); + const error = verdict.error as UnsupportedProtocolVersionError; + expect(error.supported).toEqual(['2027-12-31']); + expect(error.requested).toBe(MODERN); + } + }); +}); + +describe('row: -32004 + valid data.supported with a mutual modern version → select-and-continue, MUST NOT fall back', () => { + test('in-band -32004 yields a corrective verdict (never legacy)', () => { + const verdict = classify({ + kind: 'rpc-error', + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: [MODERN], requested: '2027-01-01' } + }); + expect(verdict).toMatchObject({ kind: 'corrective', version: MODERN }); + }); + + test('HTTP 400-bodied -32004 yields the same corrective verdict', () => { + const verdict = classify({ + kind: 'http-error', + status: 400, + body: httpErrorBody(-32_004, 'Unsupported protocol version', { supported: [MODERN], requested: MODERN }) + }); + expect(verdict).toMatchObject({ kind: 'corrective', version: MODERN }); + }); + + test('corrective even when the mutual version equals the just-rejected one (T2/A6 — caller runs it exactly once)', () => { + const verdict = classify({ + kind: 'rpc-error', + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: [MODERN], requested: MODERN } + }); + expect(verdict).toMatchObject({ kind: 'corrective', version: MODERN }); + if (verdict.kind === 'corrective') { + expect(verdict.error).toBeInstanceOf(UnsupportedProtocolVersionError); + } + }); +}); + +describe('row: -32004 with a disjoint-but-modern list → typed error, never initialize', () => { + test('no mutual modern version but the list is modern', () => { + const verdict = classify({ + kind: 'rpc-error', + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: ['2027-12-31'], requested: MODERN } + }); + expect(verdict.kind).toBe('error'); + if (verdict.kind === 'error') { + expect(verdict.error).toBeInstanceOf(UnsupportedProtocolVersionError); + expect((verdict.error as UnsupportedProtocolVersionError).supported).toEqual(['2027-12-31']); + } + }); +}); + +describe('row: -32004 with a legacy-only list → initialize; modern-only client → typed error carrying data.supported', () => { + test('legacy-only list with fallback available → legacy', () => { + const verdict = classify({ + kind: 'rpc-error', + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: [LEGACY, '2025-06-18'] } + }); + expect(verdict).toEqual({ kind: 'legacy' }); + }); + + test('legacy-only list, modern-only client → typed error carrying data.supported', () => { + const verdict = classify( + { kind: 'rpc-error', code: -32_004, message: 'Unsupported protocol version', data: { supported: [LEGACY] } }, + { fallbackAvailable: false } + ); + expect(verdict.kind).toBe('error'); + if (verdict.kind === 'error') { + expect((verdict.error as UnsupportedProtocolVersionError).supported).toEqual([LEGACY]); + expect((verdict.error as UnsupportedProtocolVersionError).requested).toBe(MODERN); + } + }); + + test('-32004 with malformed data (no valid supported list) → conservative legacy', () => { + expect(classify({ kind: 'rpc-error', code: -32_004, message: 'nope', data: { supported: 'not-a-list' } })).toEqual({ + kind: 'legacy' + }); + expect(classify({ kind: 'rpc-error', code: -32_004, message: 'nope' })).toEqual({ kind: 'legacy' }); + }); +}); + +describe('row: -32601 → legacy (never modern evidence on the probe, including 200-bodied errors)', () => { + test('in-band -32601 (stdio / 200-bodied HTTP)', () => { + expect(classify({ kind: 'rpc-error', code: -32_601, message: 'Method not found' })).toEqual({ kind: 'legacy' }); + }); + + test('HTTP 404-bodied -32601', () => { + expect(classify({ kind: 'http-error', status: 404, body: httpErrorBody(-32_601, 'Method not found') })).toEqual({ + kind: 'legacy' + }); + }); +}); + +describe('row: 400 + -32000 "Unsupported protocol version" literal (deployed TS-SDK fleet, stateless) → legacy', () => { + test('the byte-real literal body', () => { + // Fixture mirrors server/streamableHttp.ts validateProtocolVersion — the + // Q10-L1 frozen literal, consumed here as a fixture only. + const body = httpErrorBody( + -32_000, + `Bad Request: Unsupported protocol version: ${MODERN} (supported versions: 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07)` + ); + expect(classify({ kind: 'http-error', status: 400, body })).toEqual({ kind: 'legacy' }); + }); +}); + +describe('row: 400 + -32000 free-text (stateful session-required shapes) → legacy', () => { + test('"Server not initialized" (stateful first contact; session is checked before version)', () => { + expect(classify({ kind: 'http-error', status: 400, body: httpErrorBody(-32_000, 'Bad Request: Server not initialized') })).toEqual({ + kind: 'legacy' + }); + }); + + test('"Mcp-Session-Id header is required"', () => { + expect( + classify({ + kind: 'http-error', + status: 400, + body: httpErrorBody(-32_000, 'Bad Request: Mcp-Session-Id header is required') + }) + ).toEqual({ kind: 'legacy' }); + }); + + test('in-band -32000 free-text', () => { + expect(classify({ kind: 'rpc-error', code: -32_000, message: 'Server not initialized' })).toEqual({ kind: 'legacy' }); + }); +}); + +describe('row: plain-text/unparseable 400, code 0, empty body, 406, any unrecognized shape → legacy (conservative D4)', () => { + test('plain-text 400', () => { + expect(classify({ kind: 'http-error', status: 400, body: 'Bad Request' })).toEqual({ kind: 'legacy' }); + }); + + test('JSON-RPC error with code 0', () => { + expect(classify({ kind: 'rpc-error', code: 0, message: 'weird' })).toEqual({ kind: 'legacy' }); + expect(classify({ kind: 'http-error', status: 400, body: httpErrorBody(0, 'weird') })).toEqual({ kind: 'legacy' }); + }); + + test('empty body', () => { + expect(classify({ kind: 'http-error', status: 400, body: '' })).toEqual({ kind: 'legacy' }); + expect(classify({ kind: 'http-error', status: 400 })).toEqual({ kind: 'legacy' }); + }); + + test('406 Not Acceptable', () => { + expect(classify({ kind: 'http-error', status: 406, body: 'Not Acceptable: Client must accept text/event-stream' })).toEqual({ + kind: 'legacy' + }); + }); + + test('unrecognized 200 result shape (era-ambiguous first-request processing)', () => { + expect(classify({ kind: 'result', result: { ok: true } })).toEqual({ kind: 'legacy' }); + }); +}); + +describe('row: -32001 / -32003 are NEVER probe-recognized → fall into unrecognized → legacy', () => { + test('-32001 (session-404 overload on deployed servers; ladder cell underived pending conformance #336)', () => { + expect(classify({ kind: 'rpc-error', code: -32_001, message: 'Session not found' })).toEqual({ kind: 'legacy' }); + expect(classify({ kind: 'http-error', status: 404, body: httpErrorBody(-32_001, 'Session not found') })).toEqual({ + kind: 'legacy' + }); + }); + + test('-32003 with data is NOT modern evidence', () => { + expect(classify({ kind: 'rpc-error', code: -32_003, message: 'Capability required', data: { capability: 'sampling' } })).toEqual({ + kind: 'legacy' + }); + }); +}); + +describe('row: network outage → typed connect error (Node)', () => { + test('connection refused is never an era verdict', () => { + const cause = Object.assign(new Error('fetch failed'), { code: 'ECONNREFUSED' }); + const verdict = classify({ kind: 'network-error', error: cause }); + expect(verdict.kind).toBe('error'); + if (verdict.kind === 'error') { + expect(verdict.error).toBeInstanceOf(SdkError); + expect((verdict.error as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + } + }); + + test('a Node TypeError (no CORS layer) is still a typed connect error', () => { + const verdict = classify({ kind: 'network-error', error: new TypeError('fetch failed') }, { environment: 'node' }); + expect(verdict.kind).toBe('error'); + }); +}); + +describe('row: timeout after maxRetries → typed connect error — NEVER a legacy verdict (Q12)', () => { + test('timeout maps to the standard RequestTimeout SdkError', () => { + const verdict = classify({ kind: 'timeout', timeoutMs: 60_000, attempts: 1 }); + expect(verdict.kind).toBe('error'); + if (verdict.kind === 'error') { + expect(verdict.error).toBeInstanceOf(SdkError); + expect((verdict.error as SdkError).code).toBe(SdkErrorCode.RequestTimeout); + } + }); +}); + +describe('row: browser opaque CORS/preflight TypeError, PROBE PHASE ONLY → legacy fallback (F-7)', () => { + test('browser environment + bare TypeError → legacy', () => { + expect(classify({ kind: 'network-error', error: new TypeError('Failed to fetch') }, { environment: 'browser' })).toEqual({ + kind: 'legacy' + }); + }); + + test('cross-realm TypeError (name-based recognition) → legacy in a browser', () => { + const foreign = new Error('Failed to fetch'); + foreign.name = 'TypeError'; + expect(classify({ kind: 'network-error', error: foreign }, { environment: 'browser' })).toEqual({ kind: 'legacy' }); + }); + + test('browser non-TypeError network failure stays a typed connect error', () => { + const verdict = classify({ kind: 'network-error', error: new Error('socket hang up') }, { environment: 'browser' }); + expect(verdict.kind).toBe('error'); + }); +}); From c42e3cf60e936b7946d7ee925a7651f0bd46caa6 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 12 Jun 2026 15:14:55 +0000 Subject: [PATCH 03/19] feat(client): opt-in connect-time version negotiation on ClientOptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ClientOptions.versionNegotiation with three arms: absent/'legacy' keeps the plain 2025 connect sequence byte-identical (the handshake body is an extracted private method, not duplicated); 'auto' probes the server with server/discover before the Protocol machinery attaches and conservatively falls back to the legacy initialize handshake on the same connection; { pin } negotiates exactly the pinned modern revision with no fallback. Probe mechanics: - the probe runs in a wiring-layer window on the raw transport with string probe ids, so it consumes no Protocol message ids and a fallback initialize keeps id 0 — byte-equivalence with a plain legacy connect by construction - the window hands the started transport to Protocol.connect via a one-shot start pass-through; inbound non-probe messages during the window (e.g. a pre-init server-to-client request on a shared stdio pipe) are dropped with zero bytes written back - probe POST headers (MCP-Protocol-Version, Mcp-Method) derive from the probe message body in the streamable HTTP transport; the connection-level version slot is never consulted or mutated during negotiation, and envelope-less 2025-era traffic gets no new headers - after a modern resolution the client calls transport.setProtocolVersion exactly once, the same way the legacy path does after initialize - probe policy: probe.timeoutMs defaults to the standard request timeout; probe.maxRetries (default 0) governs timeout re-sends only — the -32004 corrective continuation is spec-mandated and not counted against it; a timeout after retries rejects with the standard typed timeout error and is never converted to a legacy verdict Era state rides the negotiated protocol version the instance already keeps as connection state: a modern selection stores it the same way the legacy handshake completion does, the legacy fallback lets the normal initialize path store it, and a fresh connect clears it so every new connection re-negotiates. An established modern era is never silently demoted. Exposed via Client.getProtocolEra(), a thin read of that state. No transport-level negotiation options exist (no Transport.negotiation, no HTTP/stdio option fields) — the option home is ClientOptions only, asserted by type-level tests. Ships with a changeset and a migration-guide entry. --- .changeset/add-version-negotiation-option.md | 10 + docs/migration.md | 44 ++ packages/client/src/client/client.ts | 145 +++++ packages/client/src/client/streamableHttp.ts | 26 + .../client/src/client/versionNegotiation.ts | 416 +++++++++++++ packages/client/src/index.ts | 1 + .../client/bodyDerivedProbeHeaders.test.ts | 128 ++++ .../test/client/versionNegotiation.test.ts | 570 ++++++++++++++++++ 8 files changed, 1340 insertions(+) create mode 100644 .changeset/add-version-negotiation-option.md create mode 100644 packages/client/src/client/versionNegotiation.ts create mode 100644 packages/client/test/client/bodyDerivedProbeHeaders.test.ts create mode 100644 packages/client/test/client/versionNegotiation.test.ts diff --git a/.changeset/add-version-negotiation-option.md b/.changeset/add-version-negotiation-option.md new file mode 100644 index 0000000000..102b7a3ba2 --- /dev/null +++ b/.changeset/add-version-negotiation-option.md @@ -0,0 +1,10 @@ +--- +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/core': minor +--- + +Add opt-in protocol version negotiation on `ClientOptions.versionNegotiation`. The default is unchanged: without the option (or with `mode: 'legacy'`) the client performs today's 2025 connect sequence byte-identically. `mode: 'auto'` probes the server with `server/discover` at +connect time and conservatively falls back to the plain legacy `initialize` handshake on the same connection unless the outcome is definitive modern evidence; network outage and probe timeout reject with typed connect errors and are never converted to an era verdict. +`mode: { pin: '' }` negotiates exactly the pinned modern revision with no fallback. Probe policy lives under `probe: { timeoutMs?, maxRetries? }` — the probe inherits the standard request timeout, and `maxRetries` governs timeout re-sends only (the spec-mandated +`-32004` corrective continuation is not counted against it). The probe's `MCP-Protocol-Version`/`Mcp-Method` headers derive from the probe message body; the transport version slot is never touched during negotiation, so legacy-era traffic carries zero 2026 headers by +construction. Adds `Client.getProtocolEra()` and the `SdkErrorCode.EraNegotiationFailed` code for negotiation-phase connect failures. diff --git a/docs/migration.md b/docs/migration.md index 764203ec2b..0a879c949b 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -984,6 +984,50 @@ protocol.setRequestHandler( ## Enhancements +### Opt-in protocol version negotiation (2026-07-28 draft) + +The client can now negotiate the protocol era at connect time. This is **opt-in**: if you do nothing, `connect()` performs exactly the same 2025 `initialize` handshake as before, byte for byte. + +```typescript +import { Client } from '@modelcontextprotocol/client'; + +// Auto-negotiate: try the 2026-07-28 draft revision, fall back to the 2025 +// handshake automatically when the server is a 2025-era deployment. +const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' } } +); +await client.connect(transport); + +client.getProtocolEra(); // 'modern' | 'legacy' +client.getNegotiatedProtocolVersion(); // e.g. '2026-07-28' or '2025-11-25' +``` + +How the modes behave: + +- **absent / `mode: 'legacy'`** (default): today's behavior, unchanged. No probe, no new headers. +- **`mode: 'auto'`**: `connect()` first sends a single `server/discover` probe. A modern server answers it and no `initialize` is sent; a 2025-era server rejects it (deployed servers answer fast, e.g. `-32601` or a `400`), and the client falls back to the plain legacy + handshake **on the same connection** — byte-equivalent to a 2025 client, including the `initialize` body version and with zero 2026 headers. The probe costs one round trip against an old server and nothing else. +- **`mode: { pin: '2026-07-28' }`**: modern era at exactly that revision. No fallback — if the server does not offer the pinned version, `connect()` rejects with a typed error. Use `pin` where a silent downgrade would be worse than an error (tests, CI, servers you control). + +Failure semantics under `'auto'` are deliberately conservative but never silent about infrastructure problems: anything the probe does not positively recognize as modern falls back to the legacy era, while a network outage or probe timeout rejects with a typed connect error +(`SdkError` with `EraNegotiationFailed` or `RequestTimeout`) — a dead server is never misreported as a legacy server. One browser-specific exception: an opaque CORS/preflight `TypeError` during the probe falls back to the legacy era, because deployed 2025 servers commonly +have CORS allow-lists that predate the 2026 headers and the legacy handshake sends none of them. + +Probe policy is configured under `versionNegotiation.probe`: + +```typescript +versionNegotiation: { + mode: 'auto', + probe: { + timeoutMs: 10_000, // default: the standard request timeout + maxRetries: 1 // default: 0 + } +} +``` + +Note that `maxRetries` governs timeout re-sends only; the `-32004` corrective continuation (selecting a mutually supported version after a version rejection and continuing) is mandated by the specification and is not counted against it. + ### Automatic JSON Schema validator selection by runtime The SDK now automatically selects the appropriate JSON Schema validator based on your runtime environment: diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 29710cbea4..b27d50862f 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -46,6 +46,8 @@ import { codecForVersion, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, + DEFAULT_REQUEST_TIMEOUT_MSEC, + isModernProtocolVersion, LATEST_PROTOCOL_VERSION, ListChangedOptionsBaseSchema, mergeCapabilities, @@ -59,6 +61,9 @@ import { setNegotiatedProtocolVersion } from '@modelcontextprotocol/core'; +import type { ResolvedVersionNegotiation, VersionNegotiationOptions } from './versionNegotiation.js'; +import { detectProbeEnvironment, negotiateEra, resolveVersionNegotiation } from './versionNegotiation.js'; + /** * Elicitation default application helper. Applies defaults to the `data` based on the `schema`. * @@ -149,6 +154,27 @@ export type ClientOptions = ProtocolOptions & { */ jsonSchemaValidator?: jsonSchemaValidator; + /** + * Opt-in protocol version negotiation (protocol revision 2026-07-28 and later). + * + * - absent or `mode: 'legacy'` — the plain 2025 connect sequence, byte-identical + * to today's behavior (no probe, no new headers). + * - `mode: 'auto'` — `connect()` probes the server with `server/discover` first: + * definitive modern evidence selects the modern era; definitive legacy signals + * (and anything unrecognized) fall back to the plain legacy `initialize` + * handshake on the same connection, byte-equivalent to a 2025 client. Network + * outage and probe timeout reject with typed connect errors — they are never + * converted to an era verdict. + * - `mode: { pin: '2026-07-28' }` — modern era at exactly the pinned revision; + * no probe-and-fallback: anything else fails loudly. + * + * Probe policy lives under `probe: { timeoutMs?, maxRetries? }`; the probe + * inherits the client's standard request timeout unless overridden. Note + * `maxRetries` governs timeout re-sends only — the `-32004` corrective + * continuation is spec-mandated and not counted against it. + */ + versionNegotiation?: VersionNegotiationOptions; + /** * Configure handlers for list changed notifications (tools, prompts, resources). * @@ -222,6 +248,8 @@ export class Client extends Protocol { private _listChangedDebounceTimers: Map> = new Map(); private _pendingListChangedConfig?: ListChangedHandlers; private _enforceStrictCapabilities: boolean; + private _versionNegotiation?: VersionNegotiationOptions; + private _supportedProtocolVersionsOption?: string[]; /** * Initializes this client with the given name and version information. @@ -234,6 +262,8 @@ export class Client extends Protocol { this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; + this._versionNegotiation = options?.versionNegotiation; + this._supportedProtocolVersionsOption = options?.supportedProtocolVersions; // Store list changed config for setup after connection (when we know server capabilities) if (options?.listChanged) { @@ -439,6 +469,11 @@ export class Client extends Protocol { * ``` */ override async connect(transport: Transport, options?: RequestOptions): Promise { + const negotiation = resolveVersionNegotiation(this._versionNegotiation, this._supportedProtocolVersionsOption); + if (negotiation.kind !== 'legacy') { + return this._connectNegotiated(transport, negotiation, options); + } + // Plain legacy connect — the pinned 2025 sequence, byte-untouched. await super.connect(transport); // When transport sessionId is already set this means we are trying to reconnect. // Restore the protocol version negotiated during the original initialize handshake @@ -462,6 +497,19 @@ export class Client extends Protocol { // never re-run a fresh handshake: `initialize` is physically absent // from the modern registry. (The resume branch above keeps it instead.) setNegotiatedProtocolVersion(this, undefined); + await this._legacyHandshake(transport, options); + } + + /** + * The 2025 `initialize` handshake — the body of the plain legacy connect. + * Also the `'auto'`-mode fallback path, on the same connection: fallback is + * structurally this client's own plain legacy connect under identical + * options (same `initialize` body including the protocol version, zero + * 2026 headers). Callers clear the negotiated protocol version before the + * handshake (the fresh-connect clear), so the exchange rides the bootstrap + * pins; its completion sets the negotiated (legacy) version. + */ + private async _legacyHandshake(transport: Transport, options?: RequestOptions): Promise { try { const result = await this.request( { @@ -517,6 +565,87 @@ export class Client extends Protocol { } } + /** + * Negotiated connect (`versionNegotiation` mode `'auto'` or `{ pin }`): run + * the `server/discover` probe in the wiring layer before the Protocol + * machinery attaches, then either establish the modern era or perform the + * plain legacy handshake on the same connection. + */ + private async _connectNegotiated( + transport: Transport, + negotiation: Extract, + options?: RequestOptions + ): Promise { + // Session-resuming reconnect: restore the previously negotiated version, + // never re-probe mid-session. + if (transport.sessionId !== undefined) { + await super.connect(transport); + const negotiatedProtocolVersion = negotiatedProtocolVersionOf(this); + if (negotiatedProtocolVersion !== undefined && transport.setProtocolVersion) { + transport.setProtocolVersion(negotiatedProtocolVersion); + } + return; + } + + // Fresh connect: same property as the plain legacy path — the + // negotiated protocol version is connection state, and a value left + // over from a previous connection must not survive into a new + // negotiation. Every fresh negotiated connect re-runs the probe. + setNegotiatedProtocolVersion(this, undefined); + + let result: Awaited>; + try { + result = await negotiateEra(negotiation, { + transport, + clientInfo: this._clientInfo, + capabilities: this._capabilities, + environment: detectProbeEnvironment(), + defaultTimeoutMs: options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC + }); + } catch (error) { + // Typed connect error — close the channel like a failed initialize does. + await transport.close().catch(() => {}); + throw error; + } + + await super.connect(transport); + + if (result.era === 'legacy') { + // Conservative fallback: the plain legacy handshake on the SAME + // connection — structurally the same code path as a plain legacy + // connect (the transport version slot was never touched during the + // probe, so there is nothing to clear: zero 2026 headers by + // construction). The handshake's completion sets the negotiated + // (legacy) protocol version exactly like a plain connect. + await this._legacyHandshake(transport, options); + return; + } + + this._serverCapabilities = result.discover.capabilities; + this._serverVersion = result.discover.serverInfo; + this._instructions = result.discover.instructions; + // Modern selection: the negotiated protocol version is the instance's + // connection state (the same channel the legacy handshake completion + // uses), and with it the wire era for everything this connection + // sends/receives from here on. + setNegotiatedProtocolVersion(this, result.version); + // After the era resolves modern, source per-request headers exactly the + // way the legacy path does after initialize — the single + // setProtocolVersion call site on this path. + if (transport.setProtocolVersion) { + transport.setProtocolVersion(result.version); + } + // The modern era has no notifications/initialized. List-changed handlers + // are still configured from the advertised capabilities (the discover + // advertisement excludes listChanged-class capabilities until the + // subscriptions/listen milestone lands, so this is a structural no-op + // today). + if (this._pendingListChangedConfig) { + this._setupListChangedHandlers(this._pendingListChangedConfig); + this._pendingListChangedConfig = undefined; + } + } + /** * After initialization has completed, this will be populated with the server's reported capabilities. */ @@ -540,6 +669,22 @@ export class Client extends Protocol { return negotiatedProtocolVersionOf(this); } + /** + * The protocol era of the current connection: `'modern'` for a 2026-07-28+ + * connection negotiated via `server/discover`, `'legacy'` for a 2025 + * `initialize` handshake (including the plain connect without + * `versionNegotiation`). `undefined` before negotiation completes. A thin + * read of the negotiated protocol version (the same connection state + * {@linkcode getNegotiatedProtocolVersion} exposes) — never persisted. + */ + getProtocolEra(): 'legacy' | 'modern' | undefined { + const version = negotiatedProtocolVersionOf(this); + if (version === undefined) { + return undefined; + } + return isModernProtocolVersion(version) ? 'modern' : 'legacy'; + } + /** * After initialization has completed, this may be populated with information about the server's instructions. */ diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 3b8ddafe5a..a36f835c18 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -9,6 +9,7 @@ import { isJSONRPCResultResponse, JSONRPCMessageSchema, normalizeHeaders, + PROTOCOL_VERSION_META_KEY, SdkError, SdkErrorCode, SdkHttpError @@ -231,6 +232,30 @@ export class StreamableHTTPClientTransport implements Transport { }); } + /** + * Body-derived per-request headers (protocol revision 2026-07-28): when a + * single outgoing request carries the protocol-version claim in its `_meta` + * envelope, the `MCP-Protocol-Version` and `Mcp-Method` headers derive from + * the message itself — the version negotiation probe is the first such + * sender. The connection-level version slot (`setProtocolVersion`, stamped + * after era resolution) is neither consulted nor mutated here; a body-derived + * claim takes precedence over the slot for its own request only. Messages + * without an envelope claim (all 2025-era traffic) are untouched, so no 2026 + * header can ever appear on a legacy exchange. + */ + private _applyBodyDerivedHeaders(headers: Headers, message: JSONRPCMessage | JSONRPCMessage[]): void { + if (Array.isArray(message) || !isJSONRPCRequest(message)) { + return; + } + const meta = (message.params as { _meta?: Record } | undefined)?._meta; + const envelopeVersion = meta?.[PROTOCOL_VERSION_META_KEY]; + if (typeof envelopeVersion !== 'string') { + return; + } + headers.set('mcp-protocol-version', envelopeVersion); + headers.set('mcp-method', message.method); + } + private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false): Promise { const { resumptionToken } = options; @@ -541,6 +566,7 @@ export class StreamableHTTPClientTransport implements Transport { } const headers = await this._commonHeaders(); + this._applyBodyDerivedHeaders(headers, message); headers.set('content-type', 'application/json'); const userAccept = headers.get('accept'); const types = [...(userAccept?.split(',').map(s => s.trim().toLowerCase()) ?? []), 'application/json', 'text/event-stream']; diff --git a/packages/client/src/client/versionNegotiation.ts b/packages/client/src/client/versionNegotiation.ts new file mode 100644 index 0000000000..439fd43084 --- /dev/null +++ b/packages/client/src/client/versionNegotiation.ts @@ -0,0 +1,416 @@ +/** + * Connect-time protocol version negotiation (opt-in via + * `ClientOptions.versionNegotiation`). + * + * This module owns the negotiation wiring that runs in the connect layer BEFORE + * the Protocol machinery engages: the option surface, the probe window (raw + * transport exchange with a string probe id), and the negotiation engine that + * drives the pure {@linkcode classifyProbeOutcome} classifier. + * + * Design invariants: + * + * - The probe never runs through the Protocol request machinery: it uses a string + * probe id (never colliding with Protocol's numeric ids) and consumes no message + * ids, so a legacy fallback's `initialize` is byte-equivalent to a plain legacy + * connect (same `id: 0`, same body, zero 2026 headers). + * - The probe is never the first real request — it is a dedicated + * `server/discover` exchange at connect time. + * - The transport's protocol-version slot is NEVER mutated during negotiation; + * probe headers derive from the probe message body itself (see the streamable + * HTTP transport's body-derived header stamping). `setProtocolVersion` is called + * exactly once, after the era resolves modern, the same way the legacy path + * calls it after `initialize`. + * - Probe-window guard: while the window is open, inbound messages that are not + * the probe response (e.g. a 2025-legal pre-initialization server→client request + * arriving mid-probe on a shared stdio pipe) are dropped with ZERO bytes written + * back. Such requests have no delivery guarantee mid-handshake; after the window + * closes, pre-init server→client traffic reaches Protocol dispatch exactly as it + * does today. + */ +import type { ClientCapabilities, DiscoverResult, Implementation, JSONRPCRequest, Transport } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + isJSONRPCErrorResponse, + isJSONRPCResultResponse, + isModernProtocolVersion, + legacyProtocolVersions, + modernProtocolVersions, + PROTOCOL_VERSION_META_KEY, + SdkError, + SdkErrorCode, + SdkHttpError, + SUPPORTED_MODERN_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/core'; + +import type { ProbeEnvironment, ProbeOutcome, ProbeVerdict } from './probeClassifier.js'; +import { classifyProbeOutcome } from './probeClassifier.js'; + +/** + * Probe policy for `'auto'` and pinned negotiation modes. + * + * There is no special probe timeout opinion: the probe inherits the client's + * STANDARD request timeout unless `timeoutMs` overrides it. + */ +export interface VersionNegotiationProbeOptions { + /** + * Timeout for a single probe exchange, in milliseconds. + * + * @default the standard request timeout ({@linkcode DEFAULT_REQUEST_TIMEOUT_MSEC}, or the `timeout` passed to `connect()`) + */ + timeoutMs?: number; + + /** + * How many times a TIMED-OUT probe is re-sent before `connect()` rejects + * with a typed timeout error. + * + * `maxRetries` governs timeout re-sends only; the `-32004` corrective + * continuation (select-and-continue on a mutual version) is spec-mandated + * and is not counted against it. + * + * A timeout after all retries is a typed connect error — it is NEVER + * converted to a legacy-era verdict. + * + * @default 0 + */ + maxRetries?: number; +} + +/** + * Negotiation mode: + * + * - `'legacy'` — no negotiation: the plain 2025 connect sequence, byte-identical + * to a client without this option. + * - `'auto'` — probe with `server/discover` at connect; conservative fallback to + * the plain legacy `initialize` handshake on the same connection unless the + * outcome is definitive modern evidence. Network outage and timeout reject + * with typed connect errors (never an era verdict). + * - `{ pin: '' }` — modern era at exactly the pinned revision: the + * connect-time `server/discover` must offer it. No fallback — anything else + * fails loudly with a typed error. + */ +export type VersionNegotiationMode = 'legacy' | 'auto' | { pin: string }; + +/** + * Opt-in protocol version negotiation, configured on + * `ClientOptions.versionNegotiation`. + */ +export interface VersionNegotiationOptions { + /** + * @default 'legacy' + */ + mode?: VersionNegotiationMode; + + /** + * Probe timeout/retry policy (only consulted by the probing modes). + */ + probe?: VersionNegotiationProbeOptions; +} + +/** + * The default negotiation mode when `versionNegotiation` (or its `mode`) is + * absent. + * + * The classifier and probe machine are default-agnostic: changing the v2 + * default (deferred sub-decision, ruled at the auto-negotiation milestone) is + * a flip of this single line. + */ +const DEFAULT_VERSION_NEGOTIATION_MODE: VersionNegotiationMode = 'legacy'; + +/** A fully resolved negotiation plan for one `connect()` call. */ +export type ResolvedVersionNegotiation = + | { kind: 'legacy' } + | { + kind: 'auto'; + /** Modern versions this client offers, in preference order (never empty). */ + modernVersions: string[]; + /** Whether this client can fall back to the legacy `initialize` handshake. */ + fallbackAvailable: boolean; + probe: VersionNegotiationProbeOptions; + } + | { kind: 'pin'; version: string; probe: VersionNegotiationProbeOptions }; + +/** + * Resolve the negotiation options into a per-connect plan. + * + * @param options - the `ClientOptions.versionNegotiation` value + * @param supportedProtocolVersionsOption - the raw `supportedProtocolVersions` + * option as passed by the consumer (NOT the defaulted list): when it carries + * modern versions they become the offer list, and a list without any legacy + * version makes this a modern-only client (no fallback). + */ +export function resolveVersionNegotiation( + options: VersionNegotiationOptions | undefined, + supportedProtocolVersionsOption: readonly string[] | undefined +): ResolvedVersionNegotiation { + const mode = options?.mode ?? DEFAULT_VERSION_NEGOTIATION_MODE; + if (mode === 'legacy') { + return { kind: 'legacy' }; + } + const probe = options?.probe ?? {}; + if (typeof mode === 'object') { + if (!isModernProtocolVersion(mode.pin)) { + throw new TypeError( + `versionNegotiation: { pin: '${mode.pin}' } is not a modern protocol revision — ` + + `pinning is for 2026-07-28 and later; omit versionNegotiation (or use mode: 'legacy') for 2025-era servers.` + ); + } + return { kind: 'pin', version: mode.pin, probe }; + } + const explicitModern = supportedProtocolVersionsOption ? modernProtocolVersions(supportedProtocolVersionsOption) : []; + const modernVersions = explicitModern.length > 0 ? explicitModern : [...SUPPORTED_MODERN_PROTOCOL_VERSIONS]; + const fallbackAvailable = supportedProtocolVersionsOption ? legacyProtocolVersions(supportedProtocolVersionsOption).length > 0 : true; + return { kind: 'auto', modernVersions, fallbackAvailable, probe }; +} + +/** + * Detect the probe environment for the F-7 browser row. Browser means a real + * window/document context (where fetch failures may be opaque CORS rejections); + * everything else — Node, workers — keeps typed-connect-error semantics for + * network failures. + */ +export function detectProbeEnvironment(): ProbeEnvironment { + const g = globalThis as { window?: unknown; document?: unknown }; + return g.window !== undefined && g.document !== undefined ? 'browser' : 'node'; +} + +/** Raw reply from one probe exchange, before normalization. */ +type RawProbeReply = + | { kind: 'response'; result?: unknown; error?: { code: number; message: string; data?: unknown } } + | { kind: 'send-error'; error: unknown } + | { kind: 'closed' } + | { kind: 'timeout' }; + +/** + * The probe window: temporary ownership of a raw transport for the negotiation + * exchange, before the Protocol machinery attaches. + * + * `open()` installs the window's handlers and starts the transport; `release()` + * detaches them and arms a one-shot `start()` pass-through so the subsequent + * Protocol connect (which always starts its transport) takes over the + * already-started channel without a double-start error. + */ +export class ProbeWindow { + /** Inbound messages dropped (with zero bytes written back) while the window was open. */ + droppedInboundMessages = 0; + + private _pending: { id: string; resolve: (reply: RawProbeReply) => void } | undefined; + private _probeCounter = 0; + + private constructor(private readonly _transport: Transport) {} + + static async open(transport: Transport): Promise { + const window = new ProbeWindow(transport); + transport.onmessage = message => { + const pending = window._pending; + if ( + pending !== undefined && + (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) && + message.id === pending.id + ) { + window._pending = undefined; + if (isJSONRPCResultResponse(message)) { + pending.resolve({ kind: 'response', result: message.result }); + } else { + pending.resolve({ kind: 'response', error: message.error }); + } + return; + } + // Probe-window guard: drop everything else with zero bytes (see module doc). + window.droppedInboundMessages++; + }; + transport.onerror = () => { + // Out-of-band transport errors are not necessarily fatal; the probe + // resolves via a send failure, the close signal, or the timeout. + }; + transport.onclose = () => { + const pending = window._pending; + if (pending !== undefined) { + window._pending = undefined; + pending.resolve({ kind: 'closed' }); + } + }; + await transport.start(); + return window; + } + + /** + * Send one probe request and await its reply. Each call uses a fresh string + * probe id (T9: string ids never collide with Protocol's numeric ids, e.g. + * on shared stdio pipes). + */ + async exchange(buildRequest: (id: string) => JSONRPCRequest, timeoutMs: number): Promise { + const id = `server-discover-probe-${++this._probeCounter}`; + return new Promise(resolve => { + let settled = false; + const settle = (reply: RawProbeReply) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (this._pending?.id === id) { + this._pending = undefined; + } + resolve(reply); + }; + const timer = setTimeout(() => settle({ kind: 'timeout' }), timeoutMs); + this._pending = { id, resolve: settle }; + this._transport.send(buildRequest(id)).catch((error: unknown) => settle({ kind: 'send-error', error })); + }); + } + + /** + * Close the window: detach the handlers and hand the started transport over + * to the next `Protocol.connect()` (whose unconditional `start()` call is + * absorbed exactly once). + */ + release(): void { + this._pending = undefined; + this._transport.onmessage = undefined; + this._transport.onerror = undefined; + this._transport.onclose = undefined; + const transport = this._transport; + const originalStart = transport.start.bind(transport); + let armed = true; + transport.start = async (): Promise => { + if (armed) { + armed = false; + transport.start = originalStart; + return; + } + return originalStart(); + }; + } +} + +/** Build the probe request: `server/discover` carrying the full per-request `_meta` envelope. */ +export function buildProbeRequest( + id: string, + protocolVersion: string, + clientInfo: Implementation, + capabilities: ClientCapabilities +): JSONRPCRequest { + return { + jsonrpc: '2.0', + id, + method: 'server/discover', + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: protocolVersion, + [CLIENT_INFO_META_KEY]: clientInfo, + [CLIENT_CAPABILITIES_META_KEY]: capabilities + } + } + }; +} + +function normalizeReply(reply: RawProbeReply, timeoutMs: number, attempts: number): ProbeOutcome { + switch (reply.kind) { + case 'response': { + return reply.error === undefined ? { kind: 'result', result: reply.result } : { kind: 'rpc-error', ...reply.error }; + } + case 'send-error': { + const error = reply.error; + if (error instanceof SdkHttpError) { + const text = (error.data as { text?: unknown } | undefined)?.text; + return { kind: 'http-error', status: error.data.status, body: typeof text === 'string' ? text : undefined }; + } + if (error instanceof Error && error.name === 'UnauthorizedError') { + // Auth-gated server: not era evidence — the conservative legacy + // fallback re-runs the auth flow through the plain connect path. + return { kind: 'http-error', status: 401 }; + } + return { kind: 'network-error', error }; + } + case 'closed': { + return { kind: 'network-error', error: new Error('Connection closed during the version negotiation probe') }; + } + case 'timeout': { + return { kind: 'timeout', timeoutMs, attempts }; + } + } +} + +export interface NegotiationDeps { + transport: Transport; + clientInfo: Implementation; + capabilities: ClientCapabilities; + environment: ProbeEnvironment; + /** The standard request timeout for this connect (probe inherits it unless `probe.timeoutMs` overrides). */ + defaultTimeoutMs: number; +} + +export type NegotiationResult = { era: 'modern'; version: string; discover: DiscoverResult } | { era: 'legacy' }; + +/** + * Run the negotiation probe state machine on a raw (not yet Protocol-connected) + * transport. Resolves with the negotiated era; throws typed connect errors. + * + * On return (or throw) the probe window has been released: the transport is + * started, handler-free, and ready for `Protocol.connect()` handover. + */ +export async function negotiateEra( + negotiation: Extract, + deps: NegotiationDeps +): Promise { + const timeoutMs = negotiation.probe.timeoutMs ?? deps.defaultTimeoutMs; + const maxRetries = negotiation.probe.maxRetries ?? 0; + const clientModernVersions = negotiation.kind === 'pin' ? [negotiation.version] : negotiation.modernVersions; + const fallbackAvailable = negotiation.kind === 'auto' && negotiation.fallbackAvailable; + + const window = await ProbeWindow.open(deps.transport); + try { + let requestedVersion = clientModernVersions[0]!; + // T2/A6: the -32004 corrective continuation runs exactly once — even when + // the mutual version equals the just-rejected one — and the loop guard + // arms on the second rejection. + let correctiveUsed = false; + for (;;) { + // Q12: `maxRetries` governs timeout re-sends only. + let attempts = 0; + let reply: RawProbeReply; + do { + attempts++; + reply = await window.exchange(id => buildProbeRequest(id, requestedVersion, deps.clientInfo, deps.capabilities), timeoutMs); + } while (reply.kind === 'timeout' && attempts <= maxRetries); + + const outcome = normalizeReply(reply, timeoutMs, attempts); + const verdict: ProbeVerdict = classifyProbeOutcome(outcome, { + clientModernVersions, + requestedVersion, + fallbackAvailable, + environment: deps.environment + }); + + switch (verdict.kind) { + case 'modern': { + return { era: 'modern', version: verdict.version, discover: verdict.discover }; + } + case 'corrective': { + if (correctiveUsed) { + // Second rejection: loop guard. + throw verdict.error; + } + correctiveUsed = true; + requestedVersion = verdict.version; + continue; + } + case 'legacy': { + if (negotiation.kind === 'pin') { + // Pin mode: no fallback, loud failure. + throw new SdkError( + SdkErrorCode.EraNegotiationFailed, + `Version negotiation failed: the server did not offer pinned protocol version ${negotiation.version} ` + + `via server/discover (no fallback in pin mode)` + ); + } + return { era: 'legacy' }; + } + case 'error': { + throw verdict.error; + } + } + } + } finally { + window.release(); + } +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 8a08e8fd79..42fc132c2a 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -61,6 +61,7 @@ export type { LoggingOptions, Middleware, RequestLogger } from './client/middlew export { applyMiddlewares, createMiddleware, withLogging, withOAuth } from './client/middleware.js'; export type { SSEClientTransportOptions } from './client/sse.js'; export { SSEClientTransport, SseError } from './client/sse.js'; +export type { VersionNegotiationMode, VersionNegotiationOptions, VersionNegotiationProbeOptions } from './client/versionNegotiation.js'; // StdioClientTransport, getDefaultEnvironment, DEFAULT_INHERITED_ENV_VARS, StdioServerParameters are exported from // the './stdio' subpath to keep the root entry free of process-spawning runtime dependencies (child_process, cross-spawn). export type { diff --git a/packages/client/test/client/bodyDerivedProbeHeaders.test.ts b/packages/client/test/client/bodyDerivedProbeHeaders.test.ts new file mode 100644 index 0000000000..de886f61e0 --- /dev/null +++ b/packages/client/test/client/bodyDerivedProbeHeaders.test.ts @@ -0,0 +1,128 @@ +/** + * Body-derived per-request headers on the streamable HTTP client transport: + * when a single outgoing request carries the 2026-07-28 protocol-version claim + * in its `_meta` envelope (the negotiation probe is the first such sender), the + * `MCP-Protocol-Version` and `Mcp-Method` headers derive from the message + * itself. The connection-level version slot is never consulted or mutated for + * those sends, and envelope-less (2025-era) traffic gets no new headers. + */ +import type { JSONRPCMessage } from '@modelcontextprotocol/core'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp.js'; + +describe('body-derived probe headers', () => { + let transport: StreamableHTTPClientTransport; + let fetchSpy: ReturnType; + + const okJson = (body: unknown) => ({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: () => Promise.resolve(body) + }); + + beforeEach(async () => { + fetchSpy = vi.fn(); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + await transport.start(); + }); + + afterEach(async () => { + await transport.close().catch(() => {}); + vi.restoreAllMocks(); + }); + + const probeRequest: JSONRPCMessage = { + jsonrpc: '2.0', + id: 'server-discover-probe-1', + method: 'server/discover', + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: '2026-07-28', + [CLIENT_INFO_META_KEY]: { name: 'c', version: '0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + } + } + }; + + const sentHeaders = (): Headers => { + const init = fetchSpy.mock.calls.at(-1)?.[1] as RequestInit; + return init.headers as Headers; + }; + + it('derives MCP-Protocol-Version and Mcp-Method from the probe message body', async () => { + fetchSpy.mockResolvedValueOnce( + okJson({ jsonrpc: '2.0', id: 'server-discover-probe-1', result: { supportedVersions: ['2026-07-28'] } }) + ); + + await transport.send(probeRequest); + + const headers = sentHeaders(); + expect(headers.get('mcp-protocol-version')).toBe('2026-07-28'); + expect(headers.get('mcp-method')).toBe('server/discover'); + }); + + it('never mutates the transport version slot for body-derived sends', async () => { + fetchSpy.mockResolvedValueOnce( + okJson({ jsonrpc: '2.0', id: 'server-discover-probe-1', result: { supportedVersions: ['2026-07-28'] } }) + ); + + await transport.send(probeRequest); + expect(transport.protocolVersion).toBeUndefined(); + + // A follow-up envelope-less message gets no version header at all — the + // slot is still unset; nothing leaked from the probe. + fetchSpy.mockResolvedValueOnce(okJson({ jsonrpc: '2.0', id: 0, result: {} })); + await transport.send({ jsonrpc: '2.0', id: 0, method: 'ping', params: {} }); + + const headers = sentHeaders(); + expect(headers.get('mcp-protocol-version')).toBeNull(); + expect(headers.get('mcp-method')).toBeNull(); + }); + + it('envelope-less (2025-era) requests are untouched: no 2026 headers, slot-driven behavior unchanged', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ jsonrpc: '2.0', id: 1, result: {} })); + await transport.send({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} }); + + let headers = sentHeaders(); + expect(headers.get('mcp-protocol-version')).toBeNull(); + expect(headers.get('mcp-method')).toBeNull(); + expect(headers.get('mcp-name')).toBeNull(); + + // setProtocolVersion (the legacy post-initialize call site, byte-untouched) + // still drives the header for subsequent slot-based sends. + transport.setProtocolVersion('2025-11-25'); + fetchSpy.mockResolvedValueOnce(okJson({ jsonrpc: '2.0', id: 2, result: {} })); + await transport.send({ jsonrpc: '2.0', id: 2, method: 'ping', params: {} }); + + headers = sentHeaders(); + expect(headers.get('mcp-protocol-version')).toBe('2025-11-25'); + expect(headers.get('mcp-method')).toBeNull(); + }); + + it('a body-derived claim takes precedence over the slot for its own request only', async () => { + transport.setProtocolVersion('2025-11-25'); + + fetchSpy.mockResolvedValueOnce( + okJson({ jsonrpc: '2.0', id: 'server-discover-probe-1', result: { supportedVersions: ['2026-07-28'] } }) + ); + await transport.send(probeRequest); + expect(sentHeaders().get('mcp-protocol-version')).toBe('2026-07-28'); + + fetchSpy.mockResolvedValueOnce(okJson({ jsonrpc: '2.0', id: 3, result: {} })); + await transport.send({ jsonrpc: '2.0', id: 3, method: 'ping', params: {} }); + expect(sentHeaders().get('mcp-protocol-version')).toBe('2025-11-25'); + }); + + it('batch (array) sends are never body-derived', async () => { + fetchSpy.mockResolvedValueOnce(okJson([{ jsonrpc: '2.0', id: 4, result: {} }])); + await transport.send([probeRequest as never]); + + const headers = sentHeaders(); + expect(headers.get('mcp-protocol-version')).toBeNull(); + expect(headers.get('mcp-method')).toBeNull(); + }); +}); diff --git a/packages/client/test/client/versionNegotiation.test.ts b/packages/client/test/client/versionNegotiation.test.ts new file mode 100644 index 0000000000..53b1cb155e --- /dev/null +++ b/packages/client/test/client/versionNegotiation.test.ts @@ -0,0 +1,570 @@ +/** + * Connect-time version negotiation: option surface (Q5/Q12), probe mechanics + * (T9), corrective continuation (T2/A6), typed connect errors, fallback + * byte-equivalence at the message level, era scope discipline, and the + * probe-window guard. + * + * Wire-real HTTP first-contact shapes (the -32000 literal and the session- + * required 400) are exercised against real server transports in + * test/integration/test/client/versionNegotiation.test.ts. + */ +import type { JSONRPCMessage, JSONRPCRequest, Transport } from '@modelcontextprotocol/core'; +import { + isJSONRPCRequest, + PROTOCOL_VERSION_META_KEY, + SdkError, + SdkErrorCode, + UnsupportedProtocolVersionError +} from '@modelcontextprotocol/core'; +import { describe, expect, test } from 'vitest'; + +import { Client } from '../../src/client/client.js'; +import type { StreamableHTTPClientTransportOptions } from '../../src/client/streamableHttp.js'; +import type { StdioServerParameters } from '../../src/client/stdio.js'; +import { resolveVersionNegotiation } from '../../src/client/versionNegotiation.js'; + +const MODERN = '2026-07-28'; + +/* ------------------------------------------------------------------------- * + * Q5: option home — dissolved transport/stdio negotiation surfaces stay gone. + * ------------------------------------------------------------------------- */ + +describe('option surface (Q5/Q12)', () => { + test('no Transport.negotiation, no transport/stdio negotiation or probeTimeoutMs options (dissolved surfaces)', () => { + type NotAKeyOf = K extends keyof T ? false : true; + const transportHasNoNegotiation: NotAKeyOf = true; + const httpOptionsHaveNoNegotiation: NotAKeyOf = true; + const stdioHasNoNegotiation: NotAKeyOf = true; + const stdioHasNoProbeTimeout: NotAKeyOf = true; + expect(transportHasNoNegotiation).toBe(true); + expect(httpOptionsHaveNoNegotiation).toBe(true); + expect(stdioHasNoNegotiation).toBe(true); + expect(stdioHasNoProbeTimeout).toBe(true); + }); + + test('absent versionNegotiation resolves to the legacy arm (today’s default; the deferred default ruling is a one-line flip)', () => { + expect(resolveVersionNegotiation(undefined, undefined)).toEqual({ kind: 'legacy' }); + expect(resolveVersionNegotiation({}, undefined)).toEqual({ kind: 'legacy' }); + expect(resolveVersionNegotiation({ mode: 'legacy' }, undefined)).toEqual({ kind: 'legacy' }); + }); + + test('auto resolves default-agnostically: explicit mode never consults the default', () => { + const auto = resolveVersionNegotiation({ mode: 'auto' }, undefined); + expect(auto).toMatchObject({ kind: 'auto', modernVersions: [MODERN], fallbackAvailable: true }); + }); + + test('a consumer supportedProtocolVersions list drives the offer and the fallback availability', () => { + const modernOnly = resolveVersionNegotiation({ mode: 'auto' }, [MODERN]); + expect(modernOnly).toMatchObject({ kind: 'auto', modernVersions: [MODERN], fallbackAvailable: false }); + + const mixed = resolveVersionNegotiation({ mode: 'auto' }, ['2027-01-01', MODERN, '2025-11-25']); + expect(mixed).toMatchObject({ kind: 'auto', modernVersions: ['2027-01-01', MODERN], fallbackAvailable: true }); + + const legacyOnly = resolveVersionNegotiation({ mode: 'auto' }, ['2025-11-25']); + expect(legacyOnly).toMatchObject({ kind: 'auto', modernVersions: [MODERN], fallbackAvailable: true }); + }); + + test('pin requires a modern revision', () => { + expect(resolveVersionNegotiation({ mode: { pin: MODERN } }, undefined)).toMatchObject({ kind: 'pin', version: MODERN }); + expect(() => resolveVersionNegotiation({ mode: { pin: '2025-11-25' } }, undefined)).toThrow(TypeError); + }); +}); + +/* ------------------------------------------------------------------------- * + * Scripted transport for probe mechanics. + * ------------------------------------------------------------------------- */ + +type Script = (message: JSONRPCMessage, transport: ScriptedTransport) => void; + +class ScriptedTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + sessionId?: string; + + startCalls = 0; + sent: JSONRPCMessage[] = []; + setProtocolVersionCalls: string[] = []; + + constructor(private readonly script: Script) {} + + async start(): Promise { + this.startCalls++; + if (this.startCalls > 1) { + throw new Error('ScriptedTransport already started! (double-start)'); + } + } + + async send(message: JSONRPCMessage): Promise { + this.sent.push(message); + const deliver = () => this.script(message, this); + queueMicrotask(deliver); + } + + async close(): Promise { + this.onclose?.(); + } + + setProtocolVersion(version: string): void { + this.setProtocolVersionCalls.push(version); + } + + reply(message: JSONRPCMessage): void { + this.onmessage?.(message); + } +} + +const discoverResult = (supportedVersions: string[]) => ({ + supportedVersions, + capabilities: {}, + serverInfo: { name: 'scripted-modern-server', version: '1.0.0' } +}); + +/** A scripted dual-era server: answers server/discover with a DiscoverResult and initialize like a 2025 server. */ +function modernServerScript(supportedVersions: string[] = [MODERN]): Script { + return (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + t.reply({ jsonrpc: '2.0', id: message.id, result: discoverResult(supportedVersions) }); + } + }; +} + +/** A scripted 2025 server: -32601 for unknown methods, a plain initialize result otherwise. */ +const legacyServerScript: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'initialize') { + t.reply({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: '2025-11-25', + capabilities: {}, + serverInfo: { name: 'scripted-legacy-server', version: '1.0.0' } + } + }); + } else { + t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } }); + } +}; + +const requests = (sent: JSONRPCMessage[]): JSONRPCRequest[] => sent.filter(isJSONRPCRequest); + +/* ------------------------------------------------------------------------- * + * Probe mechanics (T9) + modern resolution. + * ------------------------------------------------------------------------- */ + +describe('auto mode against a modern server', () => { + test('probe-first with a string id, no initialize, setProtocolVersion exactly once after era resolution', async () => { + const transport = new ScriptedTransport(modernServerScript()); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + await client.connect(transport); + + const sent = requests(transport.sent); + expect(sent).toHaveLength(1); + const probe = sent[0]!; + // T9: never probe with the first real request; string probe id (no + // collision with Protocol's numeric ids on shared pipes). + expect(probe.method).toBe('server/discover'); + expect(typeof probe.id).toBe('string'); + expect(String(probe.id)).toMatch(/^server-discover-probe-/); + // The probe carries the preferred version in its own _meta envelope. + const meta = (probe.params as { _meta?: Record })._meta; + expect(meta?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN); + + // No initialize, no notifications/initialized on the modern era. + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + expect(transport.sent.some(m => 'method' in m && m.method === 'notifications/initialized')).toBe(false); + + // The transport version slot was never mutated during negotiation; it is + // stamped exactly once, after the era resolved modern. + expect(transport.setProtocolVersionCalls).toEqual([MODERN]); + + expect(client.getProtocolEra()).toBe('modern'); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + expect(client.getServerVersion()?.name).toBe('scripted-modern-server'); + + await client.close(); + }); + + test('the probe window hands the started transport to Protocol.connect without a double start', async () => { + const transport = new ScriptedTransport(modernServerScript()); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(transport); + // ScriptedTransport.start throws on a second call — reaching here proves + // the handover absorbed Protocol.connect's unconditional start() exactly once. + expect(transport.startCalls).toBe(1); + await client.close(); + }); +}); + +/* ------------------------------------------------------------------------- * + * Fallback: byte-equivalence at the message level + zero version-slot writes. + * ------------------------------------------------------------------------- */ + +describe('auto mode against a legacy server (fallback)', () => { + test('falls back to initialize on the SAME connection; post-probe traffic is identical to a plain legacy connect', async () => { + const autoTransport = new ScriptedTransport(legacyServerScript); + const autoClient = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + await autoClient.connect(autoTransport); + + const plainTransport = new ScriptedTransport(legacyServerScript); + const plainClient = new Client({ name: 'c', version: '0' }); + await plainClient.connect(plainTransport); + + // Diff-asserted fallback hygiene: drop the probe, then the auto client's + // entire outbound sequence must be byte-identical to the plain legacy + // client's (same initialize id 0, same body incl. protocolVersion). + const autoSentAfterProbe = autoTransport.sent.slice(1); + expect(JSON.stringify(autoSentAfterProbe)).toBe(JSON.stringify(plainTransport.sent)); + + // Same setProtocolVersion behavior as the plain path (once, with the + // initialize-negotiated version) — nothing was set or cleared around the probe. + expect(autoTransport.setProtocolVersionCalls).toEqual(plainTransport.setProtocolVersionCalls); + + expect(autoClient.getProtocolEra()).toBe('legacy'); + expect(plainClient.getProtocolEra()).toBe('legacy'); + + await autoClient.close(); + await plainClient.close(); + }); + + test('option-parameterized oracle: a custom supportedProtocolVersions list flows into the fallback initialize body', async () => { + const versions = ['2025-06-18', '2025-03-26']; + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'initialize') { + t.reply({ + jsonrpc: '2.0', + id: message.id, + result: { protocolVersion: '2025-06-18', capabilities: {}, serverInfo: { name: 's', version: '1' } } + }); + } else { + t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } }); + } + }; + + const autoTransport = new ScriptedTransport(script); + const autoClient = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: versions } + ); + await autoClient.connect(autoTransport); + + const plainTransport = new ScriptedTransport(script); + const plainClient = new Client({ name: 'c', version: '0' }, { supportedProtocolVersions: versions }); + await plainClient.connect(plainTransport); + + expect(JSON.stringify(autoTransport.sent.slice(1))).toBe(JSON.stringify(plainTransport.sent)); + const init = requests(autoTransport.sent)[1]!; + expect((init.params as { protocolVersion?: string }).protocolVersion).toBe('2025-06-18'); + + await autoClient.close(); + await plainClient.close(); + }); + + // Fallback against REAL servers (in-memory pair, stateful HTTP, stateless + // HTTP — both first-contact wire shapes) is covered in + // test/integration/test/client/versionNegotiation.test.ts. +}); + +/* ------------------------------------------------------------------------- * + * Q12: timeout & retries are typed connect errors, never era verdicts. + * ------------------------------------------------------------------------- */ + +describe('probe timeout policy (Q12)', () => { + const silentScript: Script = () => { + /* never replies */ + }; + + test('timeout rejects with the standard typed timeout error and is NEVER converted to a legacy verdict', async () => { + const transport = new ScriptedTransport(silentScript); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 50 } } }); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout + ); + + // Never a legacy verdict: no initialize was attempted, before or after the timeout. + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + expect(requests(transport.sent)).toHaveLength(1); + expect(transport.setProtocolVersionCalls).toEqual([]); + }); + + test('maxRetries governs timeout re-sends only (default 0); each re-send uses a fresh probe id', async () => { + const transport = new ScriptedTransport(silentScript); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 30, maxRetries: 2 } } } + ); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout + ); + + const probes = requests(transport.sent); + expect(probes).toHaveLength(3); // initial + 2 re-sends + const ids = probes.map(p => String(p.id)); + expect(new Set(ids).size).toBe(3); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); +}); + +/* ------------------------------------------------------------------------- * + * T2/A6: -32004 corrective continuation — exactly once; loop guard on second + * rejection. Not counted against probe.maxRetries (Q12 disambiguation). + * ------------------------------------------------------------------------- */ + +describe('-32004 corrective continuation (T2/A6)', () => { + test('select-and-continue runs exactly once, even when the mutual version equals the just-rejected one', async () => { + let discoverCalls = 0; + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + discoverCalls++; + if (discoverCalls === 1) { + // Buggy-but-modern server: rejects the version it itself lists. + t.reply({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: [MODERN], requested: MODERN } + } + }); + } else { + t.reply({ jsonrpc: '2.0', id: message.id, result: discoverResult([MODERN]) }); + } + } + }; + + const transport = new ScriptedTransport(script); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto', probe: { maxRetries: 0 } } }); + await client.connect(transport); + + // The corrective continuation is spec-mandated and NOT counted against + // maxRetries (0 here): the second probe still happened. + expect(discoverCalls).toBe(2); + expect(client.getProtocolEra()).toBe('modern'); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + // MUST NOT fall back at any point. + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + + await client.close(); + }); + + test('the loop guard arms on the second rejection: typed error, never an infinite continuation', async () => { + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + t.reply({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32_004, message: 'Unsupported protocol version', data: { supported: [MODERN], requested: MODERN } } + }); + }; + + const transport = new ScriptedTransport(script); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + await expect(client.connect(transport)).rejects.toBeInstanceOf(UnsupportedProtocolVersionError); + expect(requests(transport.sent)).toHaveLength(2); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); + + test('-32004 with a disjoint-but-modern list: typed error, never initialize', async () => { + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + t.reply({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32_004, message: 'Unsupported protocol version', data: { supported: ['2027-12-31'] } } + }); + }; + + const transport = new ScriptedTransport(script); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + await expect(client.connect(transport)).rejects.toBeInstanceOf(UnsupportedProtocolVersionError); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); + + test('-32004 with a legacy-only list: definitive legacy signal, initialize on the same connection', async () => { + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + t.reply({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32_004, message: 'Unsupported protocol version', data: { supported: ['2025-11-25'] } } + }); + } else { + legacyServerScript(message, t); + } + }; + + const transport = new ScriptedTransport(script); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(transport); + + expect(client.getProtocolEra()).toBe('legacy'); + await client.close(); + }); + + test('modern-only client + legacy-only -32004 list: typed error carrying data.supported', async () => { + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + t.reply({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32_004, message: 'Unsupported protocol version', data: { supported: ['2025-11-25'] } } + }); + }; + + const transport = new ScriptedTransport(script); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: [MODERN] } + ); + + const rejection = await client.connect(transport).then( + () => undefined, + error => error as UnsupportedProtocolVersionError + ); + expect(rejection).toBeInstanceOf(UnsupportedProtocolVersionError); + expect(rejection!.supported).toEqual(['2025-11-25']); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); +}); + +/* ------------------------------------------------------------------------- * + * Pin mode: no fallback, loud failure. + * ------------------------------------------------------------------------- */ + +describe('pin mode', () => { + test('modern era at the pinned version when the server offers it', async () => { + const transport = new ScriptedTransport(modernServerScript([MODERN, '2027-01-01'])); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(transport); + + expect(client.getProtocolEra()).toBe('modern'); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + await client.close(); + }); + + test('a legacy server fails loudly — no initialize fallback', async () => { + const transport = new ScriptedTransport(legacyServerScript); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); + + test('a modern server without the pinned version fails with typed data — never initialize', async () => { + const transport = new ScriptedTransport(modernServerScript(['2027-12-31'])); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + + const rejection = await client.connect(transport).then( + () => undefined, + error => error as UnsupportedProtocolVersionError + ); + expect(rejection).toBeInstanceOf(UnsupportedProtocolVersionError); + expect(rejection!.supported).toEqual(['2027-12-31']); + expect(rejection!.requested).toBe(MODERN); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); +}); + +/* ------------------------------------------------------------------------- * + * Probe-window guard: pre-init server→client traffic mid-probe is dropped + * with zero bytes. + * ------------------------------------------------------------------------- */ + +describe('probe-window guard', () => { + test('a 2025-legal pre-init server→client request arriving mid-probe is dropped with zero bytes', async () => { + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + // The server pushes a ping BEFORE answering the probe (legal on a + // 2025 stdio pipe). It must be dropped — no response bytes. + t.reply({ jsonrpc: '2.0', id: 999, method: 'ping' }); + t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } }); + } else { + legacyServerScript(message, t); + } + }; + + const transport = new ScriptedTransport(script); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(transport); + + // Zero bytes for the dropped request: nothing in the sent log answers id 999. + const repliesTo999 = transport.sent.filter(m => 'id' in m && m.id === 999); + expect(repliesTo999).toEqual([]); + expect(client.getProtocolEra()).toBe('legacy'); + await client.close(); + }); +}); + +/* ------------------------------------------------------------------------- * + * Scope discipline: era is connection state — re-negotiated on every fresh + * connect, never silently demoted on the current connection. + * ------------------------------------------------------------------------- */ + +describe('era scope discipline', () => { + test('every fresh auto connect re-runs negotiation: no verdict survives a reconnect', async () => { + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + // First connect: probe, then fallback. + const first = new ScriptedTransport(legacyServerScript); + await client.connect(first); + expect(requests(first.sent)[0]!.method).toBe('server/discover'); + expect(client.getProtocolEra()).toBe('legacy'); + await client.close(); + + // Second (fresh) connect: the negotiated protocol version is connection + // state and is cleared at fresh connect — the probe runs again instead + // of replaying the previous connection's verdict. + const second = new ScriptedTransport(legacyServerScript); + await client.connect(second); + expect(requests(second.sent)[0]!.method).toBe('server/discover'); + expect(client.getProtocolEra()).toBe('legacy'); + await client.close(); + }); + + test('an established modern era is never silently demoted: later failures surface, only the NEXT connect re-negotiates', async () => { + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + const transport = new ScriptedTransport(modernServerScript()); + await client.connect(transport); + expect(client.getProtocolEra()).toBe('modern'); + + // A later transport failure does not demote the current connection's era + // and triggers no initialize. + transport.onerror?.(new Error('boom')); + expect(client.getProtocolEra()).toBe('modern'); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + await client.close(); + + // The next connect re-runs negotiation (the discover exchange doubles as + // the capability fetch). + const next = new ScriptedTransport(modernServerScript()); + await client.connect(next); + expect(requests(next.sent)[0]!.method).toBe('server/discover'); + expect(client.getProtocolEra()).toBe('modern'); + await client.close(); + }); + + test('no era state exists before the first connect, and none is persisted anywhere', () => { + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + expect(client.getProtocolEra()).toBeUndefined(); + // No cachedEra option surface (deferred-additive). + type NotAKeyOf = K extends keyof T ? false : true; + const noCachedEra: NotAKeyOf[1]>, 'cachedEra'> = true; + expect(noCachedEra).toBe(true); + }); +}); From adb88696b82fab53f43e18a57aad077ff721c0c3 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 12 Jun 2026 15:15:03 +0000 Subject: [PATCH 04/19] test(integration): wire-real negotiation fixtures against deployed-shape servers Exercises the connect-time probe against real streamable HTTP server transports over real sockets, capturing the exact bytes: - stateless deployments answer the probe 400/-32000 with the byte-exact 'Unsupported protocol version' literal (version header checked, no session) - stateful deployments answer 400/-32000 'Server not initialized' free-text (session state is checked before the version header) - both shapes resolve to the legacy fallback, which then works end to end - diff-asserted fallback hygiene: the auto client's post-probe POSTs are byte-identical (bodies and headers) to this client's own plain legacy connect under identical options, with zero 2026 headers - network outage (nothing listening) rejects with the typed era-negotiation connect error, never a legacy verdict - probe timeout re-sends per maxRetries and then rejects with the typed timeout error without ever sending initialize --- .../test/client/versionNegotiation.test.ts | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 test/integration/test/client/versionNegotiation.test.ts diff --git a/test/integration/test/client/versionNegotiation.test.ts b/test/integration/test/client/versionNegotiation.test.ts new file mode 100644 index 0000000000..c5e60b3679 --- /dev/null +++ b/test/integration/test/client/versionNegotiation.test.ts @@ -0,0 +1,225 @@ +/** + * Wire-real version negotiation fixtures: the probe against REAL deployed-shape + * servers over real HTTP. + * + * First-contact wire shapes (both deployment flavors): + * - stateless servers answer the probe 400/-32000 with the byte-exact + * "Unsupported protocol version" literal (version header checked, no session), + * - stateful servers answer 400/-32000 session-required free-text (session is + * checked BEFORE version). + * + * Plus: structural fallback hygiene (the auto client's post-probe traffic is + * byte-identical to a plain legacy client's, zero 2026 headers), and the Q12 + * typed connect errors for outage and timeout. + */ +import { randomUUID } from 'node:crypto'; +import type { Server } from 'node:http'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { McpServer } from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +/** A fetch wrapper recording every request our client puts on the wire (URL, headers, body) and the raw response (status, body). */ +function recordingFetch() { + const calls: Array<{ + method: string; + headers: Record; + body: string | undefined; + status: number; + responseBody: string; + }> = []; + const fetchFn: typeof fetch = async (input, init) => { + const headers: Record = {}; + for (const [key, value] of new Headers(init?.headers).entries()) { + headers[key.toLowerCase()] = value; + } + const response = await fetch(input, init); + const clone = response.clone(); + const responseBody = await clone.text().catch(() => ''); + calls.push({ + method: init?.method ?? 'GET', + headers, + body: typeof init?.body === 'string' ? init.body : undefined, + status: response.status, + responseBody + }); + return response; + }; + return { calls, fetchFn }; +} + +const NEGOTIATION_HEADERS = ['mcp-protocol-version', 'mcp-method', 'mcp-name'] as const; + +async function setupLegacyServer(stateful: boolean) { + const httpServer: Server = createServer(); + const mcpServer = new McpServer({ name: 'deployed-2025-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + const serverTransport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: stateful ? () => randomUUID() : undefined + }); + await mcpServer.connect(serverTransport); + httpServer.on('request', (req, res) => void serverTransport.handleRequest(req, res)); + const baseUrl = await listenOnRandomPort(httpServer); + return { httpServer, mcpServer, serverTransport, baseUrl }; +} + +describe('version negotiation against real legacy servers (wire-real first-contact shapes)', () => { + const cleanups: Array<() => Promise | void> = []; + afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!(); + }); + + async function startLegacy(stateful: boolean) { + const setup = await setupLegacyServer(stateful); + cleanups.push(async () => { + await setup.mcpServer.close().catch(() => {}); + await setup.serverTransport.close().catch(() => {}); + setup.httpServer.close(); + }); + return setup; + } + + it('stateless deployment: the probe meets the 400/-32000 "Unsupported protocol version" literal, then falls back byte-clean', async () => { + const { baseUrl } = await startLegacy(false); + const { calls, fetchFn } = recordingFetch(); + + const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + const transport = new StreamableHTTPClientTransport(baseUrl, { fetch: fetchFn }); + await client.connect(transport); + cleanups.push(() => client.close()); + + // First contact: the probe POST (body-derived 2026 headers). + const probe = calls[0]!; + expect(probe.headers['mcp-protocol-version']).toBe('2026-07-28'); + expect(probe.headers['mcp-method']).toBe('server/discover'); + // Wire-real shape #1 — the deployed-fleet literal (Q10-L1; consumed as a fixture only). + expect(probe.status).toBe(400); + const probeBody = JSON.parse(probe.responseBody) as { error: { code: number; message: string } }; + expect(probeBody.error.code).toBe(-32_000); + expect(probeBody.error.message).toContain('Bad Request: Unsupported protocol version: 2026-07-28'); + expect(probeBody.error.message).toContain('supported versions:'); + + // Conservative fallback on the same connection. + expect(client.getProtocolEra()).toBe('legacy'); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + + // Fallback hygiene: ZERO 2026 headers on every post-probe request. + for (const call of calls.slice(1)) { + expect(call.headers['mcp-method']).toBeUndefined(); + expect(call.headers['mcp-name']).toBeUndefined(); + const version = call.headers['mcp-protocol-version']; + if (version !== undefined) { + expect(version < '2026').toBe(true); + } + expect(call.body ?? '').not.toContain('2026-07-28'); + } + + // The legacy era works end to end. + const result = await client.callTool({ name: 'echo', arguments: { text: 'hi' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hi' }]); + }); + + it('stateful deployment: the probe meets 400/-32000 session-required free-text (session checked before version), then falls back', async () => { + const { baseUrl } = await startLegacy(true); + const { calls, fetchFn } = recordingFetch(); + + const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + const transport = new StreamableHTTPClientTransport(baseUrl, { fetch: fetchFn }); + await client.connect(transport); + cleanups.push(() => client.close()); + + // Wire-real shape #2 — stateful servers reject pre-init non-initialize + // POSTs before ever looking at the version header. + const probe = calls[0]!; + expect(probe.status).toBe(400); + const probeBody = JSON.parse(probe.responseBody) as { error: { code: number; message: string } }; + expect(probeBody.error.code).toBe(-32_000); + expect(probeBody.error.message).toBe('Bad Request: Server not initialized'); + + expect(client.getProtocolEra()).toBe('legacy'); + const result = await client.callTool({ name: 'echo', arguments: { text: 'stateful' } }); + expect(result.content).toEqual([{ type: 'text', text: 'stateful' }]); + }); + + it('diff-asserted fallback ≡ this client’s own plain legacy connect under identical ClientOptions', async () => { + const { baseUrl } = await startLegacy(false); + + const auto = recordingFetch(); + const autoClient = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await autoClient.connect(new StreamableHTTPClientTransport(baseUrl, { fetch: auto.fetchFn })); + cleanups.push(() => autoClient.close()); + await autoClient.callTool({ name: 'echo', arguments: { text: 'x' } }); + + const plain = recordingFetch(); + const plainClient = new Client({ name: 'neg-client', version: '1.0.0' }); + await plainClient.connect(new StreamableHTTPClientTransport(baseUrl, { fetch: plain.fetchFn })); + cleanups.push(() => plainClient.close()); + await plainClient.callTool({ name: 'echo', arguments: { text: 'x' } }); + + // Drop the probe exchange; everything after it must be identical to the + // plain client: same POST bodies (including the initialize body version) + // and the same headers (no clearing artifacts, no extras). + const autoPosts = auto.calls.filter(c => c.method === 'POST').slice(1); + const plainPosts = plain.calls.filter(c => c.method === 'POST'); + expect(autoPosts.length).toBe(plainPosts.length); + for (const [i, plainPost] of plainPosts.entries()) { + expect(autoPosts[i]!.body).toBe(plainPost!.body); + expect(autoPosts[i]!.headers).toEqual(plainPost!.headers); + for (const header of NEGOTIATION_HEADERS) { + if (header === 'mcp-protocol-version') continue; // legacy value allowed post-initialize + expect(autoPosts[i]!.headers[header]).toBeUndefined(); + } + } + }); +}); + +describe('typed connect errors (Q12) over real sockets', () => { + it('network outage (nothing listening): typed connect error, never a legacy verdict', async () => { + // Reserve a port, then close it so nothing is listening. + const placeholder = createServer(); + const url = await listenOnRandomPort(placeholder); + await new Promise(resolve => placeholder.close(() => resolve())); + + const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + const transport = new StreamableHTTPClientTransport(url); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + }); + + it('probe timeout: typed timeout error after maxRetries, no initialize ever sent', async () => { + // A server that accepts the request and never responds. + const hang = createServer(() => { + /* never answer */ + }); + const url = await listenOnRandomPort(hang); + + const { calls, fetchFn } = recordingFetch(); + const client = new Client( + { name: 'neg-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 300, maxRetries: 1 } } } + ); + const transport = new StreamableHTTPClientTransport(url, { fetch: fetchFn }); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout + ); + + // Two probe attempts (initial + 1 retry), zero initialize POSTs. + const posts = calls.filter(c => c.method === 'POST'); + expect(posts.every(c => c.headers['mcp-method'] === 'server/discover')).toBe(true); + expect(posts.every(c => (c.body ?? '').includes('server/discover'))).toBe(true); + expect(calls.some(c => (c.body ?? '').includes('"initialize"'))).toBe(false); + + await new Promise(resolve => hang.close(() => resolve())); + await new Promise(resolve => setTimeout(resolve, 50)); + }, 15_000); +}); From d1600c7333dae0db6402e872ee22bb97f5ccce0b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 12 Jun 2026 15:25:47 +0000 Subject: [PATCH 05/19] feat(core): wire server/discover into the typed request funnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The discover wire shapes landed earlier but were deliberately excluded from the typed funnel. This closes that residue: DiscoverRequestSchema joins the ClientRequestSchema union (and DiscoverResultSchema the ServerResultSchema union) and ResultTypeMap carries the typed result — so request(), setRequestHandler() and the schema funnel all speak the method. Per-era availability stays with the wire registries (one source of truth): the 2026-era registry already serves server/discover and the 2025-era registry deliberately does not, so no neutral runtime schema map gains an entry. The 2025-era result/request maps are now keyed by that era's subset of the typed method set, which keeps the runtime/typed alignment guard while excluding 2026-only vocabulary by construction. --- packages/core/src/types/schemas.ts | 2 + packages/core/src/types/types.ts | 1 + .../core/src/wire/rev2025-11-25/registry.ts | 49 ++++++++++------- .../core/test/types/discoverWiring.test.ts | 54 +++++++++++++++++++ 4 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 packages/core/test/types/discoverWiring.test.ts diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts index eec110b960..22e405ff06 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -2015,6 +2015,7 @@ export const RootsListChangedNotificationSchema = NotificationSchema.extend({ export const ClientRequestSchema = z.union([ PingRequestSchema, InitializeRequestSchema, + DiscoverRequestSchema, CompleteRequestSchema, SetLevelRequestSchema, GetPromptRequestSchema, @@ -2060,6 +2061,7 @@ export const ServerNotificationSchema = z.union([ export const ServerResultSchema = z.union([ EmptyResultSchema, InitializeResultSchema, + DiscoverResultSchema, CompleteResultSchema, GetPromptResultSchema, ListPromptsResultSchema, diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index 7c8f0d30a2..cc5d8da8de 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -466,6 +466,7 @@ export type NotificationTypeMap = MethodToTypeMap; + /* Runtime schema lookup — result schemas by method */ -// Keyed by `RequestMethod` and valued by `z.ZodType` so the -// runtime map and the typed `ResultTypeMap` cannot drift: a missing entry, an -// extra key, or an entry that does not parse to the typed map's result type -// is a compile error. No entry may be looser than the typed map (no -// task-result union members) and 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). -const resultSchemas: { readonly [M in RequestMethod]: z.ZodType } = { +// Keyed by the era's typed-method subset and valued by +// `z.ZodType` so the runtime map and the typed +// `ResultTypeMap` cannot drift: a missing entry, an extra key, or an entry +// that does not parse to the typed map's result type is a compile error. No +// entry may be looser than the typed map (no task-result union members) and +// 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). +const resultSchemas: { readonly [M in Rev2025TypedRequestMethod]: z.ZodType } = { ping: EmptyResultSchema, initialize: InitializeResultSchema, 'completion/complete': CompleteResultSchema, @@ -167,19 +180,19 @@ export function hasNotificationMethod2025(method: string): method is Rev2025Noti return Object.prototype.hasOwnProperty.call(notificationSchemas, method); } -/** Result-map membership: exactly the typed `RequestMethod` set (no task entries). */ -function hasResultMethod(method: string): method is RequestMethod { +/** Result-map membership: exactly the era's typed-method subset (no task entries, no 2026-only methods). */ +function hasResultMethod(method: string): method is Rev2025TypedRequestMethod { return Object.prototype.hasOwnProperty.call(resultSchemas, method); } /** * Gets the Zod schema for validating results of a given request method. - * Returns `undefined` for non-spec methods. + * Returns `undefined` for non-spec methods and 2026-only methods. * The typed overload is backed by the map's own typing (`z.ZodType` - * per entry), so callers with a statically known method can use the parsed - * value without a type assertion. + * per entry), so callers with a statically known 2025-era method can use the + * parsed value without a type assertion. */ -export function getResultSchema(method: M): z.ZodType; +export function getResultSchema(method: M): z.ZodType; export function getResultSchema(method: string): z.ZodType | undefined; export function getResultSchema(method: string): z.ZodType | undefined { return hasResultMethod(method) ? resultSchemas[method] : undefined; @@ -187,11 +200,11 @@ export function getResultSchema(method: string): z.ZodType | undefined { /** * Gets the Zod schema for a given request method. - * Returns `undefined` for non-spec methods. + * Returns `undefined` for non-spec methods and 2026-only methods. * The typed overload returns a ZodType that parses to `RequestTypeMap[M]`, * allowing callers to use `schema.parse()` without additional type assertions. */ -export function getRequestSchema(method: M): z.ZodType; +export function getRequestSchema(method: M): z.ZodType; export function getRequestSchema(method: string): z.ZodType | undefined; export function getRequestSchema(method: string): z.ZodType | undefined { return hasRequestMethod2025(method) ? requestSchemas[method] : undefined; diff --git a/packages/core/test/types/discoverWiring.test.ts b/packages/core/test/types/discoverWiring.test.ts new file mode 100644 index 0000000000..b17b96101c --- /dev/null +++ b/packages/core/test/types/discoverWiring.test.ts @@ -0,0 +1,54 @@ +/** + * LC-02: `server/discover` wired into the typed request funnel — the wire + * shapes landed earlier but were deliberately union-excluded; this pins the + * widening into ClientRequestSchema / ServerResultSchema / the typed method + * maps. Per-era AVAILABILITY stays with the wire registries (one source of + * truth): the 2026-era registry serves the method, the 2025-era registry does + * not — there is no neutral runtime schema map to keep in sync. + */ +import { describe, expect, expectTypeOf, test } from 'vitest'; + +import { ClientRequestSchema, DiscoverResultSchema, ServerResultSchema } from '../../src/types/index.js'; +import type { DiscoverResult, RequestMethod, RequestTypeMap, ResultTypeMap } from '../../src/types/index.js'; +import { getRequestSchema, getResultSchema } from '../../src/wire/rev2025-11-25/registry.js'; +import { getRequestSchema2026, getResultSchema2026 } from '../../src/wire/rev2026-07-28/registry.js'; + +describe('server/discover typed-funnel wiring (LC-02)', () => { + test('ClientRequestSchema accepts a server/discover request', () => { + const parsed = ClientRequestSchema.safeParse({ method: 'server/discover' }); + expect(parsed.success).toBe(true); + }); + + test('ServerResultSchema accepts a discover result', () => { + const parsed = ServerResultSchema.safeParse({ + supportedVersions: ['2026-07-28'], + capabilities: {}, + serverInfo: { name: 's', version: '1' } + }); + expect(parsed.success).toBe(true); + }); + + test('the typed method maps carry server/discover', () => { + expectTypeOf<'server/discover'>().toExtend(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toMatchObjectType<{ method: 'server/discover' }>(); + }); + + test('per-era availability lives in the wire registries: 2026 serves it, 2025 does not', () => { + expect(getRequestSchema2026('server/discover')).toBeDefined(); + expect(getResultSchema2026('server/discover')).toBeDefined(); + expect(getRequestSchema('server/discover')).toBeUndefined(); + expect(getResultSchema('server/discover')).toBeUndefined(); + }); + + test('a discover result round-trips the schema with its advertisement intact', () => { + const result = DiscoverResultSchema.parse({ + supportedVersions: ['2026-07-28'], + capabilities: { tools: {} }, + serverInfo: { name: 'modern-server', version: '2.0.0' }, + instructions: 'use the tools' + }); + expect(result.supportedVersions).toEqual(['2026-07-28']); + expect(result.instructions).toBe('use the tools'); + }); +}); From e97d22bd3f7079d4fcd1c5cb069004ccfb6b47ab Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 12 Jun 2026 15:25:47 +0000 Subject: [PATCH 06/19] =?UTF-8?q?feat(server):=20era-aware=20version=20lis?= =?UTF-8?q?ts=20=E2=80=94=20legacy=20counter-offer=20+=20modern-only=20dis?= =?UTF-8?q?cover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of the same rule (an operation only consults its own era's subset of the supported-versions list): - initialize: the accept check and the counter-offer now use the LEGACY subset, so a legacy-era client can never be offered (or have echoed back) a 2026 version string at the counter-offer site — a 2026-07-28-or-later revision is never negotiated via the legacy initialize handshake; it is only ever selected through server/discover. With today's default constant the subset is the whole list: byte-identical behavior. The existing counter-offer ordering pin is updated in the same change to state the now era-aware semantics. - server/discover: installed only when the supported list carries a modern (2026-07-28+) revision — default servers keep answering -32601 exactly like the deployed fleet. Serving requires a modern-era instance (the method is physically absent from the legacy registry); production marking of modern instances belongs to the server entry, and tests mark instances through the package-internal hook it will use. The advertisement lists ONLY modern revisions and excludes the listChanged/subscribe-class capabilities until the subscriptions/listen flow ships (the listen milestone removes the stripping); the initialize advertisement is untouched. Also pins the classification carrier rule: hand-wired streamable HTTP transport traffic is never Protocol-classified (extra.request set, extra.classification unset), and a modern-stamped body through the legacy transport keeps today's exact legacy semantics byte-identically. The integration regression test that previously reached the 2026 revision through an initialize handshake with a widened supported-versions list now negotiates it through the real server/discover path (initialize can no longer yield a modern version by design). --- packages/server/src/server/server.ts | 76 ++++++- .../server/classificationCarrierPin.test.ts | 131 +++++++++++ packages/server/test/server/discover.test.ts | 211 ++++++++++++++++++ packages/server/test/server/server.test.ts | 19 +- test/integration/test/client/client.test.ts | 41 +++- 5 files changed, 457 insertions(+), 21 deletions(-) create mode 100644 packages/server/test/server/classificationCarrierPin.test.ts create mode 100644 packages/server/test/server/discover.test.ts diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index a82432d968..771d61e425 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -6,6 +6,7 @@ import type { CreateMessageRequestParamsWithTools, CreateMessageResult, CreateMessageResultWithTools, + DiscoverResult, ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, @@ -38,8 +39,10 @@ import { CreateMessageResultSchema, CreateMessageResultWithToolsSchema, LATEST_PROTOCOL_VERSION, + legacyProtocolVersions, LoggingLevelSchema, mergeCapabilities, + modernProtocolVersions, negotiatedProtocolVersionOf, parseSchema, Protocol, @@ -114,6 +117,14 @@ export class Server extends Protocol { this.setRequestHandler('initialize', request => this._oninitialize(request)); this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.()); + // server/discover (protocol revision 2026-07-28) is installed ONLY when + // the supported-versions list carries a modern revision: a legacy-only + // server (today's default constant) keeps answering -32601, byte-identical + // to the deployed fleet. This is structural era gating, not a flag. + if (modernProtocolVersions(this._supportedProtocolVersions).length > 0) { + this.setRequestHandler('server/discover', () => this._ondiscover()); + } + if (this._capabilities.logging) { this._registerLoggingHandler(); } @@ -386,9 +397,19 @@ export class Server extends Protocol { this._clientCapabilities = request.params.capabilities; this._clientVersion = request.params.clientInfo; - const protocolVersion = this._supportedProtocolVersions.includes(requestedVersion) + // Era-aware list semantics: a 2026-07-28-or-later revision is NEVER + // negotiated via the legacy `initialize` handshake — those revisions + // are only ever selected through `server/discover`. `initialize` is a + // legacy-era handshake, so both the accept check and the counter-offer + // consult only the legacy subset of the supported versions: a modern + // revision can never be accepted or counter-offered here, even when + // the list carries one, and a legacy-era client can never meet a + // modern version string at this site. (With today's default constant + // the subset is the whole list, byte-identical behavior.) + const legacyVersions = legacyProtocolVersions(this._supportedProtocolVersions); + const protocolVersion = legacyVersions.includes(requestedVersion) ? requestedVersion - : (this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION); + : (legacyVersions[0] ?? LATEST_PROTOCOL_VERSION); // The negotiated version is the instance's connection state — it IS // the wire-era selection for everything this instance sends and @@ -404,6 +425,25 @@ export class Server extends Protocol { }; } + /** + * Answers `server/discover` (protocol revision 2026-07-28). The + * advertisement is era-aware in both directions: + * + * - `supportedVersions` lists ONLY modern revisions (the modern subset of + * the supported-versions list) — discover never advertises 2025-era + * versions; those are negotiated via `initialize`. + * - the advertised capabilities exclude the listChanged/subscribe-class + * capabilities (see {@linkcode discoverAdvertisedCapabilities}). + */ + private _ondiscover(): DiscoverResult { + return { + supportedVersions: modernProtocolVersions(this._supportedProtocolVersions), + capabilities: discoverAdvertisedCapabilities(this.getCapabilities()), + serverInfo: this._serverInfo, + ...(this._instructions && { instructions: this._instructions }) + }; + } + /** * After initialization has completed, this will be populated with the client's reported capabilities. */ @@ -671,3 +711,35 @@ export class Server extends Protocol { return this.notification({ method: 'notifications/prompts/list_changed' }); } } + +/** + * The capability set a server advertises on `server/discover`. + * + * A11 rider: until the subscriptions/listen milestone (#14 / M6.1) lands, the + * discover advertisement must NOT include the listChanged/subscribe-class + * capabilities — on the 2026-07-28 revision those flows are replaced by + * `subscriptions/listen`, which the SDK does not serve yet, so advertising + * them would promise notification flows a modern-era connection cannot get. + * The listen milestone removes this stripping when it wires the real + * subscription machinery (cross-referenced from that milestone). + * + * Pure: never mutates the input; the legacy `initialize` advertisement is + * untouched by construction (it calls `getCapabilities()` directly). + */ +export function discoverAdvertisedCapabilities(capabilities: ServerCapabilities): ServerCapabilities { + const advertised: ServerCapabilities = { ...capabilities }; + if (capabilities.tools) { + advertised.tools = { ...capabilities.tools }; + delete advertised.tools.listChanged; + } + if (capabilities.prompts) { + advertised.prompts = { ...capabilities.prompts }; + delete advertised.prompts.listChanged; + } + if (capabilities.resources) { + advertised.resources = { ...capabilities.resources }; + delete advertised.resources.listChanged; + delete advertised.resources.subscribe; + } + return advertised; +} diff --git a/packages/server/test/server/classificationCarrierPin.test.ts b/packages/server/test/server/classificationCarrierPin.test.ts new file mode 100644 index 0000000000..00e135dd0f --- /dev/null +++ b/packages/server/test/server/classificationCarrierPin.test.ts @@ -0,0 +1,131 @@ +/** + * B-2 rule pin: hand-wired legacy-transport traffic is NEVER + * Protocol-classified. + * + * Discriminator: messages delivered by the hand-wired streamable HTTP server + * transport carry `extra.request` (the HTTP side channel) but `extra.classification` + * stays UNSET — the carrier exists for edge classifiers (the 2026 entry), and + * the Protocol layer must not classify on their behalf. A modern-stamped body + * (full 2026 `_meta` envelope) pushed through a legacy transport gets today's + * exact legacy semantics, byte-identical to the same body without the envelope + * claim where the envelope does not participate (the reserved keys are lifted + * from `_meta`, exactly as for any legacy request carrying them). + */ +import type { MessageExtraInfo } from '@modelcontextprotocol/core'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { Server } from '../../src/server/server.js'; +import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp.js'; + +const MODERN = '2026-07-28'; + +async function setupHandWired() { + const server = new Server({ name: 'pin-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler('tools/call', async () => ({ content: [{ type: 'text', text: 'pinned' }] })); + server.setRequestHandler('tools/list', async () => ({ tools: [] })); + + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true + }); + await server.connect(transport); + + // Hand-wired observation point: chain onto the transport callback the same + // way a consumer wrapping the transport would (wrappable-after-connect). + const seen: Array<{ method?: string; extra?: MessageExtraInfo }> = []; + const previous = transport.onmessage; + transport.onmessage = (message, extra) => { + seen.push({ method: (message as { method?: string }).method, extra }); + previous?.(message, extra); + }; + + const post = async (body: unknown): Promise<{ status: number; text: string }> => { + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(body) + }) + ); + return { status: response.status, text: await response.text() }; + }; + + return { server, transport, seen, post }; +} + +const toolsCall = (meta?: Record) => ({ + jsonrpc: '2.0', + id: 7, + method: 'tools/call', + params: { + name: 'anything', + arguments: {}, + ...(meta !== undefined && { _meta: meta }) + } +}); + +const modernEnvelope = { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'modern-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +describe('B-2: hand-wired legacy-transport traffic is never Protocol-classified', () => { + it('extra.request is set and extra.classification stays unset for every delivered message', async () => { + const { server, seen, post } = await setupHandWired(); + + await post(toolsCall()); + await post(toolsCall(modernEnvelope)); + + expect(seen.length).toBeGreaterThanOrEqual(2); + for (const { extra } of seen) { + expect(extra?.request).toBeInstanceOf(Request); + expect(extra?.classification).toBeUndefined(); + } + + await server.close(); + }); + + it('a modern-stamped body through the legacy transport gets today’s exact legacy semantics, byte-identical', async () => { + const plainSetup = await setupHandWired(); + const plainResponse = await plainSetup.post(toolsCall()); + await plainSetup.server.close(); + + const stampedSetup = await setupHandWired(); + const stampedResponse = await stampedSetup.post(toolsCall(modernEnvelope)); + await stampedSetup.server.close(); + + // Byte-identical response: the envelope claim does not flip an era, does + // not change the result shape, does not get echoed back. + expect(stampedResponse.status).toBe(plainResponse.status); + expect(stampedResponse.text).toBe(plainResponse.text); + expect(stampedResponse.text).toContain('pinned'); + expect(stampedResponse.text).not.toContain(MODERN); + }); + + it('a modern-stamped initialize through the legacy transport negotiates exactly like today (no modern era)', async () => { + const { server, post } = await setupHandWired(); + + const { status, text } = await post({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: MODERN, + capabilities: {}, + clientInfo: { name: 'modern-client', version: '1.0.0' }, + _meta: modernEnvelope + } + }); + + expect(status).toBe(200); + const parsed = JSON.parse(text) as { result: { protocolVersion: string } }; + // Today's exact legacy semantics: the unknown requested version is + // countered with the latest released version; the body stamp does not + // make the legacy transport modern. + expect(parsed.result.protocolVersion).toBe('2025-11-25'); + + await server.close(); + }); +}); diff --git a/packages/server/test/server/discover.test.ts b/packages/server/test/server/discover.test.ts new file mode 100644 index 0000000000..c2b96da595 --- /dev/null +++ b/packages/server/test/server/discover.test.ts @@ -0,0 +1,211 @@ +/** + * `server/discover` machinery + era-aware supported-version list semantics: + * + * - the handler is installed ONLY when the server's supported-versions list + * carries a modern (2026-07-28+) revision; default servers keep answering + * -32601 byte-identically to the deployed fleet + * - the advertisement is modern-only (DV-30) and excludes the + * listChanged/subscribe-class capabilities (A11 rider — until the + * subscriptions/listen milestone lands) + * - counter-offer ordering: with era-aware list semantics in place, a legacy + * initialize can never meet a modern version string at the counter-offer + * site, even when the supported list carries one — the guard that must hold + * BEFORE any LATEST/SUPPORTED constant bump. + * + * Era is instance state: an inbound `server/discover` is served only by a + * modern-era instance (the method is physically absent from the legacy + * registry). Production marking of modern instances is owned by the + * server-entry milestone; these tests mark instances through the + * package-internal hook the entry will use, and the modern-era request shape + * carries the required per-request `_meta` envelope. + */ +import type { DiscoverResult, JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + DiscoverResultSchema, + InitializeResultSchema, + InMemoryTransport, + isJSONRPCErrorResponse, + isJSONRPCResultResponse, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY, + setNegotiatedProtocolVersion, + SUPPORTED_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { discoverAdvertisedCapabilities, Server } from '../../src/server/server.js'; + +const MODERN = '2026-07-28'; +/** A supported list spanning both eras — what the constant becomes after a future bump. */ +const DUAL_ERA_VERSIONS = [MODERN, ...SUPPORTED_PROTOCOL_VERSIONS]; + +async function sendRaw(server: Server, request: JSONRPCRequest, options?: { markModern?: boolean }): Promise { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + if (options?.markModern) { + // Stand-in for the modern-era server entry (instance binding): mark the + // instance as serving the modern era so the era gate admits the method. + setNegotiatedProtocolVersion(server, MODERN); + } + const responsePromise = new Promise(resolve => { + clientTransport.onmessage = msg => resolve(msg); + }); + await clientTransport.start(); + await clientTransport.send(request); + return responsePromise; +} + +/** A wire-true modern discover request: the 2026 era requires the per-request `_meta` envelope. */ +const discoverRequest: JSONRPCRequest = { + jsonrpc: '2.0', + id: 1, + method: 'server/discover', + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + } + } +}; + +const initializeRequest = (requestedVersion: string): JSONRPCRequest => ({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: requestedVersion, capabilities: {}, clientInfo: { name: 'test-client', version: '1.0.0' } } +}); + +describe('server/discover handler gating', () => { + it('a default (legacy-only) server answers server/discover with -32601, byte-identical to the deployed fleet', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + const response = await sendRaw(server, discoverRequest); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + expect(response.error.code).toBe(-32_601); + } + await server.close(); + }); + + it('a server with a modern revision in its supported list serves discover on a modern-era instance', async () => { + const server = new Server( + { name: 'modern-server', version: '2.0.0' }, + { capabilities: { tools: {} }, supportedProtocolVersions: DUAL_ERA_VERSIONS, instructions: 'hello' } + ); + const response = await sendRaw(server, discoverRequest, { markModern: true }); + expect(isJSONRPCResultResponse(response)).toBe(true); + if (isJSONRPCResultResponse(response)) { + const result = DiscoverResultSchema.parse(response.result); + expect(result.supportedVersions).toEqual([MODERN]); + expect(result.serverInfo).toEqual({ name: 'modern-server', version: '2.0.0' }); + expect(result.instructions).toBe('hello'); + } + await server.close(); + }); + + it('a modern-era instance whose supported list carries no modern revision still answers -32601 (handler not installed)', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + const response = await sendRaw(server, discoverRequest, { markModern: true }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + expect(response.error.code).toBe(-32_601); + } + await server.close(); + }); +}); + +describe('discover advertisement constraints', () => { + it('advertises modern-only versions (DV-30): no 2025-era string ever appears in supportedVersions', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS }); + const response = await sendRaw(server, discoverRequest, { markModern: true }); + if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); + const result = DiscoverResultSchema.parse(response.result); + expect(result.supportedVersions).toEqual([MODERN]); + for (const version of result.supportedVersions) { + expect(version >= MODERN).toBe(true); + } + await server.close(); + }); + + it('excludes listChanged/subscribe-class capabilities (A11 rider, until subscriptions/listen lands)', async () => { + const server = new Server( + { name: 'test', version: '1.0.0' }, + { + capabilities: { + tools: { listChanged: true }, + prompts: { listChanged: true }, + resources: { listChanged: true, subscribe: true }, + logging: {}, + completions: {} + }, + supportedProtocolVersions: DUAL_ERA_VERSIONS + } + ); + const response = await sendRaw(server, discoverRequest, { markModern: true }); + if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); + const result = DiscoverResultSchema.parse(response.result) as DiscoverResult; + + expect(result.capabilities.tools).toEqual({}); + expect(result.capabilities.prompts).toEqual({}); + expect(result.capabilities.resources).toEqual({}); + expect(result.capabilities.logging).toEqual({}); + expect(result.capabilities.completions).toEqual({}); + expect(JSON.stringify(result.capabilities)).not.toContain('listChanged'); + expect(JSON.stringify(result.capabilities)).not.toContain('subscribe'); + + await server.close(); + }); + + it('discoverAdvertisedCapabilities is pure and leaves the initialize advertisement untouched', async () => { + const capabilities = { tools: { listChanged: true }, resources: { subscribe: true, listChanged: true } }; + const stripped = discoverAdvertisedCapabilities(capabilities); + expect(stripped).toEqual({ tools: {}, resources: {} }); + // No mutation of the input. + expect(capabilities).toEqual({ tools: { listChanged: true }, resources: { subscribe: true, listChanged: true } }); + + // The legacy initialize advertisement still carries the full capability set. + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities, supportedProtocolVersions: DUAL_ERA_VERSIONS }); + const response = await sendRaw(server, initializeRequest(LATEST_PROTOCOL_VERSION)); + if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); + const result = InitializeResultSchema.parse(response.result); + expect(result.capabilities.tools).toEqual({ listChanged: true }); + expect(result.capabilities.resources).toEqual({ subscribe: true, listChanged: true }); + await server.close(); + }); +}); + +describe('era-aware counter-offer ordering (the guard that precedes any constant bump)', () => { + it('an unknown requested version is countered with the latest LEGACY version even when the list carries a modern one', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS }); + const response = await sendRaw(server, initializeRequest('1999-01-01')); + if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); + const result = InitializeResultSchema.parse(response.result); + // supportedProtocolVersions[0] is the modern revision here — the + // counter-offer must NOT be it: a fallback initialize never meets a + // leaked 2026 string at this site. + expect(result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + expect(result.protocolVersion).not.toBe(MODERN); + await server.close(); + }); + + it('an initialize REQUESTING the modern revision is also answered with the latest legacy version (initialize never negotiates a modern era)', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS }); + const response = await sendRaw(server, initializeRequest(MODERN)); + if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); + const result = InitializeResultSchema.parse(response.result); + expect(result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + expect(server.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + await server.close(); + }); + + it('default-list behavior is byte-identical: the legacy subset IS the whole list today', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + const response = await sendRaw(server, initializeRequest('1999-01-01')); + if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); + const result = InitializeResultSchema.parse(response.result); + expect(result.protocolVersion).toBe(SUPPORTED_PROTOCOL_VERSIONS[0]); + await server.close(); + }); +}); diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index 4ca198535b..e47a0f065b 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -131,16 +131,15 @@ describe('Server', () => { }); it('counter-offers only released versions when a draft revision is requested', async () => { - // ORDERING PIN — counter-offer leak guard. The initialize - // counter-offer is `supportedProtocolVersions[0]`: whatever sits at - // the head of that list is offered to EVERY legacy-era client whose - // requested version is unknown. Era-aware supported-version list - // semantics must therefore land BEFORE any LATEST/SUPPORTED - // constant bump that adds a 2026-era revision — bumping first - // would leak the modern revision into 2025-era initialize - // handshakes via this exact site. If this pin goes red because the - // constants moved, do NOT update it until the counter-offer is - // era-aware. + // ORDERING PIN — counter-offer leak guard. The initialize accept + // check and counter-offer are now ERA-AWARE: they consult only the + // legacy (pre-2026-07-28) subset of `supportedProtocolVersions`, + // because a 2026-07-28-or-later revision is never negotiated via + // the legacy initialize handshake (it is only selected through + // server/discover). This pin holds even after a future + // LATEST/SUPPORTED constant bump adds a modern revision: the + // counter-offer can never name it. The dual-era list arms live in + // discover.test.ts ("era-aware counter-offer ordering"). const DRAFT_REVISION = '2026-07-28'; expect(SUPPORTED_PROTOCOL_VERSIONS).not.toContain(DRAFT_REVISION); const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index 89ea643edb..8de980f16a 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -6,6 +6,7 @@ import { ProtocolErrorCode, SdkError, SdkErrorCode, + setNegotiatedProtocolVersion, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; import { McpServer, Server } from '@modelcontextprotocol/server'; @@ -174,10 +175,13 @@ test('should restore negotiated protocol version on transport when reconnecting /*** * Test: The negotiated protocol version (and with it the wire era) is connection state — it must * not survive into a fresh connect. A client whose previous connection negotiated the modern - * revision (2026-07-28) must still be able to run a FRESH initialize handshake: `initialize` is - * legacy-era vocabulary by definition (it is physically absent from the modern registry), so a - * negotiated version left over from the dead connection would otherwise kill the handshake - * locally before it reaches the transport. + * revision (2026-07-28) via server/discover must still be able to run a FRESH legacy initialize + * handshake: `initialize` is legacy-era vocabulary by definition (it is physically absent from + * the modern registry), so a negotiated version left over from the dead connection would + * otherwise kill the handshake locally before it reaches the transport. + * + * The modern era is reached through the real negotiation path (versionNegotiation + the + * server/discover probe) — never via initialize, which only negotiates legacy versions. */ test('should run a fresh initialize handshake after close() when the previous connection negotiated the modern era', async () => { const MODERN_REVISION = '2026-07-28'; @@ -187,24 +191,43 @@ test('should run a fresh initialize handshake after close() when the previous co const server = new Server({ name: 'modern server', version: '1.0' }, { capabilities: {}, supportedProtocolVersions }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); + // Stand-in for the modern-era server entry (instance binding): mark the server instance + // as serving the modern era so it can answer the client's server/discover probe. + setNegotiatedProtocolVersion(server, MODERN_REVISION); await client.connect(clientTransport); }; - const client = new Client({ name: 'test client', version: '1.0' }, { supportedProtocolVersions }); + const connectLegacy = async (client: Client) => { + const server = new Server({ name: 'legacy server', version: '1.0' }, { capabilities: {} }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + }; + + // The client opts into negotiation: server/discover probe first, legacy initialize fallback. + const client = new Client({ name: 'test client', version: '1.0' }, { supportedProtocolVersions, versionNegotiation: { mode: 'auto' } }); - // First connection negotiates the modern revision: the instance now speaks the modern wire era. + // First connection negotiates the modern revision via server/discover: the instance now + // speaks the modern wire era. await connectModern(client); expect(client.getNegotiatedProtocolVersion()).toBe(MODERN_REVISION); await client.close(); - // Fresh connect (new transport, no sessionId): the stale negotiated version is cleared, the - // handshake rides the pre-negotiation bootstrap pin (legacy era), and the connection - // can re-negotiate the modern revision. + // Fresh connect (new transport, no sessionId): the stale negotiated version is cleared and + // the connection re-negotiates from scratch — modern again here. await connectModern(client); expect(client.getNegotiatedProtocolVersion()).toBe(MODERN_REVISION); await client.close(); + + // A fresh connect against a legacy-only server still runs the legacy initialize fallback: + // a leftover modern negotiated version would kill `initialize` locally (it is physically + // absent from the modern registry). + await connectLegacy(client); + expect(client.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + + await client.close(); }); /*** From 8ec466fa39a60ae71301b3c573097027106c8525 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 12 Jun 2026 15:25:54 +0000 Subject: [PATCH 07/19] feat(client): typed discover() request Client.discover() issues server/discover through the typed request funnel (schema-validated DiscoverResult). The method exists only on the 2026-07-28 era: on a 2025-era connection the outbound era gate rejects it locally with a typed error before anything reaches the transport. Capability assertion treats the method like initialize/ping (no capability required). Changeset and migration-guide entry included. --- .changeset/wire-server-discover.md | 10 ++ docs/migration.md | 4 + packages/client/src/client/client.ts | 23 +++++ packages/client/test/client/discover.test.ts | 101 +++++++++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 .changeset/wire-server-discover.md create mode 100644 packages/client/test/client/discover.test.ts diff --git a/.changeset/wire-server-discover.md b/.changeset/wire-server-discover.md new file mode 100644 index 0000000000..cca2752951 --- /dev/null +++ b/.changeset/wire-server-discover.md @@ -0,0 +1,10 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/server': minor +'@modelcontextprotocol/client': minor +--- + +Wire `server/discover` (protocol revision 2026-07-28) into the typed request funnel and serve it era-aware. The request joins `ClientRequestSchema`/`ServerResultSchema`/`ResultTypeMap` (per-era availability stays with the wire registries: only the 2026-era registry serves +it), and `Client.discover()` issues it as a typed request on 2026-era connections. A `Server` whose `supportedProtocolVersions` list carries a modern (2026-07-28+) revision installs the `server/discover` handler, advertising ONLY its modern revisions and excluding the +listChanged/subscribe-class capabilities until the `subscriptions/listen` flow ships; servers with today's default list are unchanged and keep answering `-32601`. The `initialize` handshake is now era-aware in the other direction: its accept check and counter-offer consult +only the legacy subset of the supported versions — a 2026-era revision is never negotiated via `initialize` — so a 2025-era client can never be offered a 2026 version string; with the default list this is byte-identical to previous behavior. diff --git a/docs/migration.md b/docs/migration.md index 0a879c949b..70664f8f6b 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1028,6 +1028,10 @@ versionNegotiation: { Note that `maxRetries` governs timeout re-sends only; the `-32004` corrective continuation (selecting a mutually supported version after a version rejection and continuing) is mandated by the specification and is not counted against it. +On the server side, a `Server`/`McpServer` whose `supportedProtocolVersions` list includes a 2026-era revision installs a `server/discover` handler, advertising only its modern revisions; servers with the default version list are byte-identical to before (they keep +answering `-32601`, and the `initialize` handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). The client can also issue the request directly via `client.discover()` on a 2026-era connection; on a 2025-era +connection the method is rejected locally with a typed error, since it does not exist on that protocol revision. + ### Automatic JSON Schema validator selection by runtime The SDK now automatically selects the appropriate JSON Schema validator based on your runtime environment: diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index b27d50862f..b97781c67c 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -9,6 +9,7 @@ import type { ClientRequest, CompleteRequest, CompleteResult, + DiscoverResult, EmptyResult, GetPromptRequest, GetPromptResult, @@ -47,6 +48,7 @@ import { CreateMessageResultSchema, CreateMessageResultWithToolsSchema, DEFAULT_REQUEST_TIMEOUT_MSEC, + DiscoverResultSchema, isModernProtocolVersion, LATEST_PROTOCOL_VERSION, ListChangedOptionsBaseSchema, @@ -750,6 +752,12 @@ export class Client extends Protocol { break; } + case 'server/discover': { + // No specific capability required for discover (protocol revision + // 2026-07-28; servers on that revision MUST implement it) + break; + } + case 'ping': { // No specific capability required for ping break; @@ -835,6 +843,21 @@ export class Client extends Protocol { return this.request({ method: 'ping' }, options); } + /** + * Asks the server to advertise its supported protocol versions, capabilities, + * and implementation info (`server/discover`, protocol revision 2026-07-28). + * + * Servers on the 2026-07-28 revision MUST implement this; the connect-time + * version negotiation issues it automatically. The method exists only on + * the 2026-07-28 era: on a connection negotiated to a 2025-era version it + * is rejected locally with a typed `SdkError` + * (`MethodNotSupportedByProtocolVersion`) before anything reaches the + * transport. + */ + async discover(options?: RequestOptions): Promise { + return this._requestWithSchema({ method: 'server/discover' }, DiscoverResultSchema, options); + } + /** Requests argument autocompletion suggestions from the server for a prompt or resource. */ async complete(params: CompleteRequest['params'], options?: RequestOptions): Promise { return this.request({ method: 'completion/complete', params }, options); diff --git a/packages/client/test/client/discover.test.ts b/packages/client/test/client/discover.test.ts new file mode 100644 index 0000000000..9e971f1cc5 --- /dev/null +++ b/packages/client/test/client/discover.test.ts @@ -0,0 +1,101 @@ +/** + * Typed `Client.discover()`: issues `server/discover` through the typed + * request funnel on a 2026-era connection; on a 2025-era connection the + * method does not exist (it is absent from the legacy registry), so the + * outbound era gate rejects it locally with a typed error before anything + * reaches the transport. + */ +import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; +import { isJSONRPCRequest, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; +import { describe, expect, test } from 'vitest'; + +import { Client } from '../../src/client/client.js'; + +const MODERN = '2026-07-28'; + +class ScriptedTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + sessionId?: string; + sent: JSONRPCMessage[] = []; + + constructor(private readonly script: (message: JSONRPCMessage, transport: ScriptedTransport) => void) {} + + async start(): Promise {} + async close(): Promise { + this.onclose?.(); + } + async send(message: JSONRPCMessage): Promise { + this.sent.push(message); + queueMicrotask(() => this.script(message, this)); + } + setProtocolVersion(_version: string): void {} + reply(message: JSONRPCMessage): void { + this.onmessage?.(message); + } +} + +const discoverBody = { + // A real 2026-era server stamps the resultType discriminator on the wire, + // and the 2026 wire shape carries the cacheable-result fields. + resultType: 'complete', + ttlMs: 0, + cacheScope: 'public', + supportedVersions: [MODERN], + capabilities: { tools: {} }, + serverInfo: { name: 'modern-server', version: '1.0.0' }, + instructions: 'modern instructions' +}; + +/** Answers server/discover (probe and typed request alike) like a modern server. */ +const modernScript = (message: JSONRPCMessage, t: ScriptedTransport) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + t.reply({ jsonrpc: '2.0', id: message.id, result: discoverBody }); + } +}; + +/** A plain 2025 server: answers initialize, -32601 for everything else. */ +const legacyScript = (message: JSONRPCMessage, t: ScriptedTransport) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'initialize') { + t.reply({ + jsonrpc: '2.0', + id: message.id, + result: { protocolVersion: '2025-11-25', capabilities: {}, serverInfo: { name: 'legacy-server', version: '1.0.0' } } + }); + } else { + t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } }); + } +}; + +describe('Client.discover()', () => { + test('issues a typed server/discover request on a 2026-era connection', async () => { + const transport = new ScriptedTransport(modernScript); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(transport); + + const advertisement = await client.discover(); + expect(advertisement.supportedVersions).toEqual([MODERN]); + expect(advertisement.serverInfo).toEqual({ name: 'modern-server', version: '1.0.0' }); + expect(advertisement.instructions).toBe('modern instructions'); + + await client.close(); + }); + + test('is rejected locally with a typed error on a 2025-era connection (the method does not exist on that era)', async () => { + const transport = new ScriptedTransport(legacyScript); + const client = new Client({ name: 'c', version: '0' }); + await client.connect(transport); + + const sentBefore = transport.sent.length; + await expect(client.discover()).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.MethodNotSupportedByProtocolVersion + ); + // Rejected locally: nothing new reached the transport. + expect(transport.sent.length).toBe(sentBefore); + + await client.close(); + }); +}); From efe2e066277004fb1cf893191d779a1b1ad30351 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 12 Jun 2026 15:25:54 +0000 Subject: [PATCH 08/19] fix(node): forward setSupportedProtocolVersions to the wrapped transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NodeStreamableHTTPServerTransport delegates every Transport member to the wrapped Web Standard transport except setSupportedProtocolVersions, so a server's supportedProtocolVersions option never reached the Node adapter's MCP-Protocol-Version header validation — the inner transport silently kept validating against the default constant. Add the missing delegation. --- .changeset/node-forward-supported-versions.md | 6 ++++++ packages/middleware/node/src/streamableHttp.ts | 11 +++++++++++ 2 files changed, 17 insertions(+) create mode 100644 .changeset/node-forward-supported-versions.md diff --git a/.changeset/node-forward-supported-versions.md b/.changeset/node-forward-supported-versions.md new file mode 100644 index 0000000000..413f53fde4 --- /dev/null +++ b/.changeset/node-forward-supported-versions.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/node': patch +--- + +Forward `setSupportedProtocolVersions` from `NodeStreamableHTTPServerTransport` to the wrapped Web Standard transport. Previously a server's `supportedProtocolVersions` option never reached the Node adapter's `MCP-Protocol-Version` header validation, which silently kept +validating against the default version list. diff --git a/packages/middleware/node/src/streamableHttp.ts b/packages/middleware/node/src/streamableHttp.ts index 68a0c224f0..579db2f2fe 100644 --- a/packages/middleware/node/src/streamableHttp.ts +++ b/packages/middleware/node/src/streamableHttp.ts @@ -152,6 +152,17 @@ export class NodeStreamableHTTPServerTransport implements Transport { return this._webStandardTransport.send(message, options); } + /** + * Forwards the supported protocol versions to the wrapped Web Standard + * transport for `MCP-Protocol-Version` header validation. Called by the + * protocol layer during connect; without this delegation a server's + * `supportedProtocolVersions` option never reached the Node adapter's + * header validation. + */ + setSupportedProtocolVersions(versions: string[]): void { + this._webStandardTransport.setSupportedProtocolVersions(versions); + } + /** * Handles an incoming HTTP request, whether `GET` or `POST`. * From 07a2dbb8d256b429e863c568598df658edf7a708 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 12 Jun 2026 15:25:54 +0000 Subject: [PATCH 09/19] test(integration): discover round-trip against a modern server over real HTTP A pin-mode 2026 client completes server/discover and version selection against a modern-era server instance (no initialize ever sent; the capability stripping is visible end to end); an auto-mode client selects the modern era on the same server, and falls back cleanly to the legacy handshake when the instance is not bound to the modern era; a plain legacy client against a server whose supported list carries a 2026 revision never receives a 2026 version string in any response (the counter-offer ordering guard, end to end); and client.discover() on a legacy-era connection is rejected locally with a typed error. Modern-era instance binding is owned by the server-side entry; the tests bind the instance through the package-internal hook it will use. --- .../test/client/discoverRoundtrip.test.ts | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 test/integration/test/client/discoverRoundtrip.test.ts diff --git a/test/integration/test/client/discoverRoundtrip.test.ts b/test/integration/test/client/discoverRoundtrip.test.ts new file mode 100644 index 0000000000..94d5001969 --- /dev/null +++ b/test/integration/test/client/discoverRoundtrip.test.ts @@ -0,0 +1,173 @@ +/** + * Discover round-trip: a pin-mode 2026 client completes `server/discover` → + * version selection against a modern server over real HTTP, plus the + * era-aware counter-offer end to end (a legacy client against a server whose + * supported list carries a 2026 revision never sees a 2026 version string). + * + * Era is instance state on the server: an inbound `server/discover` is served + * only by a modern-era instance (the method is physically absent from the + * legacy registry). Production binding of modern-era instances belongs to the + * server-side entry that classifies inbound traffic; until it lands these + * tests bind the instance through the package-internal hook it will use. + */ +import type { Server as HttpServer } from 'node:http'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { SdkError, SdkErrorCode, setNegotiatedProtocolVersion, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { McpServer } from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +const MODERN = '2026-07-28'; +const DUAL_ERA_VERSIONS = [MODERN, ...SUPPORTED_PROTOCOL_VERSIONS]; + +function recordingFetch() { + const bodies: string[] = []; + const fetchFn: typeof fetch = async (input, init) => { + if (typeof init?.body === 'string') bodies.push(init.body); + return fetch(input, init); + }; + return { bodies, fetchFn }; +} + +describe('server/discover round-trip against a modern server', () => { + const cleanups: Array<() => Promise | void> = []; + afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!(); + }); + + async function startServer(options: { modernEraInstance: boolean }) { + const httpServer: HttpServer = createServer(); + const mcpServer = new McpServer( + { name: 'dual-era-server', version: '2.0.0' }, + { + capabilities: { tools: { listChanged: true } }, + supportedProtocolVersions: DUAL_ERA_VERSIONS, + instructions: 'dual era' + } + ); + mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + const serverTransport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await mcpServer.connect(serverTransport); + if (options.modernEraInstance) { + // Stand-in for the server-side entry (instance binding): mark the + // instance as serving the modern era so it can answer the probe. + setNegotiatedProtocolVersion(mcpServer.server, MODERN); + } + httpServer.on('request', (req, res) => void serverTransport.handleRequest(req, res)); + const baseUrl = await listenOnRandomPort(httpServer); + cleanups.push(async () => { + await mcpServer.close().catch(() => {}); + await serverTransport.close().catch(() => {}); + httpServer.close(); + }); + return baseUrl; + } + + it('pin-mode 2026 client: server/discover → version selection, no initialize ever sent', async () => { + const baseUrl = await startServer({ modernEraInstance: true }); + const { bodies, fetchFn } = recordingFetch(); + + const client = new Client({ name: 'pin-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(new StreamableHTTPClientTransport(baseUrl, { fetch: fetchFn })); + cleanups.push(() => client.close()); + + expect(client.getProtocolEra()).toBe('modern'); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + expect(client.getServerVersion()).toEqual({ name: 'dual-era-server', version: '2.0.0' }); + expect(client.getInstructions()).toBe('dual era'); + // The advertisement excludes listChanged-class capabilities, visible end to end. + expect(client.getServerCapabilities()).toEqual({ tools: {} }); + + expect(bodies.some(b => b.includes('"initialize"'))).toBe(false); + expect(bodies[0]).toContain('server/discover'); + }); + + it('auto-mode client selects the modern era on the same server', async () => { + const baseUrl = await startServer({ modernEraInstance: true }); + const client = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(baseUrl)); + cleanups.push(() => client.close()); + + expect(client.getProtocolEra()).toBe('modern'); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + }); + + it('auto-mode against the same server NOT bound to the modern era falls back to the legacy handshake', async () => { + // A server instance serves the legacy era until it is bound to the + // modern one (binding is owned by the server-side entry); the probe is + // answered -32601 and the client falls back cleanly on the same + // connection. + const baseUrl = await startServer({ modernEraInstance: false }); + const client = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(baseUrl)); + cleanups.push(() => client.close()); + + expect(client.getProtocolEra()).toBe('legacy'); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + const result = await client.callTool({ name: 'echo', arguments: { text: 'fallback' } }); + expect(result.content).toEqual([{ type: 'text', text: 'fallback' }]); + }); + + it('a plain legacy client against a server with a dual-era list never meets a 2026 version string (counter-offer ordering, e2e)', async () => { + const baseUrl = await startServer({ modernEraInstance: false }); + const { fetchFn } = recordingFetch(); + + const responses: string[] = []; + const sniffingFetch: typeof fetch = async (input, init) => { + const response = await fetchFn(input, init); + responses.push( + await response + .clone() + .text() + .catch(() => '') + ); + return response; + }; + + const client = new Client({ name: 'legacy-client', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(baseUrl, { fetch: sniffingFetch })); + cleanups.push(() => client.close()); + + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + const result = await client.callTool({ name: 'echo', arguments: { text: 'legacy' } }); + expect(result.content).toEqual([{ type: 'text', text: 'legacy' }]); + + // The 2026 revision never appears in any response the legacy client received. + for (const body of responses) { + expect(body).not.toContain(MODERN); + } + }); + + it('client.discover() on a legacy-era connection is rejected locally with a typed error', async () => { + // Default (legacy-only) server; the connection negotiates a legacy + // version, on which server/discover does not exist — the request is + // rejected locally before it reaches the wire. (The typed discover() + // round-trip over HTTP completes once every modern request carries the + // per-request _meta envelope.) + const httpServer: HttpServer = createServer(); + const mcpServer = new McpServer({ name: 'legacy-only', version: '1.0.0' }, { capabilities: { tools: {} } }); + const serverTransport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await mcpServer.connect(serverTransport); + httpServer.on('request', (req, res) => void serverTransport.handleRequest(req, res)); + const baseUrl = await listenOnRandomPort(httpServer); + cleanups.push(async () => { + await mcpServer.close().catch(() => {}); + await serverTransport.close().catch(() => {}); + httpServer.close(); + }); + + const client = new Client({ name: 'legacy-client', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(baseUrl)); + cleanups.push(() => client.close()); + + await expect(client.discover()).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.MethodNotSupportedByProtocolVersion + ); + }); +}); From 5759300735ae300c7284c288e9ec876e03c32c7b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 15 Jun 2026 17:11:55 +0000 Subject: [PATCH 10/19] =?UTF-8?q?feat(client):=20transport-aware=20probe?= =?UTF-8?q?=20timeout=20verdict=20=E2=80=94=20stdio=20falls=20back,=20HTTP?= =?UTF-8?q?=20stays=20a=20typed=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A version-negotiation probe that gets no response within the timeout (after all maxRetries re-sends) now resolves per transport class, following the specification's backward-compatibility rules: - stdio: the silence indicates a legacy server — some legacy servers do not respond to unknown pre-initialize requests at all — and the client falls back to the initialize handshake on the same stream (the stdio transport's backward-compatibility rule: "any other error, or does not respond within a reasonable timeout: the server is legacy"). - HTTP (and any non-stdio transport): a deployed server answers, so silence is an outage, not a legacy signal — the timeout keeps rejecting with the standard typed RequestTimeout error (the versioning compatibility matrix keys the HTTP legacy signal to a 4xx without a recognized modern error body). The classifier gains a transport-kind input; the stdio child-process transport is recognized structurally (stderr/pid accessors), so subclasses and re-bundled copies are covered without instanceof. Tests are split by transport, including a silent-legacy-stdio-server scenario over a real pipe; the changeset and migration-guide text are updated to match. --- .changeset/add-version-negotiation-option.md | 3 +- docs/migration.md | 7 +- packages/client/src/client/client.ts | 12 ++-- packages/client/src/client/probeClassifier.ts | 51 +++++++++++--- .../client/src/client/versionNegotiation.ts | 38 +++++++--- .../test/client/probeClassifier.test.ts | 23 +++++-- .../test/client/versionNegotiation.test.ts | 62 ++++++++++++++++- .../test/client/versionNegotiation.test.ts | 69 ++++++++++++++++++- 8 files changed, 232 insertions(+), 33 deletions(-) diff --git a/.changeset/add-version-negotiation-option.md b/.changeset/add-version-negotiation-option.md index 102b7a3ba2..3393aac4fb 100644 --- a/.changeset/add-version-negotiation-option.md +++ b/.changeset/add-version-negotiation-option.md @@ -4,7 +4,8 @@ --- Add opt-in protocol version negotiation on `ClientOptions.versionNegotiation`. The default is unchanged: without the option (or with `mode: 'legacy'`) the client performs today's 2025 connect sequence byte-identically. `mode: 'auto'` probes the server with `server/discover` at -connect time and conservatively falls back to the plain legacy `initialize` handshake on the same connection unless the outcome is definitive modern evidence; network outage and probe timeout reject with typed connect errors and are never converted to an era verdict. +connect time and conservatively falls back to the plain legacy `initialize` handshake on the same connection unless the outcome is definitive modern evidence; a network outage rejects with a typed connect error, and a probe timeout is transport-aware — on stdio it indicates +a legacy server and falls back to `initialize` on the same stream, on HTTP it rejects with a typed timeout error. `mode: { pin: '' }` negotiates exactly the pinned modern revision with no fallback. Probe policy lives under `probe: { timeoutMs?, maxRetries? }` — the probe inherits the standard request timeout, and `maxRetries` governs timeout re-sends only (the spec-mandated `-32004` corrective continuation is not counted against it). The probe's `MCP-Protocol-Version`/`Mcp-Method` headers derive from the probe message body; the transport version slot is never touched during negotiation, so legacy-era traffic carries zero 2026 headers by construction. Adds `Client.getProtocolEra()` and the `SdkErrorCode.EraNegotiationFailed` code for negotiation-phase connect failures. diff --git a/docs/migration.md b/docs/migration.md index 70664f8f6b..5bfd678dd0 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1010,9 +1010,10 @@ How the modes behave: handshake **on the same connection** — byte-equivalent to a 2025 client, including the `initialize` body version and with zero 2026 headers. The probe costs one round trip against an old server and nothing else. - **`mode: { pin: '2026-07-28' }`**: modern era at exactly that revision. No fallback — if the server does not offer the pinned version, `connect()` rejects with a typed error. Use `pin` where a silent downgrade would be worse than an error (tests, CI, servers you control). -Failure semantics under `'auto'` are deliberately conservative but never silent about infrastructure problems: anything the probe does not positively recognize as modern falls back to the legacy era, while a network outage or probe timeout rejects with a typed connect error -(`SdkError` with `EraNegotiationFailed` or `RequestTimeout`) — a dead server is never misreported as a legacy server. One browser-specific exception: an opaque CORS/preflight `TypeError` during the probe falls back to the legacy era, because deployed 2025 servers commonly -have CORS allow-lists that predate the 2026 headers and the legacy handshake sends none of them. +Failure semantics under `'auto'` are deliberately conservative but never silent about infrastructure problems: anything the probe does not positively recognize as modern falls back to the legacy era, while a network outage rejects with a typed connect error (`SdkError` +with `EraNegotiationFailed`). A probe timeout is transport-aware, following the specification's backward-compatibility rules: on **stdio**, a server that does not answer the probe within the timeout is treated as a legacy server (some legacy servers never respond to unknown +pre-`initialize` requests at all) and the client falls back to `initialize` on the same stream; on **HTTP**, where a deployed server answers and silence means an outage, the timeout rejects with a typed `RequestTimeout` error — a dead HTTP server is never misreported as a +legacy server. One browser-specific exception: an opaque CORS/preflight `TypeError` during the probe falls back to the legacy era, because deployed 2025 servers commonly have CORS allow-lists that predate the 2026 headers and the legacy handshake sends none of them. Probe policy is configured under `versionNegotiation.probe`: diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index b97781c67c..a072935077 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -64,7 +64,7 @@ import { } from '@modelcontextprotocol/core'; import type { ResolvedVersionNegotiation, VersionNegotiationOptions } from './versionNegotiation.js'; -import { detectProbeEnvironment, negotiateEra, resolveVersionNegotiation } from './versionNegotiation.js'; +import { detectProbeEnvironment, detectProbeTransportKind, negotiateEra, resolveVersionNegotiation } from './versionNegotiation.js'; /** * Elicitation default application helper. Applies defaults to the `data` based on the `schema`. @@ -164,9 +164,12 @@ export type ClientOptions = ProtocolOptions & { * - `mode: 'auto'` — `connect()` probes the server with `server/discover` first: * definitive modern evidence selects the modern era; definitive legacy signals * (and anything unrecognized) fall back to the plain legacy `initialize` - * handshake on the same connection, byte-equivalent to a 2025 client. Network - * outage and probe timeout reject with typed connect errors — they are never - * converted to an era verdict. + * handshake on the same connection, byte-equivalent to a 2025 client. A + * network outage rejects with a typed connect error. A probe timeout is + * transport-aware: on stdio it indicates a legacy server (some legacy servers + * never answer unknown pre-`initialize` requests) and falls back to + * `initialize` on the same stream; on HTTP it rejects with a typed timeout + * error (silence on a deployed server is an outage, not a legacy signal). * - `mode: { pin: '2026-07-28' }` — modern era at exactly the pinned revision; * no probe-and-fallback: anything else fails loudly. * @@ -602,6 +605,7 @@ export class Client extends Protocol { clientInfo: this._clientInfo, capabilities: this._capabilities, environment: detectProbeEnvironment(), + transportKind: detectProbeTransportKind(transport), defaultTimeoutMs: options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC }); } catch (error) { diff --git a/packages/client/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts index fefc3c991a..55cf7fe382 100644 --- a/packages/client/src/client/probeClassifier.ts +++ b/packages/client/src/client/probeClassifier.ts @@ -8,16 +8,22 @@ * connection), or a typed connect error. * * The classifier is deliberately **conservative**: anything it does not positively - * recognize as modern resolves to the legacy fallback — with two exceptions that - * are never era verdicts: network outage and timeout reject with typed connect - * errors (era fallback happens only on definitive legacy signals; well-behaved - * legacy servers always produce one fast). + * recognize as modern resolves to the legacy fallback. Network outage rejects with + * a typed connect error (never an era verdict). Timeouts are transport-aware: on + * stdio, a probe that nobody answers within the timeout indicates a legacy server + * — the stdio transport's backward-compatibility rule ("any other error, or does + * not respond within a reasonable timeout: the server is legacy"; some legacy + * servers do not respond to unknown pre-`initialize` requests at all) — and falls + * back to `initialize` on the same stream. On HTTP a deployed server answers, so + * silence is an outage, not a legacy signal: the timeout stays a typed connect + * error (the versioning compatibility matrix keys the HTTP legacy signal to a 4xx + * without a recognized modern error body, never to silence). * * **Scope: negotiation phase only.** These verdicts apply exclusively to the * connect-time probe exchange. Once a connection's era is established as modern, a * later unrecognized failure surfaces to the caller and is never re-classified - * into a silent demotion to `initialize`; it marks the era record stale so the - * NEXT `connect()` re-runs negotiation. + * into a silent demotion to `initialize`; the next fresh `connect()` re-runs + * negotiation from scratch. * * `-32001` and `-32003` are deliberately NOT probe-recognized in either direction: * deployed servers still overload `-32001` for session-404 bodies and the draft @@ -45,6 +51,18 @@ import { */ export type ProbeEnvironment = 'node' | 'browser'; +/** + * The transport class the probe ran on. Only consulted for the timeout row: + * the specification's backward-compatibility rule for stdio treats a probe + * that gets no response within a reasonable timeout as a legacy-server signal + * (local pipes; some legacy servers do not respond to unknown + * pre-`initialize` requests at all), while on HTTP a deployed server answers, + * so silence is an outage — the HTTP legacy signal is a 4xx without a + * recognized modern error body. Anything that is not the stdio child-process + * transport is treated like HTTP (the conservative, typed-error posture). + */ +export type ProbeTransportKind = 'stdio' | 'http'; + /** * A normalized probe outcome, produced by the connect-time wiring from the raw * transport exchange. Wire-real inputs only — the wiring maps transport-thrown @@ -89,6 +107,8 @@ export interface ProbeClassifierContext { fallbackAvailable: boolean; /** See {@linkcode ProbeEnvironment}. */ environment: ProbeEnvironment; + /** See {@linkcode ProbeTransportKind}. */ + transportKind: ProbeTransportKind; } export type ProbeVerdict = @@ -130,8 +150,23 @@ export function classifyProbeOutcome(outcome: ProbeOutcome, context: ProbeClassi return classifyNetworkError(outcome.error, context); } case 'timeout': { - // Q12: timeout (standard request timeout, after all `maxRetries` - // re-sends) is a typed connect error, NEVER a legacy verdict. + if (context.transportKind === 'stdio') { + // stdio: a probe nobody answers within the timeout (after all + // `maxRetries` re-sends) indicates a legacy server — the stdio + // transport's backward-compatibility rule says "any other + // error, or does not respond within a reasonable timeout: the + // server is legacy. Fall back to the `initialize` handshake." + // Some legacy stdio servers do not respond to unknown + // pre-initialize requests at all; the fallback runs on the + // same stream. + return { kind: 'legacy' }; + } + // HTTP (and anything that is not a local pipe): a deployed server + // answers, so silence is an outage, not a legacy signal — the + // timeout (standard request timeout, after all `maxRetries` + // re-sends) stays a typed connect error. Per the versioning + // compatibility matrix, the HTTP legacy signal is a 4xx response + // without a recognized modern error body. return { kind: 'error', error: new SdkError( diff --git a/packages/client/src/client/versionNegotiation.ts b/packages/client/src/client/versionNegotiation.ts index 439fd43084..0e92dca82b 100644 --- a/packages/client/src/client/versionNegotiation.ts +++ b/packages/client/src/client/versionNegotiation.ts @@ -43,7 +43,7 @@ import { SUPPORTED_MODERN_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; -import type { ProbeEnvironment, ProbeOutcome, ProbeVerdict } from './probeClassifier.js'; +import type { ProbeEnvironment, ProbeOutcome, ProbeTransportKind, ProbeVerdict } from './probeClassifier.js'; import { classifyProbeOutcome } from './probeClassifier.js'; /** @@ -61,15 +61,20 @@ export interface VersionNegotiationProbeOptions { timeoutMs?: number; /** - * How many times a TIMED-OUT probe is re-sent before `connect()` rejects - * with a typed timeout error. + * How many times a TIMED-OUT probe is re-sent before the timeout verdict + * applies. * * `maxRetries` governs timeout re-sends only; the `-32004` corrective * continuation (select-and-continue on a mutual version) is spec-mandated * and is not counted against it. * - * A timeout after all retries is a typed connect error — it is NEVER - * converted to a legacy-era verdict. + * The timeout verdict is transport-aware: on stdio, a probe that gets no + * response within the timeout (after all retries) indicates a legacy + * server and falls back to the `initialize` handshake on the same stream + * (the stdio transport's backward-compatibility rule — some legacy + * servers do not respond to unknown pre-`initialize` requests at all). + * On HTTP, where a deployed server answers and silence means an outage, + * `connect()` rejects with the standard typed timeout error instead. * * @default 0 */ @@ -83,8 +88,10 @@ export interface VersionNegotiationProbeOptions { * to a client without this option. * - `'auto'` — probe with `server/discover` at connect; conservative fallback to * the plain legacy `initialize` handshake on the same connection unless the - * outcome is definitive modern evidence. Network outage and timeout reject - * with typed connect errors (never an era verdict). + * outcome is definitive modern evidence. Network outage rejects with a typed + * connect error; a probe timeout falls back to `initialize` on stdio (a silent + * server on a local pipe is a legacy server) and rejects with a typed timeout + * error on HTTP (silence there is an outage). * - `{ pin: '' }` — modern era at exactly the pinned revision: the * connect-time `server/discover` must offer it. No fallback — anything else * fails loudly with a typed error. @@ -174,6 +181,18 @@ export function detectProbeEnvironment(): ProbeEnvironment { return g.window !== undefined && g.document !== undefined ? 'browser' : 'node'; } +/** + * Detect the transport class for the timeout row's transport-aware verdict + * (see {@linkcode ProbeTransportKind}). The stdio child-process transport is + * recognized structurally (its `stderr`/`pid` accessors), so consumer + * subclasses and re-bundled copies are recognized without `instanceof`; + * everything else — HTTP, SSE, in-memory, custom transports — keeps the + * conservative typed-error posture for timeouts. + */ +export function detectProbeTransportKind(transport: Transport): ProbeTransportKind { + return 'stderr' in transport && 'pid' in transport ? 'stdio' : 'http'; +} + /** Raw reply from one probe exchange, before normalization. */ type RawProbeReply = | { kind: 'response'; result?: unknown; error?: { code: number; message: string; data?: unknown } } @@ -335,6 +354,8 @@ export interface NegotiationDeps { clientInfo: Implementation; capabilities: ClientCapabilities; environment: ProbeEnvironment; + /** The transport class, for the transport-aware timeout verdict (see {@linkcode ProbeTransportKind}). */ + transportKind: ProbeTransportKind; /** The standard request timeout for this connect (probe inherits it unless `probe.timeoutMs` overrides). */ defaultTimeoutMs: number; } @@ -378,7 +399,8 @@ export async function negotiateEra( clientModernVersions, requestedVersion, fallbackAvailable, - environment: deps.environment + environment: deps.environment, + transportKind: deps.transportKind }); switch (verdict.kind) { diff --git a/packages/client/test/client/probeClassifier.test.ts b/packages/client/test/client/probeClassifier.test.ts index e14ce37bbd..10a12eff5e 100644 --- a/packages/client/test/client/probeClassifier.test.ts +++ b/packages/client/test/client/probeClassifier.test.ts @@ -20,7 +20,8 @@ const baseContext: ProbeClassifierContext = { clientModernVersions: [MODERN], requestedVersion: MODERN, fallbackAvailable: true, - environment: 'node' + environment: 'node', + transportKind: 'http' }; function classify(outcome: ProbeOutcome, context: Partial = {}): ProbeVerdict { @@ -264,15 +265,29 @@ describe('row: network outage → typed connect error (Node)', () => { }); }); -describe('row: timeout after maxRetries → typed connect error — NEVER a legacy verdict (Q12)', () => { - test('timeout maps to the standard RequestTimeout SdkError', () => { - const verdict = classify({ kind: 'timeout', timeoutMs: 60_000, attempts: 1 }); +describe('row: timeout after maxRetries — transport-aware verdict', () => { + // The specification's backward-compatibility rule for stdio: "any other + // error, or does not respond within a reasonable timeout: the server is + // legacy. Fall back to the initialize handshake." The versioning + // compatibility matrix draws the same line per transport: stdio probe + // times out → fall back to initialize; on HTTP the legacy signal is a 4xx + // without a recognized modern error body, so silence stays an outage. + test('HTTP: timeout maps to the standard RequestTimeout SdkError (silence on a deployed server is an outage)', () => { + const verdict = classify({ kind: 'timeout', timeoutMs: 60_000, attempts: 1 }, { transportKind: 'http' }); expect(verdict.kind).toBe('error'); if (verdict.kind === 'error') { expect(verdict.error).toBeInstanceOf(SdkError); expect((verdict.error as SdkError).code).toBe(SdkErrorCode.RequestTimeout); } }); + + test('stdio: timeout is a legacy-server signal → fall back to initialize on the same stream', () => { + expect(classify({ kind: 'timeout', timeoutMs: 5_000, attempts: 1 }, { transportKind: 'stdio' })).toEqual({ kind: 'legacy' }); + }); + + test('stdio: the verdict applies after the timeout re-sends, regardless of attempt count', () => { + expect(classify({ kind: 'timeout', timeoutMs: 5_000, attempts: 3 }, { transportKind: 'stdio' })).toEqual({ kind: 'legacy' }); + }); }); describe('row: browser opaque CORS/preflight TypeError, PROBE PHASE ONLY → legacy fallback (F-7)', () => { diff --git a/packages/client/test/client/versionNegotiation.test.ts b/packages/client/test/client/versionNegotiation.test.ts index 53b1cb155e..78ae48fe0c 100644 --- a/packages/client/test/client/versionNegotiation.test.ts +++ b/packages/client/test/client/versionNegotiation.test.ts @@ -270,15 +270,19 @@ describe('auto mode against a legacy server (fallback)', () => { }); /* ------------------------------------------------------------------------- * - * Q12: timeout & retries are typed connect errors, never era verdicts. + * Probe timeout policy: transport-aware. On HTTP-class transports a timeout + * is a typed connect error (silence on a deployed server is an outage); on + * stdio it is a legacy-server signal and falls back to initialize on the same + * stream (the stdio transport's backward-compatibility rule — some legacy + * servers do not respond to unknown pre-initialize requests at all). * ------------------------------------------------------------------------- */ -describe('probe timeout policy (Q12)', () => { +describe('probe timeout policy (transport-aware)', () => { const silentScript: Script = () => { /* never replies */ }; - test('timeout rejects with the standard typed timeout error and is NEVER converted to a legacy verdict', async () => { + test('HTTP-class transport: timeout rejects with the standard typed timeout error and is never converted to a legacy verdict', async () => { const transport = new ScriptedTransport(silentScript); const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 50 } } }); @@ -309,6 +313,58 @@ describe('probe timeout policy (Q12)', () => { expect(new Set(ids).size).toBe(3); expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); }); + + /** A stdio-shaped transport: structurally recognizable by its stderr/pid accessors. */ + class StdioShapedTransport extends ScriptedTransport { + get stderr(): null { + return null; + } + get pid(): number { + return 4242; + } + } + + test('stdio-class transport: a server that never answers the probe is a legacy server — initialize fallback on the same stream', async () => { + // A silent legacy stdio server: ignores the unknown server/discover + // request entirely, but answers initialize like any 2025 server. + const silentLegacyScript: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'initialize') { + legacyServerScript(message, t); + } + // Anything else (the probe) is ignored — no reply at all. + }; + + const transport = new StdioShapedTransport(silentLegacyScript); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 30, maxRetries: 1 } } } + ); + + await client.connect(transport); + + // The probe was re-sent per maxRetries, then the timeout resolved to the + // legacy verdict and the initialize fallback ran on the SAME transport. + const sent = requests(transport.sent); + expect(sent.filter(r => r.method === 'server/discover')).toHaveLength(2); + expect(sent.some(r => r.method === 'initialize')).toBe(true); + expect(client.getProtocolEra()).toBe('legacy'); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + + await client.close(); + }); + + test('stdio-class transport: pin mode still fails loudly on a silent server (no fallback)', async () => { + const transport = new StdioShapedTransport(() => { + /* never replies */ + }); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN }, probe: { timeoutMs: 30 } } }); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); }); /* ------------------------------------------------------------------------- * diff --git a/test/integration/test/client/versionNegotiation.test.ts b/test/integration/test/client/versionNegotiation.test.ts index c5e60b3679..ad93e5c853 100644 --- a/test/integration/test/client/versionNegotiation.test.ts +++ b/test/integration/test/client/versionNegotiation.test.ts @@ -9,14 +9,17 @@ * checked BEFORE version). * * Plus: structural fallback hygiene (the auto client's post-probe traffic is - * byte-identical to a plain legacy client's, zero 2026 headers), and the Q12 - * typed connect errors for outage and timeout. + * byte-identical to a plain legacy client's, zero 2026 headers), the typed + * connect errors for outage and HTTP timeout, and the stdio timeout fallback + * (a silent legacy stdio server is detected by the probe timing out and the + * client falls back to initialize on the same pipe). */ import { randomUUID } from 'node:crypto'; import type { Server } from 'node:http'; import { createServer } from 'node:http'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import { McpServer } from '@modelcontextprotocol/server'; @@ -223,3 +226,65 @@ describe('typed connect errors (Q12) over real sockets', () => { await new Promise(resolve => setTimeout(resolve, 50)); }, 15_000); }); + +describe('stdio: silent legacy server (probe timeout fallback)', () => { + // The stdio transport's backward-compatibility rule: a probe that gets no + // response within a reasonable timeout indicates a legacy server — some + // legacy servers do not respond to unknown pre-initialize requests at all + // — and the client falls back to initialize on the same pipe. (On HTTP, + // by contrast, a timeout stays a typed connect error; see the test above.) + const SILENT_LEGACY_SERVER_SCRIPT = String.raw` + let buffer = ''; + process.stdin.on('data', chunk => { + buffer += chunk.toString(); + let index; + while ((index = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, index); + buffer = buffer.slice(index + 1); + if (line.trim() === '') continue; + let message; + try { + message = JSON.parse(line); + } catch { + continue; + } + // A legacy server that simply ignores unknown pre-initialize + // requests (server/discover gets NO reply at all) but answers + // the initialize handshake normally. + if (message.method === 'initialize' && message.id !== undefined) { + process.stdout.write( + JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: '2025-11-25', + capabilities: {}, + serverInfo: { name: 'silent-legacy-stdio-server', version: '1.0.0' } + } + }) + '\n' + ); + } + } + }); + `; + + it('auto mode: the probe times out, the client falls back to initialize on the same pipe and connects on the legacy era', async () => { + const transport = new StdioClientTransport({ + command: process.execPath, + args: ['-e', SILENT_LEGACY_SERVER_SCRIPT] + }); + const client = new Client( + { name: 'neg-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 500, maxRetries: 1 } } } + ); + + try { + await client.connect(transport); + expect(client.getProtocolEra()).toBe('legacy'); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + expect(client.getServerVersion()?.name).toBe('silent-legacy-stdio-server'); + } finally { + await client.close(); + } + }, 15_000); +}); From 8589cb70c4aa1a0659b920017322d49daf6e7f78 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 15 Jun 2026 17:17:26 +0000 Subject: [PATCH 11/19] refactor(core): review follow-ups for the era seams Five small follow-ups from review of the per-era codec work, landed with the negotiation surface they relate to: - pin the outbound era gate in _requestWithSchema (an explicit result schema never smuggles an era-deleted method onto the wire) - the -32004 protocol-version-mismatch rejection's data.requested now echoes the exact revision the edge classification named, falling back to the wire era label only when no revision was carried; pinned in the era-gate suite - note at the same site that data.supported needs a second look once instances are bound to the modern era at the server entry (the configured list may not name the bound revision); behavior unchanged - refresh the bootstrap module header: the outbound pins apply while the instance's negotiated protocol version is unset, and inbound era truth is the instance state a classification is validated against (the previous wording still described per-request codec selection) - drop MessageClassification.envelope: nothing produces or consumes it; the classifying entry can reintroduce it together with its consumer --- packages/core/src/shared/protocol.ts | 14 +++++++-- packages/core/src/types/types.ts | 8 ----- packages/core/src/wire/bootstrap.ts | 35 ++++++++++++---------- packages/core/test/wire/eraGates.test.ts | 37 ++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 26 deletions(-) diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index f9b9555171..4c665fd4c0 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -732,13 +732,21 @@ export abstract class Protocol { `Era mismatch on inbound request '${request.method}': classified as ${classified} but this instance serves ${codec.era}` ) ); - sendErrorResponse(ProtocolErrorCode.UnsupportedProtocolVersion, `Unsupported protocol version: ${classified}`, { + // `requested` echoes the protocol version the classification + // actually named when it carried one; the wire-era label is + // only the fallback for classifications without an exact + // revision. + const requested = extra.classification.revision ?? classified; + sendErrorResponse(ProtocolErrorCode.UnsupportedProtocolVersion, `Unsupported protocol version: ${requested}`, { // Per spec, `supported` is the full list of protocol // versions the receiver supports — not just the version // this connection is on — so the peer can pick a mutually - // supported version from the error alone. + // supported version from the error alone. (Revisit when + // instances are bound to the modern era at the entry: a + // bound instance's configured list may not name the + // revision it was bound to.) supported: this._supportedProtocolVersions, - requested: classified + requested }); return; } diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index cc5d8da8de..9af48586c2 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -631,14 +631,6 @@ export interface MessageClassification { * The exact protocol revision, when the classifier derived one. */ revision?: string; - - /** - * The per-request `_meta` envelope, when the classifier extracted it. - * Partial: whichever reserved keys the message actually carried — - * envelope requiredness is enforced per request at dispatch time, not at - * the classifying edge. - */ - envelope?: Partial; } /** diff --git a/packages/core/src/wire/bootstrap.ts b/packages/core/src/wire/bootstrap.ts index 3f54029328..5ae43bb39d 100644 --- a/packages/core/src/wire/bootstrap.ts +++ b/packages/core/src/wire/bootstrap.ts @@ -1,23 +1,28 @@ /** * Static era pins for lifecycle messages on the OUTBOUND path (the - * chicken-and-egg bootstrap): these messages are sent before any negotiated - * version exists, and they self-identify their era by construction — - * `initialize`/`notifications/initialized` ARE the legacy handshake (Q2: - * `initialize` ⇒ legacy), and `server/discover` exists only on the 2026 era. - * No negotiated-state guess ever picks a payload schema for them. + * chicken-and-egg bootstrap): these messages are sent while the instance's + * negotiated protocol version is still unset, and they self-identify their + * era by construction — `initialize`/`notifications/initialized` ARE the + * legacy handshake (`initialize` ⇒ legacy), and `server/discover` exists only + * on the 2026 era. The pins apply only during that pre-negotiation window + * (`Protocol._resolveOutboundCodec` consults them when the negotiated version + * is `undefined`); once a version is negotiated, every send resolves through + * the instance's era. * * Scope notes: - * - OUTBOUND ONLY. Inbound era truth is per-request classification (Q2) with - * session state as fallback — pinning inbound would override the - * classifier (an unclassified `server/discover` request classifies legacy - * and correctly falls to −32601 by registry absence). + * - OUTBOUND ONLY. Inbound era truth is the instance's negotiated protocol + * version (connection state); an edge classification, when present, is + * VALIDATED against that instance era — never used to pick a codec per + * message — so pinning inbound would have nothing to attach to. An + * inbound `server/discover` on a legacy-era instance correctly falls to + * −32601 by registry absence; serving it requires an instance bound to + * the modern era. * - `ping` is deliberately NOT pinned. A bare `{method: 'ping'}` carries no - * era marker — under Q2 it classifies legacy by DEFAULT, not by - * self-identification — and pinning it would let a negotiated-modern - * session emit a 2025-only method onto the modern leg (the exact inverse - * leak registry membership exists to prevent). `ping` era-gates like any - * other method: present on the 2025 era, absent from the 2026 era (the - * modern keepalive story is owned by the negotiation milestones). + * era marker, and pinning it would let a negotiated-modern session emit a + * 2025-only method onto the modern leg (the exact inverse leak registry + * membership exists to prevent). `ping` era-gates like any other method: + * present on the 2025 era, absent from the 2026 era (the modern keepalive + * story is owned by the negotiation milestones). */ import type { WireCodec } from './codec.js'; import { codecForVersion, MODERN_WIRE_REVISION } from './codec.js'; diff --git a/packages/core/test/wire/eraGates.test.ts b/packages/core/test/wire/eraGates.test.ts index 97d2ba3313..8776ee69c0 100644 --- a/packages/core/test/wire/eraGates.test.ts +++ b/packages/core/test/wire/eraGates.test.ts @@ -418,6 +418,25 @@ describe('the edge→instance handoff — classification is validated, never an expect(h.errors.some(e => e.message.includes('Era mismatch'))).toBe(true); }); + test('the rejection’s data.requested names the exact revision the classification carried, not just the era label', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler('tools/list', () => ({ tools: [] })); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} } as JSONRPCMessage, { + era: 'legacy', + revision: '2025-06-18' + }); + await h.flush(); + + const error = errorOf(h.sent[0]) as { code: number; data?: { requested?: string } } | undefined; + expect(error?.code).toBe(-32004); + expect(error?.data?.requested).toBe('2025-06-18'); + }); + test('a modern-classified notification on a legacy-era instance is dropped, with onerror', async () => { let delivered = 0; const h = await harness({ @@ -498,6 +517,24 @@ describe('outbound era gates — typed local error before the transport', () => expect(h.sent).toHaveLength(0); }); + test('_requestWithSchema applies the same outbound era gate: an explicit schema never smuggles a deleted method', async () => { + const h = await harness({ era: '2026-07-28' }); + const requestWithSchema = ( + h.receiver as unknown as { + _requestWithSchema: (request: { method: string }, schema: unknown) => Promise; + } + )._requestWithSchema.bind(h.receiver); + + expect(() => requestWithSchema({ method: 'ping' }, z.object({}))).toThrow(SdkError); + try { + requestWithSchema({ method: 'ping' }, z.object({})); + } catch (error) { + expect((error as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); + expect((error as SdkError).data).toMatchObject({ method: 'ping', era: '2026-07-28' }); + } + expect(h.sent).toHaveLength(0); + }); + test('pre-negotiation bootstrap pins still route initialize to the 2025 era', async () => { // An instance with NO negotiated version may always send the legacy // handshake; setting a modern version afterwards closes it (the pin From bb23780d9aff32c9c0fd278a51e5c298e1b5a4ea Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 11 Jun 2026 09:49:55 +0000 Subject: [PATCH 12/19] test(e2e): activate the 2026-07-28 spec-version matrix axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 2026-07-28 to ALL_SPEC_VERSIONS so every requirement registers cells on both spec versions. The cells that previously asserted a raw 2026-07-28 echo out of the initialize handshake are reshaped to assert the negotiated behavior instead — initialize never negotiates a 2026-era revision, so: - transport:standalone:raw-relay and hosting:express:adapter-host-header-validation expect a raw initialize naming 2026-07-28 to be answered with the server's latest legacy version - transport:custom:client-connect completes the 2026-era handshake over the consumer transport via server/discover (the client opts in through versionNegotiation), keeping the initialize exchange for 2025-era cells - the raw-result-type modern arms negotiate the draft revision through the real versionNegotiation path (the relay advertises it via server/discover) instead of widening supportedProtocolVersions and echoing initialize No spec-version-scoped knownFailures remain: the six entries that tracked the raw-echo expectation are removed, and 2025-era cells keep their existing results unchanged. --- test/e2e/requirements.ts | 7 +-- test/e2e/scenarios/hosting-express.test.ts | 7 ++- test/e2e/scenarios/protocol.test.ts | 63 ++++++++++++++++------ test/e2e/scenarios/raw-result-type.test.ts | 28 ++++++++-- test/e2e/scenarios/transport-raw.test.ts | 32 +++++++++-- test/e2e/types.ts | 2 +- 6 files changed, 110 insertions(+), 29 deletions(-) diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 750b8e537b..7f5d68077e 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2193,7 +2193,7 @@ export const REQUIREMENTS: Record = { behavior: 'An app created by createMcpExpressApp() with the default localhost host applies DNS-rebinding protection: a request whose Host header is not an allowed local host is rejected with 403 before reaching the MCP transport.', transports: ['streamableHttp'], - note: 'This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. The allowed-host control asserts initialize semantics per spec version: a 2026-era request is answered with the latest legacy version, since 2026-era revisions are never negotiated via initialize.' }, 'custom-methods:server-handler:roundtrip': { source: 'sdk', @@ -2384,14 +2384,15 @@ export const REQUIREMENTS: Record = { 'transport:standalone:raw-relay': { source: 'sdk', behavior: - 'Client and server transports can be driven directly (start/send/onmessage/onclose/onerror) without wrapping them in a Client or Server, supporting message-relay proxies.' + 'Client and server transports can be driven directly (start/send/onmessage/onclose/onerror) without wrapping them in a Client or Server, supporting message-relay proxies.', + note: 'Against real SDK servers the relayed initialize negotiates per initialize semantics: a 2026-era request is answered with the latest legacy version, since 2026-era revisions are never negotiated via initialize.' }, 'transport:custom:client-connect': { source: 'sdk', behavior: 'Client.connect accepts any consumer-implemented object satisfying the Transport interface and completes the handshake over it.', transports: ['inMemory'], - note: 'The test supplies its own custom Transport implementation, so the matrix transport arg is ignored; it runs as a single inMemory-labelled cell to avoid duplicate runs.' + note: 'The test supplies its own custom Transport implementation, so the matrix transport arg is ignored; it runs as a single inMemory-labelled cell to avoid duplicate runs. On 2026-era cells the handshake is the server/discover negotiation (opted into via versionNegotiation); on 2025-era cells it is the plain initialize exchange.' }, 'protocol:transport-callbacks:wrappable-after-connect': { source: 'sdk', diff --git a/test/e2e/scenarios/hosting-express.test.ts b/test/e2e/scenarios/hosting-express.test.ts index fbc9851c5e..f4a3141a78 100644 --- a/test/e2e/scenarios/hosting-express.test.ts +++ b/test/e2e/scenarios/hosting-express.test.ts @@ -24,7 +24,7 @@ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/cli import { createMcpExpressApp, mcpAuthMetadataRouter, requireBearerAuth } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { OAuthMetadata } from '@modelcontextprotocol/server'; -import { McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; +import { LATEST_PROTOCOL_VERSION, McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; import type { Express, RequestHandler } from 'express'; import express from 'express'; import { expect } from 'vitest'; @@ -475,13 +475,16 @@ verifies('hosting:express:adapter-host-header-validation', async ({ protocolVers expect(mcpRouteHits).toBe(0); // Control: the identical request with the real localhost Host reaches the transport and initializes normally. + // The negotiated version follows initialize semantics: a 2026-era request is answered with the latest legacy + // version (2026-era revisions are never negotiated via initialize); legacy requests are echoed back. + const negotiatedVersion = protocolVersion >= '2026-07-28' ? LATEST_PROTOCOL_VERSION : protocolVersion; const allowed = await postWithHost(new URL('/mcp', baseUrl), `127.0.0.1:${baseUrl.port}`, initializeBody); expect(allowed.status).toBe(200); const allowedJson: unknown = JSON.parse(allowed.body); expect(allowedJson).toMatchObject({ jsonrpc: '2.0', id: 1, - result: { protocolVersion, serverInfo: { name: 'rebind-protected-server', version: '1.0.0' } } + result: { protocolVersion: negotiatedVersion, serverInfo: { name: 'rebind-protected-server', version: '1.0.0' } } }); expect(mcpRouteHits).toBe(1); } finally { diff --git a/test/e2e/scenarios/protocol.test.ts b/test/e2e/scenarios/protocol.test.ts index 40b5a20af3..4cb0406763 100644 --- a/test/e2e/scenarios/protocol.test.ts +++ b/test/e2e/scenarios/protocol.test.ts @@ -1535,16 +1535,40 @@ class LoopbackTransport implements Transport { this.events.push('method' in message ? `send:${message.method}` : 'send:response'); if (!isRequest(message)) return; this.clientRequests.push(message); - if (message.method === 'initialize') { - this.respond(message.id, { - protocolVersion: this.serverProtocolVersion, - capabilities: { tools: {} }, - serverInfo: { name: 'loopback-server', version: '3.1.4' } - }); - } else if (message.method === 'tools/list') { - this.respond(message.id, { - tools: [{ name: 'lookup_order', description: 'Look up an order by id', inputSchema: { type: 'object' } }] - }); + const modern = this.serverProtocolVersion >= '2026-07-28'; + switch (message.method) { + case 'initialize': { + this.respond(message.id, { + protocolVersion: this.serverProtocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: 'loopback-server', version: '3.1.4' } + }); + break; + } + case 'server/discover': { + // The 2026-era handshake: advertise the canned identity instead of + // answering an initialize exchange. + this.respond(message.id, { + supportedVersions: [this.serverProtocolVersion], + capabilities: { tools: {} }, + serverInfo: { name: 'loopback-server', version: '3.1.4' } + }); + break; + } + case 'tools/list': { + const tools = [{ name: 'lookup_order', description: 'Look up an order by id', inputSchema: { type: 'object' } }]; + this.respond( + message.id, + modern + ? // The 2026 wire shape carries the result discriminator and the cacheable-result fields. + ({ resultType: 'complete', ttlMs: 0, cacheScope: 'public', tools } as unknown as Result) + : { tools } + ); + break; + } + default: { + break; + } } } @@ -1560,29 +1584,38 @@ class LoopbackTransport implements Transport { verifies('transport:custom:client-connect', async ({ protocolVersion }: TestArgs) => { // The body supplies its own consumer-implemented Transport, so the matrix transport arg is unused by design. + // On 2025-era cells the handshake is the plain initialize exchange; on 2026-era cells it is the + // server/discover negotiation (a 2026 revision is never negotiated via initialize), which the client opts + // into by pinning the cell's revision. + const modern = protocolVersion >= '2026-07-28'; const customTransport = new LoopbackTransport(protocolVersion); - const client = newClient(); + const client = modern + ? new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: protocolVersion } } }) + : newClient(); const clientOnclose = vi.fn(); client.onclose = clientOnclose; + const handshake = modern ? ['send:server/discover'] : ['send:initialize', 'send:notifications/initialized']; + const handshakeRequests = modern ? ['server/discover'] : ['initialize']; try { await client.connect(customTransport); - // Protocol installed its callbacks on the consumer object before invoking start(). + // Connect installed callbacks on the consumer object before invoking start(). expect(customTransport.callbacksPresentAtStart).toEqual({ onmessage: true, onclose: true, onerror: true }); // The full handshake ran over the consumer transport, and its canned identity is what the client now reports. - expect(customTransport.events).toEqual(['start', 'send:initialize', 'send:notifications/initialized']); + expect(customTransport.events).toEqual(['start', ...handshake]); expect(client.getServerCapabilities()).toEqual({ tools: {} }); expect(client.getServerVersion()).toEqual({ name: 'loopback-server', version: '3.1.4' }); + expect(client.getNegotiatedProtocolVersion()).toBe(protocolVersion); // A post-handshake request round-trips through the consumer transport's send(). const listed = await client.listTools(); expect(listed.tools).toEqual([{ name: 'lookup_order', description: 'Look up an order by id', inputSchema: { type: 'object' } }]); - expect(customTransport.clientRequests.map(m => m.method)).toEqual(['initialize', 'tools/list']); + expect(customTransport.clientRequests.map(m => m.method)).toEqual([...handshakeRequests, 'tools/list']); await client.close(); // close() reached the consumer transport, and its onclose callback fed back into the client's close handling. - expect(customTransport.events).toEqual(['start', 'send:initialize', 'send:notifications/initialized', 'send:tools/list', 'close']); + expect(customTransport.events).toEqual(['start', ...handshake, 'send:tools/list', 'close']); expect(clientOnclose).toHaveBeenCalledTimes(1); expect(client.transport).toBeUndefined(); } finally { diff --git a/test/e2e/scenarios/raw-result-type.test.ts b/test/e2e/scenarios/raw-result-type.test.ts index 35956810fe..ee5b454763 100644 --- a/test/e2e/scenarios/raw-result-type.test.ts +++ b/test/e2e/scenarios/raw-result-type.test.ts @@ -4,7 +4,9 @@ * postures are ruled per era by Q1-SD3). * * A raw relay server (no SDK Server involved) answers tools/call with hand - * built bodies. The negotiated protocol version selects the wire era: + * built bodies. The negotiated protocol version selects the wire era; the + * modern arms negotiate it through the real path (versionNegotiation + + * server/discover — a 2026 revision is never negotiated via initialize): * * - Negotiated 2026-07-28: `resultType` is the REQUIRED discriminator. An * `input_required` body surfaces the discriminated kind as a typed local @@ -55,6 +57,15 @@ function makeResponder(toolCallBody: unknown) { const requested = (request.params as { protocolVersion?: string } | undefined)?.protocolVersion ?? LATEST_PROTOCOL_VERSION; return initializeResult(requested); } + if (request.method === 'server/discover') { + // The modern handshake: the relay advertises the draft revision so a + // negotiating client selects it (no initialize on that path). + return { + supportedVersions: ['2026-07-28'], + capabilities: { tools: {} }, + serverInfo: { name: 'raw-input-required-server', version: '0' } + }; + } if (request.method === 'tools/call') return toolCallBody; return {}; }; @@ -116,10 +127,14 @@ verifies('typescript:client:raw-result-type-first', async ({ transport }: TestAr } } - // ---- Modern negotiation: the client opts into the draft revision, the - // relay echoes it back → 2026 era → V-1 discrimination in the codec. ---- + // ---- Modern negotiation: the client pins the draft revision, the relay + // advertises it via server/discover → 2026 era → V-1 discrimination in + // the codec. ---- { - const client = new Client({ name: 'raw-result-type-client', version: '0' }, { supportedProtocolVersions: ['2026-07-28'] }); + const client = new Client( + { name: 'raw-result-type-client', version: '0' }, + { versionNegotiation: { mode: { pin: '2026-07-28' } } } + ); await (transport === 'inMemory' ? connectInMemory(client, INPUT_REQUIRED_BODY) : connectStreamableHttp(client, INPUT_REQUIRED_BODY)); @@ -141,7 +156,10 @@ verifies('typescript:client:raw-result-type-first', async ({ transport }: TestAr // surfaced as a typed error naming it (Q1-SD3 i — the absent⇒complete // bridge applies only to earlier-revision servers). ---- { - const client = new Client({ name: 'raw-result-type-client', version: '0' }, { supportedProtocolVersions: ['2026-07-28'] }); + const client = new Client( + { name: 'raw-result-type-client', version: '0' }, + { versionNegotiation: { mode: { pin: '2026-07-28' } } } + ); await (transport === 'inMemory' ? connectInMemory(client, ABSENT_RESULT_TYPE_BODY) : connectStreamableHttp(client, ABSENT_RESULT_TYPE_BODY)); diff --git a/test/e2e/scenarios/transport-raw.test.ts b/test/e2e/scenarios/transport-raw.test.ts index 5645df0181..bef7300943 100644 --- a/test/e2e/scenarios/transport-raw.test.ts +++ b/test/e2e/scenarios/transport-raw.test.ts @@ -17,7 +17,12 @@ import { fileURLToPath } from 'node:url'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -import { CallToolResultSchema, InitializeResultSchema, JSONRPCResultResponseSchema } from '@modelcontextprotocol/core'; +import { + CallToolResultSchema, + InitializeResultSchema, + JSONRPCResultResponseSchema, + LATEST_PROTOCOL_VERSION +} from '@modelcontextprotocol/core'; import type { JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/server'; import { InMemoryTransport, McpServer } from '@modelcontextprotocol/server'; import { expect, vi } from 'vitest'; @@ -45,6 +50,17 @@ function initializeRequest(id: number, protocolVersion: string): JSONRPCRequest const INITIALIZED_NOTIFICATION: JSONRPCNotification = { jsonrpc: '2.0', method: 'notifications/initialized' }; +/** + * The protocol version a real SDK server negotiates for a raw `initialize` + * naming `requested`: 2026-era revisions are never negotiated via the legacy + * initialize handshake (they are only selected through `server/discover`), so + * the server answers with its latest legacy version instead of echoing the + * request. + */ +function expectedNegotiatedVersion(requested: string): string { + return requested >= '2026-07-28' ? LATEST_PROTOCOL_VERSION : requested; +} + /** Hand-built tools/call request for the echo tool exposed by both real servers used below. */ function echoCallRequest(id: number): JSONRPCRequest { return { jsonrpc: '2.0', id, method: 'tools/call', params: { name: 'echo', arguments: { text: 'relayed raw' } } }; @@ -158,7 +174,12 @@ async function rawRelayStdio(protocolVersion: string): Promise { await transport.send(initializeRequest(1, protocolVersion)); // Generous first wait: tsx compiles the fixture inside the freshly spawned child before it can answer. await vi.waitFor(() => expect(received).toHaveLength(1), { timeout: 10_000, interval: 25 }); - expectInitializeResponse(defined(received[0], 'initialize response'), 1, protocolVersion, 'stdio-echo-server'); + expectInitializeResponse( + defined(received[0], 'initialize response'), + 1, + expectedNegotiatedVersion(protocolVersion), + 'stdio-echo-server' + ); // Forward the rest of a relay's traffic by hand: initialized notification, then a tools/call. await transport.send(INITIALIZED_NOTIFICATION); @@ -206,7 +227,12 @@ async function rawRelayStreamableHttp(protocolVersion: string, stateless: boolea expect(records).toEqual([{ method: 'POST' }]); await vi.waitFor(() => expect(received).toHaveLength(1), { timeout: 5000, interval: 10 }); - expectInitializeResponse(defined(received[0], 'initialize response'), 1, protocolVersion, 'raw-relay-http-server'); + expectInitializeResponse( + defined(received[0], 'initialize response'), + 1, + expectedNegotiatedVersion(protocolVersion), + 'raw-relay-http-server' + ); // Forward the rest of a relay's traffic by hand: initialized notification, then a tools/call. await transport.send(INITIALIZED_NOTIFICATION); diff --git a/test/e2e/types.ts b/test/e2e/types.ts index c7ff6bdd80..d10ab18fca 100644 --- a/test/e2e/types.ts +++ b/test/e2e/types.ts @@ -14,7 +14,7 @@ export const KNOWN_SPEC_VERSIONS = ['2025-11-25', '2026-07-28'] as const; export type SpecVersion = (typeof KNOWN_SPEC_VERSIONS)[number]; /** The spec versions cells are registered for (the active matrix axis). */ -export const ALL_SPEC_VERSIONS = ['2025-11-25'] as const satisfies readonly SpecVersion[]; +export const ALL_SPEC_VERSIONS = ['2025-11-25', '2026-07-28'] as const satisfies readonly SpecVersion[]; /** * Arguments every test body receives. Expand with new matrix axes here so From e12218fe37b9e5b6bf855e3145f18b4a7e7636c5 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 15 Jun 2026 19:31:35 +0000 Subject: [PATCH 13/19] fix(client): satisfy the docs build for the negotiation module ProbeWindow is internal probe machinery used only within versionNegotiation.ts; drop its unused module export so typedoc does not document it (its exchange() signature referenced the non-exported RawProbeReply type, failing docs:check). Also reword the probe timeoutMs @default note to plain text so the constant reference resolves regardless of documentation context. --- packages/client/src/client/versionNegotiation.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/src/client/versionNegotiation.ts b/packages/client/src/client/versionNegotiation.ts index 0e92dca82b..68ee892415 100644 --- a/packages/client/src/client/versionNegotiation.ts +++ b/packages/client/src/client/versionNegotiation.ts @@ -56,7 +56,7 @@ export interface VersionNegotiationProbeOptions { /** * Timeout for a single probe exchange, in milliseconds. * - * @default the standard request timeout ({@linkcode DEFAULT_REQUEST_TIMEOUT_MSEC}, or the `timeout` passed to `connect()`) + * @default the standard request timeout (`DEFAULT_REQUEST_TIMEOUT_MSEC`, or the `timeout` passed to `connect()`) */ timeoutMs?: number; @@ -209,7 +209,7 @@ type RawProbeReply = * Protocol connect (which always starts its transport) takes over the * already-started channel without a double-start error. */ -export class ProbeWindow { +class ProbeWindow { /** Inbound messages dropped (with zero bytes written back) while the window was open. */ droppedInboundMessages = 0; From 0a6044cec4dd1bf714fa973598ae672bfcf1bd65 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 15 Jun 2026 19:53:24 +0000 Subject: [PATCH 14/19] refactor(core): make the negotiated protocol version a protected Protocol member Client and Server now read and assign Protocol's _negotiatedProtocolVersion field directly instead of going through the module-level accessor pair. The read accessor (negotiatedProtocolVersionOf) had no remaining callers and is removed; setNegotiatedProtocolVersion stays on the core internal barrel as the write channel for callers outside the class hierarchy (tests and the upcoming modern-era server entry). No behavior change. The field now appears as protected in the emitted declarations, which is the intended outcome. --- packages/client/src/client/client.ts | 24 ++++++------ packages/core/src/shared/protocol.ts | 56 ++++++++-------------------- packages/server/src/server/server.ts | 10 ++--- 3 files changed, 31 insertions(+), 59 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index a072935077..b905d5922a 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -53,14 +53,12 @@ import { LATEST_PROTOCOL_VERSION, ListChangedOptionsBaseSchema, mergeCapabilities, - negotiatedProtocolVersionOf, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, SdkError, - SdkErrorCode, - setNegotiatedProtocolVersion + SdkErrorCode } from '@modelcontextprotocol/core'; import type { ResolvedVersionNegotiation, VersionNegotiationOptions } from './versionNegotiation.js'; @@ -335,7 +333,7 @@ export class Client extends Protocol { // Era-exact validation: the schemas are resolved from the // instance era at dispatch time (the era gate guarantees the // method exists on the serving era before we get here). - const codec = codecForVersion(negotiatedProtocolVersionOf(this)); + const codec = codecForVersion(this._negotiatedProtocolVersion); const elicitRequestSchema = codec.requestSchema('elicitation/create'); // The era registry entry IS the plain ElicitResult schema // (the result map is aligned to the typed map — no widened @@ -398,7 +396,7 @@ export class Client extends Protocol { if (method === 'sampling/createMessage') { return async (request, ctx) => { // Era-exact validation via the instance era (see above). - const codec = codecForVersion(negotiatedProtocolVersionOf(this)); + const codec = codecForVersion(this._negotiatedProtocolVersion); const samplingRequestSchema = codec.requestSchema('sampling/createMessage'); if (!samplingRequestSchema) { throw new ProtocolError( @@ -484,7 +482,7 @@ export class Client extends Protocol { // Restore the protocol version negotiated during the original initialize handshake // so HTTP transports include the required mcp-protocol-version header, but skip re-init. if (transport.sessionId !== undefined) { - const negotiatedProtocolVersion = negotiatedProtocolVersionOf(this); + const negotiatedProtocolVersion = this._negotiatedProtocolVersion; if (negotiatedProtocolVersion !== undefined) { // Resuming keeps the original negotiation: the instance still // holds the negotiated version (and with it the wire era) — @@ -501,7 +499,7 @@ export class Client extends Protocol { // Without this, an instance that once negotiated a modern era could // never re-run a fresh handshake: `initialize` is physically absent // from the modern registry. (The resume branch above keeps it instead.) - setNegotiatedProtocolVersion(this, undefined); + this._negotiatedProtocolVersion = undefined; await this._legacyHandshake(transport, options); } @@ -556,7 +554,7 @@ export class Client extends Protocol { // Q1-SD1). Set AFTER the initialized notification: the initialize // EXCHANGE is the legacy handshake by definition and completes on // that era. - setNegotiatedProtocolVersion(this, result.protocolVersion); + this._negotiatedProtocolVersion = result.protocolVersion; // Set up list changed handlers now that we know server capabilities if (this._pendingListChangedConfig) { @@ -585,7 +583,7 @@ export class Client extends Protocol { // never re-probe mid-session. if (transport.sessionId !== undefined) { await super.connect(transport); - const negotiatedProtocolVersion = negotiatedProtocolVersionOf(this); + const negotiatedProtocolVersion = this._negotiatedProtocolVersion; if (negotiatedProtocolVersion !== undefined && transport.setProtocolVersion) { transport.setProtocolVersion(negotiatedProtocolVersion); } @@ -596,7 +594,7 @@ export class Client extends Protocol { // negotiated protocol version is connection state, and a value left // over from a previous connection must not survive into a new // negotiation. Every fresh negotiated connect re-runs the probe. - setNegotiatedProtocolVersion(this, undefined); + this._negotiatedProtocolVersion = undefined; let result: Awaited>; try { @@ -634,7 +632,7 @@ export class Client extends Protocol { // connection state (the same channel the legacy handshake completion // uses), and with it the wire era for everything this connection // sends/receives from here on. - setNegotiatedProtocolVersion(this, result.version); + this._negotiatedProtocolVersion = result.version; // After the era resolves modern, source per-request headers exactly the // way the legacy path does after initialize — the single // setProtocolVersion call site on this path. @@ -672,7 +670,7 @@ export class Client extends Protocol { * value to the new transport so it continues sending the required `mcp-protocol-version` header. */ getNegotiatedProtocolVersion(): string | undefined { - return negotiatedProtocolVersionOf(this); + return this._negotiatedProtocolVersion; } /** @@ -684,7 +682,7 @@ export class Client extends Protocol { * {@linkcode getNegotiatedProtocolVersion} exposes) — never persisted. */ getProtocolEra(): 'legacy' | 'modern' | undefined { - const version = negotiatedProtocolVersionOf(this); + const version = this._negotiatedProtocolVersion; if (version === undefined) { return undefined; } diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 4c665fd4c0..96f9aa67e8 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -379,36 +379,23 @@ type TimeoutInfo = { }; /* - * Package-internal access to Protocol's negotiated-protocol-version state. + * Package-internal write access to Protocol's negotiated-protocol-version state. * - * The negotiated version is a TS-private field on Protocol (it is connection - * state, not public surface — it never appears in the published declaration - * reports). The role classes (Client/Server), tests, and the modern-era - * server entry still need to read and write it at their lifecycle points, so - * Protocol's static initializer hands these module-scoped closures privileged - * access and the two functions below re-export them on the core INTERNAL - * barrel only. This is the F-2-style package-internal hook — deliberately not - * public API. + * The negotiated version is a protected field on Protocol that the role classes + * (Client/Server) assign directly. Tests and the modern-era server entry still + * need to set it from outside the class hierarchy, so Protocol's static + * initializer hands this module-scoped closure privileged access and + * `setNegotiatedProtocolVersion` re-exports it on the core INTERNAL barrel + * only — deliberately not public API. */ -let readNegotiatedProtocolVersion: (instance: Protocol) => string | undefined; let writeNegotiatedProtocolVersion: (instance: Protocol, version: string | undefined) => void; -/** - * Package-internal read channel for the protocol version a {@linkcode Protocol} - * instance has negotiated (`undefined` before negotiation). Exported on the - * core internal barrel only — never public API. - */ -export function negotiatedProtocolVersionOf(instance: Protocol): string | undefined { - return readNegotiatedProtocolVersion(instance); -} - /** * Package-internal write channel for a {@linkcode Protocol} instance's - * negotiated protocol version — the single era set/clear point outside the - * class itself. Called by `Client.connect` (fresh-connect clear + handshake - * completion), `Server._oninitialize`, tests, and the (future) modern-era - * server entry when it marks a factory instance modern at binding time. - * Exported on the core internal barrel only — never public API. + * negotiated protocol version, for callers outside the class hierarchy: + * tests and the (future) modern-era server entry that marks a factory + * instance modern at binding time. Exported on the core internal barrel + * only — never public API. */ export function setNegotiatedProtocolVersion( instance: Protocol, @@ -436,25 +423,14 @@ export abstract class Protocol { private _pendingDebouncedNotifications = new Set(); /** - * The protocol version negotiated for the current connection — the single - * source of truth for the wire era this instance speaks (Q1-SD1: the - * negotiated version cashes out as the negotiated wire ERA). - * - * Ordinary connection state, no side tables: - * - `Client.connect` clears it at the start of a fresh connect (the - * handshake itself runs pre-negotiation) and sets it once the handshake - * completes; the resume path keeps the original negotiation. - * - `Server._oninitialize` sets it when answering the legacy handshake; - * modern-era server instances get it set at instance binding through - * the package-internal hook ({@linkcode setNegotiatedProtocolVersion}). - * - * `undefined` = not negotiated yet: outbound lifecycle messages ride the - * bootstrap method pins and everything else defaults to the legacy era. + * The protocol version negotiated for the current connection (`undefined` + * before negotiation completes), which determines the wire era this + * instance speaks. Set by the SDK's negotiation and initialize paths + * (`Client.connect`, `Server._oninitialize`). */ - private _negotiatedProtocolVersion?: string; + protected _negotiatedProtocolVersion?: string; static { - readNegotiatedProtocolVersion = instance => instance._negotiatedProtocolVersion; writeNegotiatedProtocolVersion = (instance, version) => { instance._negotiatedProtocolVersion = version; }; diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 771d61e425..6f1aeb6da9 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -43,14 +43,12 @@ import { LoggingLevelSchema, mergeCapabilities, modernProtocolVersions, - negotiatedProtocolVersionOf, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, SdkError, - SdkErrorCode, - setNegotiatedProtocolVersion + SdkErrorCode } from '@modelcontextprotocol/core'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; @@ -218,7 +216,7 @@ export class Server extends Protocol { // Era-exact validation: the request and result schemas come from // the instance era, resolved at dispatch time (the era gate // guarantees tools/call exists on the serving era). - const codec = codecForVersion(negotiatedProtocolVersionOf(this)); + const codec = codecForVersion(this._negotiatedProtocolVersion); const callToolRequestSchema = codec.requestSchema('tools/call'); // The era registry entry IS the plain CallToolResult schema (the // result map is aligned to the typed map — no widened unions), @@ -414,7 +412,7 @@ export class Server extends Protocol { // The negotiated version is the instance's connection state — it IS // the wire-era selection for everything this instance sends and // receives from here on (legacy handshake ⇒ a legacy-era version). - setNegotiatedProtocolVersion(this, protocolVersion); + this._negotiatedProtocolVersion = protocolVersion; this.transport?.setProtocolVersion?.(protocolVersion); return { @@ -464,7 +462,7 @@ export class Server extends Protocol { * `undefined` before initialization. */ getNegotiatedProtocolVersion(): string | undefined { - return negotiatedProtocolVersionOf(this); + return this._negotiatedProtocolVersion; } /** From fa5230a737bd7eb25601673eda6ad4609f890554 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 15 Jun 2026 20:09:29 +0000 Subject: [PATCH 15/19] docs(client): slim comments in the version negotiation modules Comment-only pass over the negotiation source: compress multi-paragraph module headers and narrative blocks into short statements of the non-obvious constraints (the conservative fallback principle, the never-via-initialize rule, the stdio-vs-HTTP timeout rationale, the probe-window start() handover), and drop prose that restated the adjacent code. Public API JSDoc (the versionNegotiation option types, ClientOptions.versionNegotiation, Client.discover()) is unchanged. No code changes. --- packages/client/src/client/client.ts | 46 ++--- packages/client/src/client/probeClassifier.ts | 167 ++++++------------ packages/client/src/client/streamableHttp.ts | 15 +- .../client/src/client/versionNegotiation.ts | 106 ++++------- packages/core/src/shared/protocolEras.ts | 53 ++---- packages/server/src/server/server.ts | 47 ++--- 6 files changed, 134 insertions(+), 300 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index b905d5922a..33b9436e8c 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -504,13 +504,10 @@ export class Client extends Protocol { } /** - * The 2025 `initialize` handshake — the body of the plain legacy connect. - * Also the `'auto'`-mode fallback path, on the same connection: fallback is - * structurally this client's own plain legacy connect under identical - * options (same `initialize` body including the protocol version, zero - * 2026 headers). Callers clear the negotiated protocol version before the - * handshake (the fresh-connect clear), so the exchange rides the bootstrap - * pins; its completion sets the negotiated (legacy) version. + * The 2025 `initialize` handshake — the body of the plain legacy connect and + * the `'auto'`-mode fallback path (same connection, same `initialize` body, + * zero 2026 headers). Callers clear the negotiated protocol version before + * the handshake; its completion sets the negotiated (legacy) version. */ private async _legacyHandshake(transport: Transport, options?: RequestOptions): Promise { try { @@ -569,10 +566,9 @@ export class Client extends Protocol { } /** - * Negotiated connect (`versionNegotiation` mode `'auto'` or `{ pin }`): run - * the `server/discover` probe in the wiring layer before the Protocol - * machinery attaches, then either establish the modern era or perform the - * plain legacy handshake on the same connection. + * Negotiated connect (mode `'auto'` or `{ pin }`): probe with `server/discover` + * before the Protocol machinery attaches, then either establish the modern era + * or perform the plain legacy handshake on the same connection. */ private async _connectNegotiated( transport: Transport, @@ -590,10 +586,8 @@ export class Client extends Protocol { return; } - // Fresh connect: same property as the plain legacy path — the - // negotiated protocol version is connection state, and a value left - // over from a previous connection must not survive into a new - // negotiation. Every fresh negotiated connect re-runs the probe. + // Fresh connect: stale connection state must not survive into a new + // negotiation — every fresh negotiated connect re-runs the probe. this._negotiatedProtocolVersion = undefined; let result: Awaited>; @@ -616,11 +610,7 @@ export class Client extends Protocol { if (result.era === 'legacy') { // Conservative fallback: the plain legacy handshake on the SAME - // connection — structurally the same code path as a plain legacy - // connect (the transport version slot was never touched during the - // probe, so there is nothing to clear: zero 2026 headers by - // construction). The handshake's completion sets the negotiated - // (legacy) protocol version exactly like a plain connect. + // connection (the probe never touched the transport version slot). await this._legacyHandshake(transport, options); return; } @@ -628,22 +618,14 @@ export class Client extends Protocol { this._serverCapabilities = result.discover.capabilities; this._serverVersion = result.discover.serverInfo; this._instructions = result.discover.instructions; - // Modern selection: the negotiated protocol version is the instance's - // connection state (the same channel the legacy handshake completion - // uses), and with it the wire era for everything this connection - // sends/receives from here on. + // Modern selection: the same connection state the legacy handshake completion sets. this._negotiatedProtocolVersion = result.version; - // After the era resolves modern, source per-request headers exactly the - // way the legacy path does after initialize — the single - // setProtocolVersion call site on this path. + // The single setProtocolVersion call site on this path, mirroring the legacy path after initialize. if (transport.setProtocolVersion) { transport.setProtocolVersion(result.version); } - // The modern era has no notifications/initialized. List-changed handlers - // are still configured from the advertised capabilities (the discover - // advertisement excludes listChanged-class capabilities until the - // subscriptions/listen milestone lands, so this is a structural no-op - // today). + // The modern era has no notifications/initialized; list-changed handlers + // are configured straight from the advertised capabilities. if (this._pendingListChangedConfig) { this._setupListChangedHandlers(this._pendingListChangedConfig); this._pendingListChangedConfig = undefined; diff --git a/packages/client/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts index 55cf7fe382..ca46371948 100644 --- a/packages/client/src/client/probeClassifier.ts +++ b/packages/client/src/client/probeClassifier.ts @@ -1,36 +1,14 @@ /** - * Probe outcome classifier — the merged fallback table (pure module). + * Probe outcome classifier (pure module): maps the outcome of the connect-time + * `server/discover` probe onto one of four verdicts — modern era, the + * spec-mandated `-32004` corrective continuation, legacy fallback (the plain + * 2025 `initialize` handshake on the same connection), or a typed connect error. * - * Classifies the outcome of the version-negotiation probe (`server/discover` sent - * at connect time) into one of four verdicts: modern era (select a version), a - * spec-mandated corrective continuation (`-32004` with a mutual modern version), - * legacy fallback (perform the plain 2025 `initialize` handshake on the same - * connection), or a typed connect error. - * - * The classifier is deliberately **conservative**: anything it does not positively - * recognize as modern resolves to the legacy fallback. Network outage rejects with - * a typed connect error (never an era verdict). Timeouts are transport-aware: on - * stdio, a probe that nobody answers within the timeout indicates a legacy server - * — the stdio transport's backward-compatibility rule ("any other error, or does - * not respond within a reasonable timeout: the server is legacy"; some legacy - * servers do not respond to unknown pre-`initialize` requests at all) — and falls - * back to `initialize` on the same stream. On HTTP a deployed server answers, so - * silence is an outage, not a legacy signal: the timeout stays a typed connect - * error (the versioning compatibility matrix keys the HTTP legacy signal to a 4xx - * without a recognized modern error body, never to silence). - * - * **Scope: negotiation phase only.** These verdicts apply exclusively to the - * connect-time probe exchange. Once a connection's era is established as modern, a - * later unrecognized failure surfaces to the caller and is never re-classified - * into a silent demotion to `initialize`; the next fresh `connect()` re-runs - * negotiation from scratch. - * - * `-32001` and `-32003` are deliberately NOT probe-recognized in either direction: - * deployed servers still overload `-32001` for session-404 bodies and the draft - * error-code ladder for these cells is still being derived upstream (conformance - * #336), so both fall into the conservative "unrecognized → legacy" default. A - * conformant modern server never answers a well-formed discover with either code, - * so nothing is lost. + * The classifier is deliberately conservative: anything it does not positively + * recognize as modern resolves to the legacy fallback, and a network outage is a + * typed connect error, never an era verdict. The verdicts apply to the + * negotiation phase only — an established modern connection is never silently + * demoted to `initialize` by a later failure. */ import type { DiscoverResult } from '@modelcontextprotocol/core'; import { @@ -43,66 +21,42 @@ import { /** * The runtime environment the probe executed in. Only consulted for the - * network-failure row (F-7): in a browser, a CORS-preflight rejection against a - * deployed 2025 server surfaces as an opaque `TypeError` indistinguishable from an - * outage — but the legacy fallback carries no custom headers (no preflight), so it - * is treated as a definitive-enough legacy signal. In Node there is no CORS layer, - * so a network failure stays a typed connect error. + * network-failure row: a browser CORS-preflight rejection is treated as a + * legacy signal, while in Node a network failure stays a typed connect error. */ export type ProbeEnvironment = 'node' | 'browser'; /** - * The transport class the probe ran on. Only consulted for the timeout row: - * the specification's backward-compatibility rule for stdio treats a probe - * that gets no response within a reasonable timeout as a legacy-server signal - * (local pipes; some legacy servers do not respond to unknown - * pre-`initialize` requests at all), while on HTTP a deployed server answers, - * so silence is an outage — the HTTP legacy signal is a 4xx without a - * recognized modern error body. Anything that is not the stdio child-process - * transport is treated like HTTP (the conservative, typed-error posture). + * The transport class the probe ran on. Only consulted for the timeout row: a + * stdio probe that times out signals a legacy server, while an HTTP timeout + * stays a typed error. Anything that is not the stdio child-process transport + * is treated like HTTP. */ export type ProbeTransportKind = 'stdio' | 'http'; /** * A normalized probe outcome, produced by the connect-time wiring from the raw - * transport exchange. Wire-real inputs only — the wiring maps transport-thrown - * HTTP errors, network errors, in-band JSON-RPC responses, and timeouts onto - * these shapes. + * transport exchange. */ export type ProbeOutcome = - /** The probe request was answered with a JSON-RPC result. */ | { kind: 'result'; result: unknown } - /** The probe request was answered with a JSON-RPC error (any HTTP status, including 200-bodied errors and stdio in-band errors). */ + /** Answered with a JSON-RPC error (any HTTP status, including 200-bodied errors and stdio in-band errors). */ | { kind: 'rpc-error'; code: number; message: string; data?: unknown } /** The HTTP layer rejected the probe POST (non-2xx); `body` is the raw response text, when available. */ | { kind: 'http-error'; status: number; body?: string } - /** The probe send failed below HTTP (connection refused, DNS, reset, opaque fetch failure). */ | { kind: 'network-error'; error: unknown } /** No response arrived within the probe timeout, after all timeout re-sends. */ | { kind: 'timeout'; timeoutMs: number; attempts: number }; export interface ProbeClassifierContext { - /** - * Modern-era protocol versions this client can negotiate, in preference order. - * Never empty. - */ + /** Modern-era versions this client can negotiate, in preference order (never empty). */ clientModernVersions: readonly string[]; - /** - * The version the probe carried in its `_meta` envelope (used to synthesize - * `data.requested` on typed errors when the server omitted it). - */ + /** The version the probe carried in its `_meta` envelope (used to synthesize `data.requested` on typed errors). */ requestedVersion: string; /** - * Whether a legacy `initialize` fallback is possible. `false` for a - * modern-only client and for `pin` mode (no fallback, loud failure): rows - * whose action would be "initialize on the same connection" yield a typed - * `UnsupportedProtocolVersionError` (with synthesized data when needed) - * instead. - * - * Note this only affects the two *modern-evidence* rows (DiscoverResult with - * no overlap; `-32004` with a legacy-only list). The plain conservative rows - * (`-32601`, legacy 400 shapes, unrecognized) always return `legacy`; the - * caller maps that verdict per its negotiation mode. + * Whether a legacy `initialize` fallback is possible — `false` for a + * modern-only client and for `pin` mode, where rows that would otherwise + * fall back yield a typed `UnsupportedProtocolVersionError` instead. */ fallbackAvailable: boolean; /** See {@linkcode ProbeEnvironment}. */ @@ -115,10 +69,9 @@ export type ProbeVerdict = /** Definitive modern evidence: select `version` and continue without `initialize`. */ | { kind: 'modern'; version: string; discover: DiscoverResult } /** - * `-32004` with a mutual modern version: select-and-continue (re-send the - * probe at `version`). Spec-mandated corrective continuation — the caller - * runs it exactly once (even when `version` equals the just-rejected one) - * and arms a loop guard on the second rejection, throwing `error`. + * `-32004` with a mutual modern version: re-send the probe at `version`. + * Spec-mandated select-and-continue — the caller runs it exactly once and + * arms a loop guard on the second rejection, throwing `error`. */ | { kind: 'corrective'; version: string; error: UnsupportedProtocolVersionError } /** Definitive legacy signal or unrecognized shape: perform the plain legacy `initialize` handshake on the same connection. */ @@ -128,7 +81,11 @@ export type ProbeVerdict = /** The `-32004` UnsupportedProtocolVersion protocol error code (negotiation-phase recognition). */ const UNSUPPORTED_PROTOCOL_VERSION = -32_004; -/** Codes deliberately not probe-recognized (overloaded on deployed servers / ladder underived pending conformance #336). */ +/** + * Deliberately not probe-recognized in either direction: deployed servers + * overload `-32001` and the error-code ladder for these cells is still being + * derived upstream, so both fall into the conservative legacy default. + */ const NOT_PROBE_RECOGNIZED = new Set([-32_001, -32_003]); /** @@ -151,22 +108,14 @@ export function classifyProbeOutcome(outcome: ProbeOutcome, context: ProbeClassi } case 'timeout': { if (context.transportKind === 'stdio') { - // stdio: a probe nobody answers within the timeout (after all - // `maxRetries` re-sends) indicates a legacy server — the stdio - // transport's backward-compatibility rule says "any other - // error, or does not respond within a reasonable timeout: the - // server is legacy. Fall back to the `initialize` handshake." - // Some legacy stdio servers do not respond to unknown - // pre-initialize requests at all; the fallback runs on the - // same stream. + // Per the stdio transport's backward-compatibility rule, a probe + // nobody answers within the timeout indicates a legacy server — + // fall back to `initialize` on the same stream. return { kind: 'legacy' }; } - // HTTP (and anything that is not a local pipe): a deployed server - // answers, so silence is an outage, not a legacy signal — the - // timeout (standard request timeout, after all `maxRetries` - // re-sends) stays a typed connect error. Per the versioning - // compatibility matrix, the HTTP legacy signal is a 4xx response - // without a recognized modern error body. + // On HTTP a deployed server answers, so silence is an outage, not a + // legacy signal: keep the typed timeout error (the compatibility + // matrix keys the HTTP legacy signal to a 4xx, never to silence). return { kind: 'error', error: new SdkError( @@ -182,8 +131,7 @@ export function classifyProbeOutcome(outcome: ProbeOutcome, context: ProbeClassi function classifyResult(result: unknown, context: ProbeClassifierContext): ProbeVerdict { const parsed = DiscoverResultSchema.safeParse(result); if (!parsed.success) { - // 200-processed era-ambiguous first requests / any unrecognized result - // shape: not modern evidence — conservative legacy fallback. + // Unrecognized result shape: not modern evidence — conservative legacy fallback. return { kind: 'legacy' }; } const supportedVersions = parsed.data.supportedVersions; @@ -191,9 +139,8 @@ function classifyResult(result: unknown, context: ProbeClassifierContext): Probe if (overlap !== undefined) { return { kind: 'modern', version: overlap, discover: parsed.data }; } - // DiscoverResult with NO overlap is still modern evidence — but on a dual-era - // server it drives era SELECTION: initialize on the SAME connection when - // fallback is possible; otherwise a typed error with synthesized data. + // A DiscoverResult with no overlap still drives era selection: initialize on + // the same connection when fallback is possible, otherwise a typed error. if (context.fallbackAvailable) { return { kind: 'legacy' }; } @@ -209,8 +156,7 @@ function classifyRpcError(outcome: { code: number; message: string; data?: unkno if (code === UNSUPPORTED_PROTOCOL_VERSION) { const supported = parseSupportedList(data); if (supported === undefined) { - // -32004 without a valid `data.supported` list is not actionable - // modern evidence — conservative legacy fallback. + // -32004 without a valid data.supported list is not actionable modern evidence. return { kind: 'legacy' }; } const requested = parseRequested(data) ?? context.requestedVersion; @@ -218,52 +164,42 @@ function classifyRpcError(outcome: { code: number; message: string; data?: unkno const supportedModern = modernProtocolVersions(supported); const mutual = context.clientModernVersions.find(version => supportedModern.includes(version)); if (mutual !== undefined) { - // Mutual modern version: select-and-continue. MUST NOT fall back — - // a server that speaks -32004 with a version list is modern by - // definition (spec: "Do not fall back"). + // Mutual modern version: spec-mandated select-and-continue — never + // fall back to initialize here. return { kind: 'corrective', version: mutual, error }; } if (supportedModern.length > 0) { // Disjoint-but-modern list: typed error, never initialize. return { kind: 'error', error }; } - // Legacy-only list: definitive legacy signal → initialize; a modern-only - // client gets the typed error carrying `data.supported` instead. + // Legacy-only list: definitive legacy signal (typed error for a modern-only client). return context.fallbackAvailable ? { kind: 'legacy' } : { kind: 'error', error }; } if (NOT_PROBE_RECOGNIZED.has(code)) { - // -32001 / -32003: deliberately not probe-recognized in either direction - // (see module doc) — falls into the conservative default. return { kind: 'legacy' }; } - // Everything else is a definitive legacy signal or the conservative default: - // -32601 (method not found — never modern evidence on the probe, including - // 200-bodied errors), -32000 with the deployed "Unsupported protocol - // version" literal, -32000 free-text ("Server not initialized", - // session-required), `code: 0`, and any unrecognized code. + // Everything else — -32601, the deployed -32000 literals/free-text, code 0, + // any unrecognized code — is a legacy signal or the conservative default. return { kind: 'legacy' }; } function classifyHttpError(outcome: { status: number; body?: string }, context: ProbeClassifierContext): ProbeVerdict { - // HTTP-rejected probes (400/-32000, 400/-32004, …) carry their JSON-RPC error - // in the response body — classify the body exactly like an in-band error. + // HTTP-rejected probes carry their JSON-RPC error in the response body — classify it like an in-band error. const rpcError = parseJsonRpcErrorBody(outcome.body); if (rpcError !== undefined) { return classifyRpcError(rpcError, context); } - // Plain-text/unparseable 400, empty body, 406, or any other unrecognized - // status: conservative legacy fallback. + // Unparseable or unrecognized HTTP rejection: conservative legacy fallback. return { kind: 'legacy' }; } function classifyNetworkError(error: unknown, context: ProbeClassifierContext): ProbeVerdict { if (context.environment === 'browser' && isOpaqueFetchTypeError(error)) { - // F-7 (ruled Q12 exception, PROBE PHASE ONLY): a browser CORS-preflight - // rejection against a deployed 2025 server is an opaque `TypeError`; the - // legacy fallback carries no custom headers, so it proceeds where the - // probe could not. Node outage below stays a typed connect error. + // A browser CORS-preflight rejection against a deployed 2025 server is an + // opaque TypeError; the legacy fallback carries no custom headers (no + // preflight), so it can proceed where the probe could not. return { kind: 'legacy' }; } return { @@ -275,8 +211,7 @@ function classifyNetworkError(error: unknown, context: ProbeClassifierContext): } function isOpaqueFetchTypeError(error: unknown): boolean { - // Cross-realm safe: bundled or sandboxed fetch implementations may not share - // this realm's TypeError identity. + // Cross-realm safe: a bundled or sandboxed fetch may not share this realm's TypeError identity. return error instanceof TypeError || (error instanceof Error && error.name === 'TypeError'); } diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index a36f835c18..5dea9a7cc5 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -233,15 +233,12 @@ export class StreamableHTTPClientTransport implements Transport { } /** - * Body-derived per-request headers (protocol revision 2026-07-28): when a - * single outgoing request carries the protocol-version claim in its `_meta` - * envelope, the `MCP-Protocol-Version` and `Mcp-Method` headers derive from - * the message itself — the version negotiation probe is the first such - * sender. The connection-level version slot (`setProtocolVersion`, stamped - * after era resolution) is neither consulted nor mutated here; a body-derived - * claim takes precedence over the slot for its own request only. Messages - * without an envelope claim (all 2025-era traffic) are untouched, so no 2026 - * header can ever appear on a legacy exchange. + * Body-derived per-request headers: when an outgoing request carries a + * protocol-version claim in its `_meta` envelope (the version negotiation + * probe is the first such sender), `MCP-Protocol-Version` and `Mcp-Method` + * derive from the message itself. The connection-level version slot is + * neither consulted nor mutated; messages without an envelope claim are + * untouched, so no 2026 header can appear on a legacy exchange. */ private _applyBodyDerivedHeaders(headers: Headers, message: JSONRPCMessage | JSONRPCMessage[]): void { if (Array.isArray(message) || !isJSONRPCRequest(message)) { diff --git a/packages/client/src/client/versionNegotiation.ts b/packages/client/src/client/versionNegotiation.ts index 68ee892415..c810375cff 100644 --- a/packages/client/src/client/versionNegotiation.ts +++ b/packages/client/src/client/versionNegotiation.ts @@ -1,31 +1,15 @@ /** * Connect-time protocol version negotiation (opt-in via - * `ClientOptions.versionNegotiation`). + * `ClientOptions.versionNegotiation`): the option surface, the probe window (a + * raw transport exchange run before the Protocol machinery attaches), and the + * negotiation engine driving the pure {@linkcode classifyProbeOutcome} classifier. * - * This module owns the negotiation wiring that runs in the connect layer BEFORE - * the Protocol machinery engages: the option surface, the probe window (raw - * transport exchange with a string probe id), and the negotiation engine that - * drives the pure {@linkcode classifyProbeOutcome} classifier. - * - * Design invariants: - * - * - The probe never runs through the Protocol request machinery: it uses a string - * probe id (never colliding with Protocol's numeric ids) and consumes no message - * ids, so a legacy fallback's `initialize` is byte-equivalent to a plain legacy - * connect (same `id: 0`, same body, zero 2026 headers). - * - The probe is never the first real request — it is a dedicated - * `server/discover` exchange at connect time. - * - The transport's protocol-version slot is NEVER mutated during negotiation; - * probe headers derive from the probe message body itself (see the streamable - * HTTP transport's body-derived header stamping). `setProtocolVersion` is called - * exactly once, after the era resolves modern, the same way the legacy path - * calls it after `initialize`. - * - Probe-window guard: while the window is open, inbound messages that are not - * the probe response (e.g. a 2025-legal pre-initialization server→client request - * arriving mid-probe on a shared stdio pipe) are dropped with ZERO bytes written - * back. Such requests have no delivery guarantee mid-handshake; after the window - * closes, pre-init server→client traffic reaches Protocol dispatch exactly as it - * does today. + * Invariants: the probe uses string ids and consumes no Protocol message ids, so + * a legacy fallback's `initialize` is byte-equivalent to a plain legacy connect; + * the transport's protocol-version slot is never mutated during negotiation + * (probe headers derive from the probe message body) and is set exactly once + * after a modern resolution; while the probe window is open, inbound messages + * that are not the probe response are dropped with zero bytes written back. */ import type { ClientCapabilities, DiscoverResult, Implementation, JSONRPCRequest, Transport } from '@modelcontextprotocol/core'; import { @@ -115,12 +99,8 @@ export interface VersionNegotiationOptions { } /** - * The default negotiation mode when `versionNegotiation` (or its `mode`) is - * absent. - * - * The classifier and probe machine are default-agnostic: changing the v2 - * default (deferred sub-decision, ruled at the auto-negotiation milestone) is - * a flip of this single line. + * The default mode when `versionNegotiation` (or its `mode`) is absent; + * changing the default later is a flip of this single line. */ const DEFAULT_VERSION_NEGOTIATION_MODE: VersionNegotiationMode = 'legacy'; @@ -138,13 +118,9 @@ export type ResolvedVersionNegotiation = | { kind: 'pin'; version: string; probe: VersionNegotiationProbeOptions }; /** - * Resolve the negotiation options into a per-connect plan. - * - * @param options - the `ClientOptions.versionNegotiation` value - * @param supportedProtocolVersionsOption - the raw `supportedProtocolVersions` - * option as passed by the consumer (NOT the defaulted list): when it carries - * modern versions they become the offer list, and a list without any legacy - * version makes this a modern-only client (no fallback). + * Resolve the negotiation options into a per-connect plan. The raw (not + * defaulted) `supportedProtocolVersions` option supplies the modern offer list; + * a list without any legacy version makes this a modern-only client (no fallback). */ export function resolveVersionNegotiation( options: VersionNegotiationOptions | undefined, @@ -170,24 +146,17 @@ export function resolveVersionNegotiation( return { kind: 'auto', modernVersions, fallbackAvailable, probe }; } -/** - * Detect the probe environment for the F-7 browser row. Browser means a real - * window/document context (where fetch failures may be opaque CORS rejections); - * everything else — Node, workers — keeps typed-connect-error semantics for - * network failures. - */ +/** Detect the probe environment for the network-failure row — see {@linkcode ProbeEnvironment}. */ export function detectProbeEnvironment(): ProbeEnvironment { const g = globalThis as { window?: unknown; document?: unknown }; return g.window !== undefined && g.document !== undefined ? 'browser' : 'node'; } /** - * Detect the transport class for the timeout row's transport-aware verdict - * (see {@linkcode ProbeTransportKind}). The stdio child-process transport is - * recognized structurally (its `stderr`/`pid` accessors), so consumer - * subclasses and re-bundled copies are recognized without `instanceof`; - * everything else — HTTP, SSE, in-memory, custom transports — keeps the - * conservative typed-error posture for timeouts. + * Detect the transport class for the transport-aware timeout verdict (see + * {@linkcode ProbeTransportKind}). The stdio child-process transport is + * recognized structurally (`stderr`/`pid` accessors, no `instanceof` — safe + * across bundles); everything else is treated like HTTP. */ export function detectProbeTransportKind(transport: Transport): ProbeTransportKind { return 'stderr' in transport && 'pid' in transport ? 'stdio' : 'http'; @@ -201,13 +170,11 @@ type RawProbeReply = | { kind: 'timeout' }; /** - * The probe window: temporary ownership of a raw transport for the negotiation - * exchange, before the Protocol machinery attaches. - * - * `open()` installs the window's handlers and starts the transport; `release()` - * detaches them and arms a one-shot `start()` pass-through so the subsequent - * Protocol connect (which always starts its transport) takes over the - * already-started channel without a double-start error. + * Temporary ownership of a raw transport for the negotiation exchange, before + * the Protocol machinery attaches. `open()` installs the window's handlers and + * starts the transport; `release()` detaches them and arms a one-shot `start()` + * pass-through so the subsequent Protocol connect (which always starts its + * transport) takes over the already-started channel without a double-start error. */ class ProbeWindow { /** Inbound messages dropped (with zero bytes written back) while the window was open. */ @@ -254,9 +221,8 @@ class ProbeWindow { } /** - * Send one probe request and await its reply. Each call uses a fresh string - * probe id (T9: string ids never collide with Protocol's numeric ids, e.g. - * on shared stdio pipes). + * Send one probe request and await its reply. Probe ids are strings, so they + * never collide with Protocol's numeric ids (e.g. on a shared stdio pipe). */ async exchange(buildRequest: (id: string) => JSONRPCRequest, timeoutMs: number): Promise { const id = `server-discover-probe-${++this._probeCounter}`; @@ -277,11 +243,7 @@ class ProbeWindow { }); } - /** - * Close the window: detach the handlers and hand the started transport over - * to the next `Protocol.connect()` (whose unconditional `start()` call is - * absorbed exactly once). - */ + /** Detach the handlers and arm the one-shot `start()` pass-through for the `Protocol.connect()` handover. */ release(): void { this._pending = undefined; this._transport.onmessage = undefined; @@ -364,9 +326,8 @@ export type NegotiationResult = { era: 'modern'; version: string; discover: Disc /** * Run the negotiation probe state machine on a raw (not yet Protocol-connected) - * transport. Resolves with the negotiated era; throws typed connect errors. - * - * On return (or throw) the probe window has been released: the transport is + * transport. Resolves with the negotiated era; throws typed connect errors. On + * return (or throw) the probe window has been released: the transport is * started, handler-free, and ready for `Protocol.connect()` handover. */ export async function negotiateEra( @@ -381,12 +342,12 @@ export async function negotiateEra( const window = await ProbeWindow.open(deps.transport); try { let requestedVersion = clientModernVersions[0]!; - // T2/A6: the -32004 corrective continuation runs exactly once — even when - // the mutual version equals the just-rejected one — and the loop guard - // arms on the second rejection. + // The -32004 corrective continuation runs exactly once (even when the + // mutual version equals the just-rejected one); the loop guard arms on + // the second rejection. let correctiveUsed = false; for (;;) { - // Q12: `maxRetries` governs timeout re-sends only. + // `maxRetries` governs timeout re-sends only. let attempts = 0; let reply: RawProbeReply; do { @@ -418,7 +379,6 @@ export async function negotiateEra( } case 'legacy': { if (negotiation.kind === 'pin') { - // Pin mode: no fallback, loud failure. throw new SdkError( SdkErrorCode.EraNegotiationFailed, `Version negotiation failed: the server did not offer pinned protocol version ${negotiation.version} ` + diff --git a/packages/core/src/shared/protocolEras.ts b/packages/core/src/shared/protocolEras.ts index 403d816f72..a85135fa06 100644 --- a/packages/core/src/shared/protocolEras.ts +++ b/packages/core/src/shared/protocolEras.ts @@ -1,62 +1,41 @@ /** - * Protocol-era helpers (pure module). + * Protocol-era helpers (pure module). The MCP wire protocol splits into two eras: + * legacy (the 2025-11-25 family and earlier; the version is negotiated via the + * `initialize` handshake) and modern (2026-07-28 and later; no `initialize` — + * servers advertise versions via `server/discover` and every request carries a + * `_meta` envelope). * - * The MCP wire protocol splits into two eras: - * - * - **legacy** — the 2025-11-25 family of revisions and earlier. Connections are - * established with the `initialize` handshake; the protocol version is negotiated - * once per connection. - * - **modern** — protocol revision 2026-07-28 and later. There is no `initialize` - * handshake; servers advertise their supported versions via `server/discover` and - * every request carries a per-request `_meta` envelope. - * - * Era-aware supported-version list semantics: an operation that belongs to one era - * must only ever consult that era's subset of a supported-versions list. In - * particular, the `initialize` handshake (a legacy-era operation) must never accept - * or counter-offer a modern revision — see {@linkcode legacyProtocolVersions} — and - * the `server/discover` advertisement must only ever contain modern revisions — see - * {@linkcode modernProtocolVersions}. This keeps modern version strings out of - * 2025-era exchanges even when a single supported-versions list spans both eras. + * An operation that belongs to one era must only ever consult that era's subset + * of a supported-versions list: `initialize` never accepts or counter-offers a + * modern revision, and the `server/discover` advertisement only ever contains + * modern revisions. */ /** - * The first protocol revision of the modern (2026-07-28) era. - * - * Revision identifiers are ISO dates, so lexicographic comparison orders them - * chronologically. + * The first protocol revision of the modern (2026-07-28) era. Revision identifiers + * are ISO dates, so lexicographic comparison orders them chronologically. */ export const FIRST_MODERN_PROTOCOL_VERSION = '2026-07-28'; /** * Modern-era protocol revisions this SDK can negotiate via `server/discover`. - * * Deliberately separate from {@linkcode SUPPORTED_PROTOCOL_VERSIONS} (the legacy - * `initialize` list): the two lists feed era-disjoint code paths, so adding a - * revision here can never leak a modern version string into a 2025-era handshake. - * - * Internal — not part of the public API surface. + * `initialize` list), so adding a revision here can never leak a modern version + * string into a 2025-era handshake. Internal — not part of the public API surface. */ export const SUPPORTED_MODERN_PROTOCOL_VERSIONS = [FIRST_MODERN_PROTOCOL_VERSION]; -/** - * Whether the given protocol revision belongs to the modern (2026-07-28+) era. - */ +/** Whether the given protocol revision belongs to the modern (2026-07-28+) era. */ export function isModernProtocolVersion(version: string): boolean { return version >= FIRST_MODERN_PROTOCOL_VERSION; } -/** - * The legacy-era (pre-2026-07-28) subset of a supported-versions list, in the - * list's own preference order. - */ +/** The legacy-era (pre-2026-07-28) subset of a supported-versions list, in the list's own preference order. */ export function legacyProtocolVersions(versions: readonly string[]): string[] { return versions.filter(version => !isModernProtocolVersion(version)); } -/** - * The modern-era (2026-07-28+) subset of a supported-versions list, in the list's - * own preference order. - */ +/** The modern-era (2026-07-28+) subset of a supported-versions list, in the list's own preference order. */ export function modernProtocolVersions(versions: readonly string[]): string[] { return versions.filter(version => isModernProtocolVersion(version)); } diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 6f1aeb6da9..e783940536 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -115,10 +115,8 @@ export class Server extends Protocol { this.setRequestHandler('initialize', request => this._oninitialize(request)); this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.()); - // server/discover (protocol revision 2026-07-28) is installed ONLY when - // the supported-versions list carries a modern revision: a legacy-only - // server (today's default constant) keeps answering -32601, byte-identical - // to the deployed fleet. This is structural era gating, not a flag. + // server/discover is installed only when the supported-versions list + // carries a modern revision: a legacy-only server keeps answering -32601. if (modernProtocolVersions(this._supportedProtocolVersions).length > 0) { this.setRequestHandler('server/discover', () => this._ondiscover()); } @@ -395,15 +393,9 @@ export class Server extends Protocol { this._clientCapabilities = request.params.capabilities; this._clientVersion = request.params.clientInfo; - // Era-aware list semantics: a 2026-07-28-or-later revision is NEVER - // negotiated via the legacy `initialize` handshake — those revisions - // are only ever selected through `server/discover`. `initialize` is a - // legacy-era handshake, so both the accept check and the counter-offer - // consult only the legacy subset of the supported versions: a modern - // revision can never be accepted or counter-offered here, even when - // the list carries one, and a legacy-era client can never meet a - // modern version string at this site. (With today's default constant - // the subset is the whole list, byte-identical behavior.) + // A 2026-07-28-or-later revision is NEVER negotiated via the legacy + // `initialize` handshake — only ever selected through `server/discover` — + // so the accept check and counter-offer consult only the legacy subset. const legacyVersions = legacyProtocolVersions(this._supportedProtocolVersions); const protocolVersion = legacyVersions.includes(requestedVersion) ? requestedVersion @@ -424,14 +416,10 @@ export class Server extends Protocol { } /** - * Answers `server/discover` (protocol revision 2026-07-28). The - * advertisement is era-aware in both directions: - * - * - `supportedVersions` lists ONLY modern revisions (the modern subset of - * the supported-versions list) — discover never advertises 2025-era - * versions; those are negotiated via `initialize`. - * - the advertised capabilities exclude the listChanged/subscribe-class - * capabilities (see {@linkcode discoverAdvertisedCapabilities}). + * Answers `server/discover` (protocol revision 2026-07-28). `supportedVersions` + * lists only modern revisions (2025-era versions are negotiated via `initialize`); + * the advertised capabilities exclude the listChanged/subscribe-class capabilities + * (see {@linkcode discoverAdvertisedCapabilities}). */ private _ondiscover(): DiscoverResult { return { @@ -711,18 +699,11 @@ export class Server extends Protocol { } /** - * The capability set a server advertises on `server/discover`. - * - * A11 rider: until the subscriptions/listen milestone (#14 / M6.1) lands, the - * discover advertisement must NOT include the listChanged/subscribe-class - * capabilities — on the 2026-07-28 revision those flows are replaced by - * `subscriptions/listen`, which the SDK does not serve yet, so advertising - * them would promise notification flows a modern-era connection cannot get. - * The listen milestone removes this stripping when it wires the real - * subscription machinery (cross-referenced from that milestone). - * - * Pure: never mutates the input; the legacy `initialize` advertisement is - * untouched by construction (it calls `getCapabilities()` directly). + * The capability set a server advertises on `server/discover`: until the + * `subscriptions/listen` flow ships, the advertisement excludes the + * listChanged/subscribe-class capabilities, which a modern-era connection + * cannot be served yet. Pure — never mutates the input; the legacy + * `initialize` advertisement is untouched. */ export function discoverAdvertisedCapabilities(capabilities: ServerCapabilities): ServerCapabilities { const advertised: ServerCapabilities = { ...capabilities }; From 07a19a42da031f9840d86613e09de4c3ae439c19 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 15 Jun 2026 20:22:53 +0000 Subject: [PATCH 16/19] refactor(client): drop speculative negotiation surface Remove pieces of the negotiation surface that have no consumers yet: - `probe.maxRetries`: the probe is now a single timed exchange. The timeout verdict is unchanged (stdio timeout falls back to the legacy handshake on the same stream; HTTP timeout stays a typed error). The timeout outcome no longer carries an attempt count. - `Client.getProtocolEra()`: callers can derive the era from `getNegotiatedProtocolVersion()`; tests now assert on the negotiated version directly. - The unused `ProbeWindow.droppedInboundMessages` counter. Changeset and migration guide updated to match. --- .changeset/add-version-negotiation-option.md | 5 +- docs/migration.md | 6 +- packages/client/src/client/client.ts | 23 +------ packages/client/src/client/probeClassifier.ts | 12 ++-- .../client/src/client/versionNegotiation.ts | 52 +++++---------- .../test/client/probeClassifier.test.ts | 10 +-- .../test/client/versionNegotiation.test.ts | 64 ++++++------------- .../test/client/discoverRoundtrip.test.ts | 3 - .../test/client/versionNegotiation.test.ts | 12 ++-- 9 files changed, 52 insertions(+), 135 deletions(-) diff --git a/.changeset/add-version-negotiation-option.md b/.changeset/add-version-negotiation-option.md index 3393aac4fb..1c81a2b294 100644 --- a/.changeset/add-version-negotiation-option.md +++ b/.changeset/add-version-negotiation-option.md @@ -6,6 +6,5 @@ Add opt-in protocol version negotiation on `ClientOptions.versionNegotiation`. The default is unchanged: without the option (or with `mode: 'legacy'`) the client performs today's 2025 connect sequence byte-identically. `mode: 'auto'` probes the server with `server/discover` at connect time and conservatively falls back to the plain legacy `initialize` handshake on the same connection unless the outcome is definitive modern evidence; a network outage rejects with a typed connect error, and a probe timeout is transport-aware — on stdio it indicates a legacy server and falls back to `initialize` on the same stream, on HTTP it rejects with a typed timeout error. -`mode: { pin: '' }` negotiates exactly the pinned modern revision with no fallback. Probe policy lives under `probe: { timeoutMs?, maxRetries? }` — the probe inherits the standard request timeout, and `maxRetries` governs timeout re-sends only (the spec-mandated -`-32004` corrective continuation is not counted against it). The probe's `MCP-Protocol-Version`/`Mcp-Method` headers derive from the probe message body; the transport version slot is never touched during negotiation, so legacy-era traffic carries zero 2026 headers by -construction. Adds `Client.getProtocolEra()` and the `SdkErrorCode.EraNegotiationFailed` code for negotiation-phase connect failures. +`mode: { pin: '' }` negotiates exactly the pinned modern revision with no fallback. Probe policy lives under `probe: { timeoutMs? }` — the probe inherits the standard request timeout. The probe's `MCP-Protocol-Version`/`Mcp-Method` headers derive from the probe +message body; the transport version slot is never touched during negotiation, so legacy-era traffic carries zero 2026 headers by construction. Adds the `SdkErrorCode.EraNegotiationFailed` code for negotiation-phase connect failures. diff --git a/docs/migration.md b/docs/migration.md index 5bfd678dd0..899b9a32e2 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -999,7 +999,6 @@ const client = new Client( ); await client.connect(transport); -client.getProtocolEra(); // 'modern' | 'legacy' client.getNegotiatedProtocolVersion(); // e.g. '2026-07-28' or '2025-11-25' ``` @@ -1021,14 +1020,11 @@ Probe policy is configured under `versionNegotiation.probe`: versionNegotiation: { mode: 'auto', probe: { - timeoutMs: 10_000, // default: the standard request timeout - maxRetries: 1 // default: 0 + timeoutMs: 10_000 // default: the standard request timeout } } ``` -Note that `maxRetries` governs timeout re-sends only; the `-32004` corrective continuation (selecting a mutually supported version after a version rejection and continuing) is mandated by the specification and is not counted against it. - On the server side, a `Server`/`McpServer` whose `supportedProtocolVersions` list includes a 2026-era revision installs a `server/discover` handler, advertising only its modern revisions; servers with the default version list are byte-identical to before (they keep answering `-32601`, and the `initialize` handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). The client can also issue the request directly via `client.discover()` on a 2026-era connection; on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that protocol revision. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 33b9436e8c..fe393c8f19 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -49,7 +49,6 @@ import { CreateMessageResultWithToolsSchema, DEFAULT_REQUEST_TIMEOUT_MSEC, DiscoverResultSchema, - isModernProtocolVersion, LATEST_PROTOCOL_VERSION, ListChangedOptionsBaseSchema, mergeCapabilities, @@ -171,10 +170,8 @@ export type ClientOptions = ProtocolOptions & { * - `mode: { pin: '2026-07-28' }` — modern era at exactly the pinned revision; * no probe-and-fallback: anything else fails loudly. * - * Probe policy lives under `probe: { timeoutMs?, maxRetries? }`; the probe - * inherits the client's standard request timeout unless overridden. Note - * `maxRetries` governs timeout re-sends only — the `-32004` corrective - * continuation is spec-mandated and not counted against it. + * Probe policy lives under `probe: { timeoutMs? }`; the probe inherits the + * client's standard request timeout unless overridden. */ versionNegotiation?: VersionNegotiationOptions; @@ -655,22 +652,6 @@ export class Client extends Protocol { return this._negotiatedProtocolVersion; } - /** - * The protocol era of the current connection: `'modern'` for a 2026-07-28+ - * connection negotiated via `server/discover`, `'legacy'` for a 2025 - * `initialize` handshake (including the plain connect without - * `versionNegotiation`). `undefined` before negotiation completes. A thin - * read of the negotiated protocol version (the same connection state - * {@linkcode getNegotiatedProtocolVersion} exposes) — never persisted. - */ - getProtocolEra(): 'legacy' | 'modern' | undefined { - const version = this._negotiatedProtocolVersion; - if (version === undefined) { - return undefined; - } - return isModernProtocolVersion(version) ? 'modern' : 'legacy'; - } - /** * After initialization has completed, this may be populated with information about the server's instructions. */ diff --git a/packages/client/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts index ca46371948..950174fdc6 100644 --- a/packages/client/src/client/probeClassifier.ts +++ b/packages/client/src/client/probeClassifier.ts @@ -45,8 +45,8 @@ export type ProbeOutcome = /** The HTTP layer rejected the probe POST (non-2xx); `body` is the raw response text, when available. */ | { kind: 'http-error'; status: number; body?: string } | { kind: 'network-error'; error: unknown } - /** No response arrived within the probe timeout, after all timeout re-sends. */ - | { kind: 'timeout'; timeoutMs: number; attempts: number }; + /** No response arrived within the probe timeout. */ + | { kind: 'timeout'; timeoutMs: number }; export interface ProbeClassifierContext { /** Modern-era versions this client can negotiate, in preference order (never empty). */ @@ -118,11 +118,9 @@ export function classifyProbeOutcome(outcome: ProbeOutcome, context: ProbeClassi // matrix keys the HTTP legacy signal to a 4xx, never to silence). return { kind: 'error', - error: new SdkError( - SdkErrorCode.RequestTimeout, - `Version negotiation probe timed out after ${outcome.attempts} attempt(s)`, - { timeout: outcome.timeoutMs, attempts: outcome.attempts } - ) + error: new SdkError(SdkErrorCode.RequestTimeout, `Version negotiation probe timed out after ${outcome.timeoutMs}ms`, { + timeout: outcome.timeoutMs + }) }; } } diff --git a/packages/client/src/client/versionNegotiation.ts b/packages/client/src/client/versionNegotiation.ts index c810375cff..1c6fa41c72 100644 --- a/packages/client/src/client/versionNegotiation.ts +++ b/packages/client/src/client/versionNegotiation.ts @@ -38,31 +38,17 @@ import { classifyProbeOutcome } from './probeClassifier.js'; */ export interface VersionNegotiationProbeOptions { /** - * Timeout for a single probe exchange, in milliseconds. - * - * @default the standard request timeout (`DEFAULT_REQUEST_TIMEOUT_MSEC`, or the `timeout` passed to `connect()`) - */ - timeoutMs?: number; - - /** - * How many times a TIMED-OUT probe is re-sent before the timeout verdict - * applies. - * - * `maxRetries` governs timeout re-sends only; the `-32004` corrective - * continuation (select-and-continue on a mutual version) is spec-mandated - * and is not counted against it. + * Timeout for the probe exchange, in milliseconds. * * The timeout verdict is transport-aware: on stdio, a probe that gets no - * response within the timeout (after all retries) indicates a legacy - * server and falls back to the `initialize` handshake on the same stream - * (the stdio transport's backward-compatibility rule — some legacy - * servers do not respond to unknown pre-`initialize` requests at all). - * On HTTP, where a deployed server answers and silence means an outage, - * `connect()` rejects with the standard typed timeout error instead. + * response within the timeout indicates a legacy server and falls back to + * the `initialize` handshake on the same stream; on HTTP, where a deployed + * server answers and silence means an outage, `connect()` rejects with the + * standard typed timeout error instead. * - * @default 0 + * @default the standard request timeout (`DEFAULT_REQUEST_TIMEOUT_MSEC`, or the `timeout` passed to `connect()`) */ - maxRetries?: number; + timeoutMs?: number; } /** @@ -177,9 +163,6 @@ type RawProbeReply = * transport) takes over the already-started channel without a double-start error. */ class ProbeWindow { - /** Inbound messages dropped (with zero bytes written back) while the window was open. */ - droppedInboundMessages = 0; - private _pending: { id: string; resolve: (reply: RawProbeReply) => void } | undefined; private _probeCounter = 0; @@ -202,8 +185,7 @@ class ProbeWindow { } return; } - // Probe-window guard: drop everything else with zero bytes (see module doc). - window.droppedInboundMessages++; + // Probe-window guard: drop everything else with zero bytes written back (see module doc). }; transport.onerror = () => { // Out-of-band transport errors are not necessarily fatal; the probe @@ -284,7 +266,7 @@ export function buildProbeRequest( }; } -function normalizeReply(reply: RawProbeReply, timeoutMs: number, attempts: number): ProbeOutcome { +function normalizeReply(reply: RawProbeReply, timeoutMs: number): ProbeOutcome { switch (reply.kind) { case 'response': { return reply.error === undefined ? { kind: 'result', result: reply.result } : { kind: 'rpc-error', ...reply.error }; @@ -306,7 +288,7 @@ function normalizeReply(reply: RawProbeReply, timeoutMs: number, attempts: numbe return { kind: 'network-error', error: new Error('Connection closed during the version negotiation probe') }; } case 'timeout': { - return { kind: 'timeout', timeoutMs, attempts }; + return { kind: 'timeout', timeoutMs }; } } } @@ -335,7 +317,6 @@ export async function negotiateEra( deps: NegotiationDeps ): Promise { const timeoutMs = negotiation.probe.timeoutMs ?? deps.defaultTimeoutMs; - const maxRetries = negotiation.probe.maxRetries ?? 0; const clientModernVersions = negotiation.kind === 'pin' ? [negotiation.version] : negotiation.modernVersions; const fallbackAvailable = negotiation.kind === 'auto' && negotiation.fallbackAvailable; @@ -347,15 +328,12 @@ export async function negotiateEra( // the second rejection. let correctiveUsed = false; for (;;) { - // `maxRetries` governs timeout re-sends only. - let attempts = 0; - let reply: RawProbeReply; - do { - attempts++; - reply = await window.exchange(id => buildProbeRequest(id, requestedVersion, deps.clientInfo, deps.capabilities), timeoutMs); - } while (reply.kind === 'timeout' && attempts <= maxRetries); + const reply = await window.exchange( + id => buildProbeRequest(id, requestedVersion, deps.clientInfo, deps.capabilities), + timeoutMs + ); - const outcome = normalizeReply(reply, timeoutMs, attempts); + const outcome = normalizeReply(reply, timeoutMs); const verdict: ProbeVerdict = classifyProbeOutcome(outcome, { clientModernVersions, requestedVersion, diff --git a/packages/client/test/client/probeClassifier.test.ts b/packages/client/test/client/probeClassifier.test.ts index 10a12eff5e..5318d30b5e 100644 --- a/packages/client/test/client/probeClassifier.test.ts +++ b/packages/client/test/client/probeClassifier.test.ts @@ -265,7 +265,7 @@ describe('row: network outage → typed connect error (Node)', () => { }); }); -describe('row: timeout after maxRetries — transport-aware verdict', () => { +describe('row: timeout — transport-aware verdict', () => { // The specification's backward-compatibility rule for stdio: "any other // error, or does not respond within a reasonable timeout: the server is // legacy. Fall back to the initialize handshake." The versioning @@ -273,7 +273,7 @@ describe('row: timeout after maxRetries — transport-aware verdict', () => { // times out → fall back to initialize; on HTTP the legacy signal is a 4xx // without a recognized modern error body, so silence stays an outage. test('HTTP: timeout maps to the standard RequestTimeout SdkError (silence on a deployed server is an outage)', () => { - const verdict = classify({ kind: 'timeout', timeoutMs: 60_000, attempts: 1 }, { transportKind: 'http' }); + const verdict = classify({ kind: 'timeout', timeoutMs: 60_000 }, { transportKind: 'http' }); expect(verdict.kind).toBe('error'); if (verdict.kind === 'error') { expect(verdict.error).toBeInstanceOf(SdkError); @@ -282,11 +282,7 @@ describe('row: timeout after maxRetries — transport-aware verdict', () => { }); test('stdio: timeout is a legacy-server signal → fall back to initialize on the same stream', () => { - expect(classify({ kind: 'timeout', timeoutMs: 5_000, attempts: 1 }, { transportKind: 'stdio' })).toEqual({ kind: 'legacy' }); - }); - - test('stdio: the verdict applies after the timeout re-sends, regardless of attempt count', () => { - expect(classify({ kind: 'timeout', timeoutMs: 5_000, attempts: 3 }, { transportKind: 'stdio' })).toEqual({ kind: 'legacy' }); + expect(classify({ kind: 'timeout', timeoutMs: 5_000 }, { transportKind: 'stdio' })).toEqual({ kind: 'legacy' }); }); }); diff --git a/packages/client/test/client/versionNegotiation.test.ts b/packages/client/test/client/versionNegotiation.test.ts index 78ae48fe0c..7e45d085a2 100644 --- a/packages/client/test/client/versionNegotiation.test.ts +++ b/packages/client/test/client/versionNegotiation.test.ts @@ -181,7 +181,6 @@ describe('auto mode against a modern server', () => { // stamped exactly once, after the era resolved modern. expect(transport.setProtocolVersionCalls).toEqual([MODERN]); - expect(client.getProtocolEra()).toBe('modern'); expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); expect(client.getServerVersion()?.name).toBe('scripted-modern-server'); @@ -223,8 +222,8 @@ describe('auto mode against a legacy server (fallback)', () => { // initialize-negotiated version) — nothing was set or cleared around the probe. expect(autoTransport.setProtocolVersionCalls).toEqual(plainTransport.setProtocolVersionCalls); - expect(autoClient.getProtocolEra()).toBe('legacy'); - expect(plainClient.getProtocolEra()).toBe('legacy'); + expect(autoClient.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + expect(plainClient.getNegotiatedProtocolVersion()).toBe('2025-11-25'); await autoClient.close(); await plainClient.close(); @@ -296,24 +295,6 @@ describe('probe timeout policy (transport-aware)', () => { expect(transport.setProtocolVersionCalls).toEqual([]); }); - test('maxRetries governs timeout re-sends only (default 0); each re-send uses a fresh probe id', async () => { - const transport = new ScriptedTransport(silentScript); - const client = new Client( - { name: 'c', version: '0' }, - { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 30, maxRetries: 2 } } } - ); - - await expect(client.connect(transport)).rejects.toSatisfy( - error => error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout - ); - - const probes = requests(transport.sent); - expect(probes).toHaveLength(3); // initial + 2 re-sends - const ids = probes.map(p => String(p.id)); - expect(new Set(ids).size).toBe(3); - expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); - }); - /** A stdio-shaped transport: structurally recognizable by its stderr/pid accessors. */ class StdioShapedTransport extends ScriptedTransport { get stderr(): null { @@ -336,19 +317,15 @@ describe('probe timeout policy (transport-aware)', () => { }; const transport = new StdioShapedTransport(silentLegacyScript); - const client = new Client( - { name: 'c', version: '0' }, - { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 30, maxRetries: 1 } } } - ); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 30 } } }); await client.connect(transport); - // The probe was re-sent per maxRetries, then the timeout resolved to the - // legacy verdict and the initialize fallback ran on the SAME transport. + // The timeout resolved to the legacy verdict and the initialize fallback + // ran on the SAME transport. const sent = requests(transport.sent); - expect(sent.filter(r => r.method === 'server/discover')).toHaveLength(2); + expect(sent.filter(r => r.method === 'server/discover')).toHaveLength(1); expect(sent.some(r => r.method === 'initialize')).toBe(true); - expect(client.getProtocolEra()).toBe('legacy'); expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); await client.close(); @@ -368,11 +345,11 @@ describe('probe timeout policy (transport-aware)', () => { }); /* ------------------------------------------------------------------------- * - * T2/A6: -32004 corrective continuation — exactly once; loop guard on second - * rejection. Not counted against probe.maxRetries (Q12 disambiguation). + * -32004 corrective continuation — exactly once; loop guard on second + * rejection. * ------------------------------------------------------------------------- */ -describe('-32004 corrective continuation (T2/A6)', () => { +describe('-32004 corrective continuation', () => { test('select-and-continue runs exactly once, even when the mutual version equals the just-rejected one', async () => { let discoverCalls = 0; const script: Script = (message, t) => { @@ -397,13 +374,11 @@ describe('-32004 corrective continuation (T2/A6)', () => { }; const transport = new ScriptedTransport(script); - const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto', probe: { maxRetries: 0 } } }); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(transport); - // The corrective continuation is spec-mandated and NOT counted against - // maxRetries (0 here): the second probe still happened. + // The corrective continuation is spec-mandated: the second probe still happened. expect(discoverCalls).toBe(2); - expect(client.getProtocolEra()).toBe('modern'); expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); // MUST NOT fall back at any point. @@ -465,7 +440,7 @@ describe('-32004 corrective continuation (T2/A6)', () => { const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(transport); - expect(client.getProtocolEra()).toBe('legacy'); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); await client.close(); }); @@ -505,7 +480,6 @@ describe('pin mode', () => { const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); await client.connect(transport); - expect(client.getProtocolEra()).toBe('modern'); expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); await client.close(); }); @@ -561,7 +535,7 @@ describe('probe-window guard', () => { // Zero bytes for the dropped request: nothing in the sent log answers id 999. const repliesTo999 = transport.sent.filter(m => 'id' in m && m.id === 999); expect(repliesTo999).toEqual([]); - expect(client.getProtocolEra()).toBe('legacy'); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); await client.close(); }); }); @@ -579,7 +553,7 @@ describe('era scope discipline', () => { const first = new ScriptedTransport(legacyServerScript); await client.connect(first); expect(requests(first.sent)[0]!.method).toBe('server/discover'); - expect(client.getProtocolEra()).toBe('legacy'); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); await client.close(); // Second (fresh) connect: the negotiated protocol version is connection @@ -588,7 +562,7 @@ describe('era scope discipline', () => { const second = new ScriptedTransport(legacyServerScript); await client.connect(second); expect(requests(second.sent)[0]!.method).toBe('server/discover'); - expect(client.getProtocolEra()).toBe('legacy'); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); await client.close(); }); @@ -597,12 +571,12 @@ describe('era scope discipline', () => { const transport = new ScriptedTransport(modernServerScript()); await client.connect(transport); - expect(client.getProtocolEra()).toBe('modern'); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); // A later transport failure does not demote the current connection's era // and triggers no initialize. transport.onerror?.(new Error('boom')); - expect(client.getProtocolEra()).toBe('modern'); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); await client.close(); @@ -611,13 +585,13 @@ describe('era scope discipline', () => { const next = new ScriptedTransport(modernServerScript()); await client.connect(next); expect(requests(next.sent)[0]!.method).toBe('server/discover'); - expect(client.getProtocolEra()).toBe('modern'); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); await client.close(); }); test('no era state exists before the first connect, and none is persisted anywhere', () => { const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); - expect(client.getProtocolEra()).toBeUndefined(); + expect(client.getNegotiatedProtocolVersion()).toBeUndefined(); // No cachedEra option surface (deferred-additive). type NotAKeyOf = K extends keyof T ? false : true; const noCachedEra: NotAKeyOf[1]>, 'cachedEra'> = true; diff --git a/test/integration/test/client/discoverRoundtrip.test.ts b/test/integration/test/client/discoverRoundtrip.test.ts index 94d5001969..cdf551d60e 100644 --- a/test/integration/test/client/discoverRoundtrip.test.ts +++ b/test/integration/test/client/discoverRoundtrip.test.ts @@ -77,7 +77,6 @@ describe('server/discover round-trip against a modern server', () => { await client.connect(new StreamableHTTPClientTransport(baseUrl, { fetch: fetchFn })); cleanups.push(() => client.close()); - expect(client.getProtocolEra()).toBe('modern'); expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); expect(client.getServerVersion()).toEqual({ name: 'dual-era-server', version: '2.0.0' }); expect(client.getInstructions()).toBe('dual era'); @@ -94,7 +93,6 @@ describe('server/discover round-trip against a modern server', () => { await client.connect(new StreamableHTTPClientTransport(baseUrl)); cleanups.push(() => client.close()); - expect(client.getProtocolEra()).toBe('modern'); expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); }); @@ -108,7 +106,6 @@ describe('server/discover round-trip against a modern server', () => { await client.connect(new StreamableHTTPClientTransport(baseUrl)); cleanups.push(() => client.close()); - expect(client.getProtocolEra()).toBe('legacy'); expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); const result = await client.callTool({ name: 'echo', arguments: { text: 'fallback' } }); expect(result.content).toEqual([{ type: 'text', text: 'fallback' }]); diff --git a/test/integration/test/client/versionNegotiation.test.ts b/test/integration/test/client/versionNegotiation.test.ts index ad93e5c853..a5aaee0148 100644 --- a/test/integration/test/client/versionNegotiation.test.ts +++ b/test/integration/test/client/versionNegotiation.test.ts @@ -110,7 +110,6 @@ describe('version negotiation against real legacy servers (wire-real first-conta expect(probeBody.error.message).toContain('supported versions:'); // Conservative fallback on the same connection. - expect(client.getProtocolEra()).toBe('legacy'); expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); // Fallback hygiene: ZERO 2026 headers on every post-probe request. @@ -146,7 +145,7 @@ describe('version negotiation against real legacy servers (wire-real first-conta expect(probeBody.error.code).toBe(-32_000); expect(probeBody.error.message).toBe('Bad Request: Server not initialized'); - expect(client.getProtocolEra()).toBe('legacy'); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); const result = await client.callTool({ name: 'echo', arguments: { text: 'stateful' } }); expect(result.content).toEqual([{ type: 'text', text: 'stateful' }]); }); @@ -198,7 +197,7 @@ describe('typed connect errors (Q12) over real sockets', () => { ); }); - it('probe timeout: typed timeout error after maxRetries, no initialize ever sent', async () => { + it('probe timeout: typed timeout error, no initialize ever sent', async () => { // A server that accepts the request and never responds. const hang = createServer(() => { /* never answer */ @@ -208,7 +207,7 @@ describe('typed connect errors (Q12) over real sockets', () => { const { calls, fetchFn } = recordingFetch(); const client = new Client( { name: 'neg-client', version: '1.0.0' }, - { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 300, maxRetries: 1 } } } + { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 300 } } } ); const transport = new StreamableHTTPClientTransport(url, { fetch: fetchFn }); @@ -216,7 +215,7 @@ describe('typed connect errors (Q12) over real sockets', () => { error => error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout ); - // Two probe attempts (initial + 1 retry), zero initialize POSTs. + // Probe POSTs only — zero initialize POSTs. const posts = calls.filter(c => c.method === 'POST'); expect(posts.every(c => c.headers['mcp-method'] === 'server/discover')).toBe(true); expect(posts.every(c => (c.body ?? '').includes('server/discover'))).toBe(true); @@ -275,12 +274,11 @@ describe('stdio: silent legacy server (probe timeout fallback)', () => { }); const client = new Client( { name: 'neg-client', version: '1.0.0' }, - { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 500, maxRetries: 1 } } } + { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 500 } } } ); try { await client.connect(transport); - expect(client.getProtocolEra()).toBe('legacy'); expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); expect(client.getServerVersion()?.name).toBe('silent-legacy-stdio-server'); } finally { From 393f5b959dff8de9bdc7baab32fd5db7659c6f21 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 15 Jun 2026 20:25:52 +0000 Subject: [PATCH 17/19] fix(client): restore the transport's start() when negotiation fails The probe window arms a one-shot start() pass-through when it hands the already-started transport over to Protocol.connect(). When negotiation threw (pin mismatch, typed probe error), that pass-through stayed armed on a transport the caller still owns, so a later start() call would be silently swallowed. Failed negotiation now detaches the probe window without arming the pass-through, leaving the transport exactly as it was found. Adds a unit test pinning that a failed pin-mode connect leaves transport.start untouched. --- .../client/src/client/versionNegotiation.ts | 31 ++++++++++++++----- .../test/client/versionNegotiation.test.ts | 15 +++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/client/src/client/versionNegotiation.ts b/packages/client/src/client/versionNegotiation.ts index 1c6fa41c72..1bb925a419 100644 --- a/packages/client/src/client/versionNegotiation.ts +++ b/packages/client/src/client/versionNegotiation.ts @@ -225,12 +225,17 @@ class ProbeWindow { }); } - /** Detach the handlers and arm the one-shot `start()` pass-through for the `Protocol.connect()` handover. */ - release(): void { + /** Detach the window's handlers, leaving the transport's own `start` untouched. */ + detach(): void { this._pending = undefined; this._transport.onmessage = undefined; this._transport.onerror = undefined; this._transport.onclose = undefined; + } + + /** Detach the handlers and arm the one-shot `start()` pass-through for the `Protocol.connect()` handover. */ + release(): void { + this.detach(); const transport = this._transport; const originalStart = transport.start.bind(transport); let armed = true; @@ -309,8 +314,9 @@ export type NegotiationResult = { era: 'modern'; version: string; discover: Disc /** * Run the negotiation probe state machine on a raw (not yet Protocol-connected) * transport. Resolves with the negotiated era; throws typed connect errors. On - * return (or throw) the probe window has been released: the transport is - * started, handler-free, and ready for `Protocol.connect()` handover. + * return the probe window has been released: the transport is started, + * handler-free, and ready for `Protocol.connect()` handover. On throw the + * window is detached and the transport's `start` is left untouched. */ export async function negotiateEra( negotiation: Extract, @@ -321,7 +327,8 @@ export async function negotiateEra( const fallbackAvailable = negotiation.kind === 'auto' && negotiation.fallbackAvailable; const window = await ProbeWindow.open(deps.transport); - try { + + const probe = async (): Promise => { let requestedVersion = clientModernVersions[0]!; // The -32004 corrective continuation runs exactly once (even when the // mutual version equals the just-rejected one); the loop guard arms on @@ -370,7 +377,17 @@ export async function negotiateEra( } } } - } finally { - window.release(); + }; + + let result: NegotiationResult; + try { + result = await probe(); + } catch (error) { + // A failed negotiation leaves the transport exactly as it found it: + // handlers detached, original start untouched (no pass-through armed). + window.detach(); + throw error; } + window.release(); + return result; } diff --git a/packages/client/test/client/versionNegotiation.test.ts b/packages/client/test/client/versionNegotiation.test.ts index 7e45d085a2..4967ad0787 100644 --- a/packages/client/test/client/versionNegotiation.test.ts +++ b/packages/client/test/client/versionNegotiation.test.ts @@ -507,6 +507,21 @@ describe('pin mode', () => { expect(rejection!.requested).toBe(MODERN); expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); }); + + test('a failed negotiation leaves the transport start() untouched (no armed pass-through)', async () => { + const transport = new ScriptedTransport(legacyServerScript); + const originalStart = transport.start; + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + + // The probe window's one-shot start() pass-through must not stay armed + // on a transport the caller still owns after a failed connect. + expect(transport.start).toBe(originalStart); + expect(transport.onmessage).toBeUndefined(); + }); }); /* ------------------------------------------------------------------------- * From 59874c0d8d8c3018e58157ff2a98959761765140 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 15 Jun 2026 20:26:52 +0000 Subject: [PATCH 18/19] docs: clarify that server-side discover serving lands with the server entry point The migration guide and the server/discover changeset read as if a Server with a 2026 revision in supportedProtocolVersions already serves discover for ordinary HTTP/stdio traffic. Add a sentence to each making clear that this arrives with the upcoming server-side entry point, that the negotiation surface today is client-side (auto mode falls back cleanly against current SDK servers), and that a full typed client.discover() round-trip against an SDK server needs the per-request envelope support that lands with that entry. --- .changeset/wire-server-discover.md | 3 ++- docs/migration.md | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.changeset/wire-server-discover.md b/.changeset/wire-server-discover.md index cca2752951..b83b860e5e 100644 --- a/.changeset/wire-server-discover.md +++ b/.changeset/wire-server-discover.md @@ -7,4 +7,5 @@ Wire `server/discover` (protocol revision 2026-07-28) into the typed request funnel and serve it era-aware. The request joins `ClientRequestSchema`/`ServerResultSchema`/`ResultTypeMap` (per-era availability stays with the wire registries: only the 2026-era registry serves it), and `Client.discover()` issues it as a typed request on 2026-era connections. A `Server` whose `supportedProtocolVersions` list carries a modern (2026-07-28+) revision installs the `server/discover` handler, advertising ONLY its modern revisions and excluding the listChanged/subscribe-class capabilities until the `subscriptions/listen` flow ships; servers with today's default list are unchanged and keep answering `-32601`. The `initialize` handshake is now era-aware in the other direction: its accept check and counter-offer consult -only the legacy subset of the supported versions — a 2026-era revision is never negotiated via `initialize` — so a 2025-era client can never be offered a 2026 version string; with the default list this is byte-identical to previous behavior. +only the legacy subset of the supported versions — a 2026-era revision is never negotiated via `initialize` — so a 2025-era client can never be offered a 2026 version string; with the default list this is byte-identical to previous behavior. Serving the 2026 revision to +ordinary HTTP/stdio traffic arrives with an upcoming server-side entry point: today the negotiation surface is client-side, and `mode: 'auto'` falls back cleanly against current SDK servers. diff --git a/docs/migration.md b/docs/migration.md index 899b9a32e2..67f24e19e6 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1026,8 +1026,9 @@ versionNegotiation: { ``` On the server side, a `Server`/`McpServer` whose `supportedProtocolVersions` list includes a 2026-era revision installs a `server/discover` handler, advertising only its modern revisions; servers with the default version list are byte-identical to before (they keep -answering `-32601`, and the `initialize` handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). The client can also issue the request directly via `client.discover()` on a 2026-era connection; on a 2025-era -connection the method is rejected locally with a typed error, since it does not exist on that protocol revision. +answering `-32601`, and the `initialize` handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Note that serving the 2026 revision to ordinary HTTP/stdio traffic arrives with an upcoming server-side entry +point: today the negotiation surface is client-side, and `mode: 'auto'` falls back cleanly against current SDK servers. The client can also issue the request directly via `client.discover()` on a 2026-era connection — though a full typed round-trip against an SDK +server additionally needs the per-request envelope support that lands with that server entry — while on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that protocol revision. ### Automatic JSON Schema validator selection by runtime From 79631bb5eae4110a339afde4badd454ecadcc650 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 15 Jun 2026 20:34:23 +0000 Subject: [PATCH 19/19] fix(client): keep 2026-era revisions out of the legacy initialize fallback Mirror the server's era-aware initialize semantics on the client. The legacy handshake now offers and accepts only the legacy subset of supportedProtocolVersions, so a dual-era list can no longer put a 2026-era version string into an initialize request, and a non-conforming server echoing a 2026 revision from initialize is rejected by the accept check. For a modern-only supported list the legacy fallback is genuinely unavailable: auto-mode negotiation now rejects with a typed error instead of attempting an initialize, and the handshake itself raises a typed error when no legacy version exists to offer. Behavior with the default version list is unchanged. Adds unit tests for the legacy-subset offer, the 2026-echo rejection, and the modern-only typed error. --- packages/client/src/client/client.ts | 17 +++++- .../client/src/client/versionNegotiation.ts | 9 +++ .../test/client/versionNegotiation.test.ts | 55 +++++++++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index fe393c8f19..a032a10ee4 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -49,7 +49,7 @@ import { CreateMessageResultWithToolsSchema, DEFAULT_REQUEST_TIMEOUT_MSEC, DiscoverResultSchema, - LATEST_PROTOCOL_VERSION, + legacyProtocolVersions, ListChangedOptionsBaseSchema, mergeCapabilities, parseSchema, @@ -507,12 +507,23 @@ export class Client extends Protocol { * the handshake; its completion sets the negotiated (legacy) version. */ private async _legacyHandshake(transport: Transport, options?: RequestOptions): Promise { + // initialize is a legacy-era handshake: only the legacy subset of the + // supported versions is ever offered or accepted here — a 2026-era + // revision is negotiated exclusively via server/discover. + const legacyVersions = legacyProtocolVersions(this._supportedProtocolVersions); try { + const offeredVersion = legacyVersions[0]; + if (offeredVersion === undefined) { + throw new SdkError( + SdkErrorCode.EraNegotiationFailed, + 'Cannot run the initialize handshake: supportedProtocolVersions contains no pre-2026-07-28 protocol version' + ); + } const result = await this.request( { method: 'initialize', params: { - protocolVersion: this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION, + protocolVersion: offeredVersion, capabilities: this._capabilities, clientInfo: this._clientInfo } @@ -524,7 +535,7 @@ export class Client extends Protocol { throw new Error(`Server sent invalid initialize result: ${result}`); } - if (!this._supportedProtocolVersions.includes(result.protocolVersion)) { + if (!legacyVersions.includes(result.protocolVersion)) { throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); } diff --git a/packages/client/src/client/versionNegotiation.ts b/packages/client/src/client/versionNegotiation.ts index 1bb925a419..f4b80511ca 100644 --- a/packages/client/src/client/versionNegotiation.ts +++ b/packages/client/src/client/versionNegotiation.ts @@ -370,6 +370,15 @@ export async function negotiateEra( `via server/discover (no fallback in pin mode)` ); } + if (!negotiation.fallbackAvailable) { + // Modern-only client: the legacy initialize fallback is + // unavailable and must never carry a 2026-era version string. + throw new SdkError( + SdkErrorCode.EraNegotiationFailed, + 'Version negotiation failed: the server gave no modern evidence and this client supports no ' + + 'pre-2026-07-28 protocol version to fall back to' + ); + } return { era: 'legacy' }; } case 'error': { diff --git a/packages/client/test/client/versionNegotiation.test.ts b/packages/client/test/client/versionNegotiation.test.ts index 4967ad0787..a358ca0c30 100644 --- a/packages/client/test/client/versionNegotiation.test.ts +++ b/packages/client/test/client/versionNegotiation.test.ts @@ -263,6 +263,61 @@ describe('auto mode against a legacy server (fallback)', () => { await plainClient.close(); }); + test('a dual-era supportedProtocolVersions list never leaks a 2026 version into the fallback initialize', async () => { + const transport = new ScriptedTransport(legacyServerScript); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: [MODERN, '2025-11-25'] } + ); + await client.connect(transport); + + // The fallback initialize offers the first LEGACY version of the list, + // never the 2026-era entry. + const init = requests(transport.sent).find(r => r.method === 'initialize')!; + expect((init.params as { protocolVersion?: string }).protocolVersion).toBe('2025-11-25'); + expect(JSON.stringify(transport.sent.slice(1))).not.toContain(MODERN); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + + await client.close(); + }); + + test('a non-conforming server that echoes a 2026 revision from initialize is rejected by the accept check', async () => { + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'initialize') { + t.reply({ + jsonrpc: '2.0', + id: message.id, + result: { protocolVersion: MODERN, capabilities: {}, serverInfo: { name: 's', version: '1' } } + }); + } else { + t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } }); + } + }; + + const transport = new ScriptedTransport(script); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: [MODERN, '2025-11-25'] } + ); + + await expect(client.connect(transport)).rejects.toThrow(/protocol version is not supported/); + }); + + test('a modern-only client in auto mode gets a typed error instead of a fallback when the server gives no modern evidence', async () => { + const transport = new ScriptedTransport(legacyServerScript); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: [MODERN] } + ); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + // The fallback never ran: no initialize carrying any version was sent. + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); + // Fallback against REAL servers (in-memory pair, stateful HTTP, stateless // HTTP — both first-contact wire shapes) is covered in // test/integration/test/client/versionNegotiation.test.ts.