diff --git a/.changeset/add-version-negotiation-option.md b/.changeset/add-version-negotiation-option.md new file mode 100644 index 0000000000..1c81a2b294 --- /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; 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? }` — 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/.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/.changeset/wire-server-discover.md b/.changeset/wire-server-discover.md new file mode 100644 index 0000000000..b83b860e5e --- /dev/null +++ b/.changeset/wire-server-discover.md @@ -0,0 +1,11 @@ +--- +'@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. 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 764203ec2b..67f24e19e6 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -984,6 +984,52 @@ 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.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 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`: + +```typescript +versionNegotiation: { + mode: 'auto', + probe: { + timeoutMs: 10_000 // default: the standard request timeout + } +} +``` + +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). 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 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..a032a10ee4 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, @@ -46,19 +47,22 @@ import { codecForVersion, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, - LATEST_PROTOCOL_VERSION, + DEFAULT_REQUEST_TIMEOUT_MSEC, + DiscoverResultSchema, + legacyProtocolVersions, ListChangedOptionsBaseSchema, mergeCapabilities, - negotiatedProtocolVersionOf, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, SdkError, - SdkErrorCode, - setNegotiatedProtocolVersion + SdkErrorCode } from '@modelcontextprotocol/core'; +import type { ResolvedVersionNegotiation, VersionNegotiationOptions } from './versionNegotiation.js'; +import { detectProbeEnvironment, detectProbeTransportKind, negotiateEra, resolveVersionNegotiation } from './versionNegotiation.js'; + /** * Elicitation default application helper. Applies defaults to the `data` based on the `schema`. * @@ -149,6 +153,28 @@ 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. 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. + * + * Probe policy lives under `probe: { timeoutMs? }`; the probe inherits the + * client's standard request timeout unless overridden. + */ + 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) { @@ -300,7 +330,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 @@ -363,7 +393,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( @@ -439,12 +469,17 @@ 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 // 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) — @@ -461,13 +496,34 @@ 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); + } + + /** + * 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 { + // 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 } @@ -479,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}`); } @@ -503,7 +559,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) { @@ -517,6 +573,73 @@ export class Client extends Protocol { } } + /** + * 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, + 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 = this._negotiatedProtocolVersion; + if (negotiatedProtocolVersion !== undefined && transport.setProtocolVersion) { + transport.setProtocolVersion(negotiatedProtocolVersion); + } + return; + } + + // 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>; + try { + result = await negotiateEra(negotiation, { + transport, + clientInfo: this._clientInfo, + capabilities: this._capabilities, + environment: detectProbeEnvironment(), + transportKind: detectProbeTransportKind(transport), + 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 (the probe never touched the transport version slot). + await this._legacyHandshake(transport, options); + return; + } + + this._serverCapabilities = result.discover.capabilities; + this._serverVersion = result.discover.serverInfo; + this._instructions = result.discover.instructions; + // Modern selection: the same connection state the legacy handshake completion sets. + this._negotiatedProtocolVersion = result.version; + // 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 configured straight from the advertised capabilities. + if (this._pendingListChangedConfig) { + this._setupListChangedHandlers(this._pendingListChangedConfig); + this._pendingListChangedConfig = undefined; + } + } + /** * After initialization has completed, this will be populated with the server's reported capabilities. */ @@ -537,7 +660,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; } /** @@ -605,6 +728,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; @@ -690,6 +819,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/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts new file mode 100644 index 0000000000..950174fdc6 --- /dev/null +++ b/packages/client/src/client/probeClassifier.ts @@ -0,0 +1,249 @@ +/** + * 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. + * + * 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 { + DiscoverResultSchema, + modernProtocolVersions, + SdkError, + SdkErrorCode, + UnsupportedProtocolVersionError +} from '@modelcontextprotocol/core'; + +/** + * The runtime environment the probe executed in. Only consulted for the + * 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: 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. + */ +export type ProbeOutcome = + | { kind: 'result'; result: unknown } + /** 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 } + | { kind: 'network-error'; error: unknown } + /** 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). */ + clientModernVersions: readonly string[]; + /** 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, where rows that would otherwise + * fall back yield a typed `UnsupportedProtocolVersionError` instead. + */ + fallbackAvailable: boolean; + /** See {@linkcode ProbeEnvironment}. */ + environment: ProbeEnvironment; + /** See {@linkcode ProbeTransportKind}. */ + transportKind: ProbeTransportKind; +} + +export type ProbeVerdict = + /** Definitive modern evidence: select `version` and continue without `initialize`. */ + | { kind: 'modern'; version: string; discover: DiscoverResult } + /** + * `-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. */ + | { 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; +/** + * 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]); + +/** + * 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': { + if (context.transportKind === 'stdio') { + // 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' }; + } + // 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(SdkErrorCode.RequestTimeout, `Version negotiation probe timed out after ${outcome.timeoutMs}ms`, { + timeout: outcome.timeoutMs + }) + }; + } + } +} + +function classifyResult(result: unknown, context: ProbeClassifierContext): ProbeVerdict { + const parsed = DiscoverResultSchema.safeParse(result); + if (!parsed.success) { + // 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 }; + } + // 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' }; + } + 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. + 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: 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 (typed error for a modern-only client). + return context.fallbackAvailable ? { kind: 'legacy' } : { kind: 'error', error }; + } + + if (NOT_PROBE_RECOGNIZED.has(code)) { + return { kind: 'legacy' }; + } + + // 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 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); + } + // Unparseable or unrecognized HTTP rejection: conservative legacy fallback. + return { kind: 'legacy' }; +} + +function classifyNetworkError(error: unknown, context: ProbeClassifierContext): ProbeVerdict { + if (context.environment === 'browser' && isOpaqueFetchTypeError(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 { + kind: 'error', + error: new SdkError(SdkErrorCode.EraNegotiationFailed, `Version negotiation probe failed: ${describeError(error)}`, { + cause: error + }) + }; +} + +function isOpaqueFetchTypeError(error: unknown): boolean { + // 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'); +} + +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/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 3b8ddafe5a..5dea9a7cc5 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,27 @@ export class StreamableHTTPClientTransport implements Transport { }); } + /** + * 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)) { + 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 +563,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..f4b80511ca --- /dev/null +++ b/packages/client/src/client/versionNegotiation.ts @@ -0,0 +1,402 @@ +/** + * Connect-time protocol version negotiation (opt-in via + * `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. + * + * 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 { + 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, ProbeTransportKind, 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 the probe exchange, in milliseconds. + * + * The timeout verdict is transport-aware: on stdio, a probe that gets no + * 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 the standard request timeout (`DEFAULT_REQUEST_TIMEOUT_MSEC`, or the `timeout` passed to `connect()`) + */ + timeoutMs?: 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 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. + */ +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 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'; + +/** 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. 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, + 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 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 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'; +} + +/** 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' }; + +/** + * 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 { + 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 written back (see module doc). + }; + 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. 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}`; + 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 })); + }); + } + + /** 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; + 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): 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 }; + } + } +} + +export interface NegotiationDeps { + transport: Transport; + 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; +} + +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 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, + deps: NegotiationDeps +): Promise { + const timeoutMs = negotiation.probe.timeoutMs ?? deps.defaultTimeoutMs; + const clientModernVersions = negotiation.kind === 'pin' ? [negotiation.version] : negotiation.modernVersions; + const fallbackAvailable = negotiation.kind === 'auto' && negotiation.fallbackAvailable; + + const window = await ProbeWindow.open(deps.transport); + + 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 + // the second rejection. + let correctiveUsed = false; + for (;;) { + const reply = await window.exchange( + id => buildProbeRequest(id, requestedVersion, deps.clientInfo, deps.capabilities), + timeoutMs + ); + + const outcome = normalizeReply(reply, timeoutMs); + const verdict: ProbeVerdict = classifyProbeOutcome(outcome, { + clientModernVersions, + requestedVersion, + fallbackAvailable, + environment: deps.environment, + transportKind: deps.transportKind + }); + + 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') { + 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)` + ); + } + 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': { + throw verdict.error; + } + } + } + }; + + 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/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/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(); + }); +}); diff --git a/packages/client/test/client/probeClassifier.test.ts b/packages/client/test/client/probeClassifier.test.ts new file mode 100644 index 0000000000..5318d30b5e --- /dev/null +++ b/packages/client/test/client/probeClassifier.test.ts @@ -0,0 +1,306 @@ +/** + * 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', + transportKind: 'http' +}; + +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 — 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 }, { 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 }, { transportKind: 'stdio' })).toEqual({ kind: 'legacy' }); + }); +}); + +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'); + }); +}); diff --git a/packages/client/test/client/versionNegotiation.test.ts b/packages/client/test/client/versionNegotiation.test.ts new file mode 100644 index 0000000000..a358ca0c30 --- /dev/null +++ b/packages/client/test/client/versionNegotiation.test.ts @@ -0,0 +1,670 @@ +/** + * 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.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.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + expect(plainClient.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + + 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(); + }); + + 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. +}); + +/* ------------------------------------------------------------------------- * + * 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 (transport-aware)', () => { + const silentScript: Script = () => { + /* never replies */ + }; + + 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 } } }); + + 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([]); + }); + + /** 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 } } }); + + await client.connect(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(1); + expect(sent.some(r => r.method === 'initialize')).toBe(true); + 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); + }); +}); + +/* ------------------------------------------------------------------------- * + * -32004 corrective continuation — exactly once; loop guard on second + * rejection. + * ------------------------------------------------------------------------- */ + +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) => { + 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' } }); + await client.connect(transport); + + // The corrective continuation is spec-mandated: the second probe still happened. + expect(discoverCalls).toBe(2); + 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.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + 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.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); + }); + + 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(); + }); +}); + +/* ------------------------------------------------------------------------- * + * 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.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + 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.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + 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.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + 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.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.getNegotiatedProtocolVersion()).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.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.getNegotiatedProtocolVersion()).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); + }); +}); 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/protocol.ts b/packages/core/src/shared/protocol.ts index f9b9555171..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; }; @@ -732,13 +708,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/shared/protocolEras.ts b/packages/core/src/shared/protocolEras.ts new file mode 100644 index 0000000000..a85135fa06 --- /dev/null +++ b/packages/core/src/shared/protocolEras.ts @@ -0,0 +1,41 @@ +/** + * 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). + * + * 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. + */ +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), 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/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..9af48586c2 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -466,6 +466,7 @@ export type NotificationTypeMap = MethodToTypeMap; } /** 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/src/wire/rev2025-11-25/registry.ts b/packages/core/src/wire/rev2025-11-25/registry.ts index e865fb58ea..e81d1e90c8 100644 --- a/packages/core/src/wire/rev2025-11-25/registry.ts +++ b/packages/core/src/wire/rev2025-11-25/registry.ts @@ -9,8 +9,9 @@ * BEHAVIOR-FROZEN behind the Q10-L2 byte-identity suite: the request and * notification maps carry the full deliberate 2025-11-25 wire vocabulary, * including the task family (the #2248 wire-interop restore). The RESULT map - * is the runtime/typed ALIGNED map (PR #2293 review): keyed by - * `RequestMethod` so it cannot drift from the typed `ResultTypeMap` — no + * is the runtime/typed ALIGNED map (PR #2293 review): keyed by this era's + * subset of the typed `RequestMethod` set so it cannot drift from the typed + * `ResultTypeMap` — no * task-result union members and no `tasks/*` entries; a task-capable 2025 * peer's `CreateTaskResult` answer fails the plain per-method schema as a * typed invalid-result error, and callers needing task interop pass an @@ -87,15 +88,27 @@ export type Rev2025RequestMethod = WireRequest['method']; /** Every notification method in the 2025-era wire vocabulary. */ export type Rev2025NotificationMethod = WireNotification['method']; +/** + * The typed-method surface this era serves: the typed `RequestMethod` set + * minus methods whose wire vocabulary does not exist on this era (e.g. + * `server/discover`, which the typed maps carry but only the 2026-era + * registry serves). Deriving the subset from the era's own wire role unions + * keeps the both-direction drift guard: a typed 2025-era method without a map + * entry, or a map entry the era's wire vocabulary does not know, is a compile + * error. + */ +type Rev2025TypedRequestMethod = Extract; + /* 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/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/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'); + }); +}); 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', 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 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`. * diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index a82432d968..e783940536 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,16 +39,16 @@ import { CreateMessageResultSchema, CreateMessageResultWithToolsSchema, LATEST_PROTOCOL_VERSION, + legacyProtocolVersions, LoggingLevelSchema, mergeCapabilities, - negotiatedProtocolVersionOf, + modernProtocolVersions, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, SdkError, - SdkErrorCode, - setNegotiatedProtocolVersion + SdkErrorCode } from '@modelcontextprotocol/core'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; @@ -114,6 +115,12 @@ export class Server extends Protocol { this.setRequestHandler('initialize', request => this._oninitialize(request)); this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.()); + // 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()); + } + if (this._capabilities.logging) { this._registerLoggingHandler(); } @@ -207,7 +214,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), @@ -386,14 +393,18 @@ export class Server extends Protocol { this._clientCapabilities = request.params.capabilities; this._clientVersion = request.params.clientInfo; - const protocolVersion = this._supportedProtocolVersions.includes(requestedVersion) + // 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 - : (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 // receives from here on (legacy handshake ⇒ a legacy-era version). - setNegotiatedProtocolVersion(this, protocolVersion); + this._negotiatedProtocolVersion = protocolVersion; this.transport?.setProtocolVersion?.(protocolVersion); return { @@ -404,6 +415,21 @@ export class Server extends Protocol { }; } + /** + * 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 { + 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. */ @@ -424,7 +450,7 @@ export class Server extends Protocol { * `undefined` before initialization. */ getNegotiatedProtocolVersion(): string | undefined { - return negotiatedProtocolVersionOf(this); + return this._negotiatedProtocolVersion; } /** @@ -671,3 +697,28 @@ export class Server extends Protocol { return this.notification({ method: 'notifications/prompts/list_changed' }); } } + +/** + * 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 }; + 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/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 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(); }); /*** diff --git a/test/integration/test/client/discoverRoundtrip.test.ts b/test/integration/test/client/discoverRoundtrip.test.ts new file mode 100644 index 0000000000..cdf551d60e --- /dev/null +++ b/test/integration/test/client/discoverRoundtrip.test.ts @@ -0,0 +1,170 @@ +/** + * 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.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.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.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 + ); + }); +}); diff --git a/test/integration/test/client/versionNegotiation.test.ts b/test/integration/test/client/versionNegotiation.test.ts new file mode 100644 index 0000000000..a5aaee0148 --- /dev/null +++ b/test/integration/test/client/versionNegotiation.test.ts @@ -0,0 +1,288 @@ +/** + * 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), 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'; +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.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.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + 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, 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 } } } + ); + const transport = new StreamableHTTPClientTransport(url, { fetch: fetchFn }); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout + ); + + // 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); + 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); +}); + +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 } } } + ); + + try { + await client.connect(transport); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + expect(client.getServerVersion()?.name).toBe('silent-legacy-stdio-server'); + } finally { + await client.close(); + } + }, 15_000); +});