diff --git a/.changeset/envelope-auto-emission.md b/.changeset/envelope-auto-emission.md new file mode 100644 index 0000000000..d832c64d8d --- /dev/null +++ b/.changeset/envelope-auto-emission.md @@ -0,0 +1,11 @@ +--- +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/core': minor +--- + +Per-request `_meta` envelope auto-emission on modern-era connections: once a client negotiates a 2026-07-28+ protocol revision (via `versionNegotiation: { mode: 'auto' }` or `{ pin }`), it automatically attaches the reserved protocol-version / client-info / client-capabilities +`_meta` keys to every outgoing request and notification — you no longer set the envelope by hand. User-supplied `_meta` keys take precedence over the auto-attached ones; the auto-attached client-capabilities reflect what the client actually registered. Legacy-era connections +(the default, and the `'auto'`-mode fallback) never gain these keys, so 2025-era outbound traffic is byte-identical to before. + +Adds `Client.getProtocolEra()` (`'legacy' | 'modern' | undefined`), the `ProtocolEra` type, `Client.setVersionNegotiation()` for configuring negotiation pre-connect on an already-constructed instance, and the `probe.maxRetries` knob (default `0`) which governs probe-timeout +re-sends only — the spec-mandated `-32004` corrective continuation is never counted against it. The `versionNegotiation` default remains `'legacy'`: absent (or `mode: 'legacy'`), `connect()` runs the plain 2025 sequence, byte-identical to a v1.x client. diff --git a/docs/client.md b/docs/client.md index c2bb5b05b1..042ba2861a 100644 --- a/docs/client.md +++ b/docs/client.md @@ -88,6 +88,26 @@ try { For a complete example with error reporting, see [`streamableHttpWithSseFallbackClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/streamableHttpWithSseFallbackClient.ts). +### Protocol version negotiation (2026-07-28 revision) + +By default the client negotiates a 2025-era protocol version via the `initialize` handshake — exactly the v1.x behavior, byte for byte. To talk to a server on the 2026-07-28 revision, opt into version negotiation via `ClientOptions.versionNegotiation`: + +```ts source="../examples/client/src/clientGuide.examples.ts#Client_versionNegotiation" +// Auto-negotiate: probe with server/discover, fall back to the 2025 handshake +// against a 2025-only server. +const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); +await client.connect(transport); + +client.getProtocolEra(); // 'modern' or 'legacy' +client.getNegotiatedProtocolVersion(); // '2026-07-28' or '2025-11-25' +``` + +- **absent / `mode: 'legacy'` (the default)** — today's 2025 connect sequence; no probe, no new headers. +- **`mode: 'auto'`** — `connect()` probes with `server/discover`; a 2025-only server rejects the probe and the client falls back to the plain `initialize` handshake on the same connection, byte-equivalent to a 2025 client. The probe costs one round trip against an old server. +- **`mode: { pin: '2026-07-28' }`** — modern era at exactly that revision; no fallback. Against a 2025-only server `connect()` rejects with a typed error. Use `pin` where a silent downgrade would be worse than an error (tests, CI, servers you control). + +Once a modern era is negotiated, the client automatically attaches the per-request `_meta` envelope (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification. You can also configure negotiation pre-connect on an already-constructed instance via {@linkcode @modelcontextprotocol/client!client/client.Client#setVersionNegotiation | client.setVersionNegotiation()}. See the [migration guide](./migration.md#opt-in-protocol-version-negotiation-2026-07-28-draft) for the full failure semantics, probe policy, and the `'auto'`-mode compatibility table. + ### Disconnecting Call {@linkcode @modelcontextprotocol/client!client/client.Client#close | await client.close() } to disconnect. Pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error. diff --git a/docs/migration.md b/docs/migration.md index 688d4e4bbe..445304b986 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1024,15 +1024,22 @@ Probe policy is configured under `versionNegotiation.probe`: versionNegotiation: { mode: 'auto', probe: { - timeoutMs: 10_000 // default: the standard request timeout + timeoutMs: 10_000, // default: the standard request timeout + maxRetries: 0 // default: no retries — governs timeout re-sends only } } ``` +`maxRetries` governs timeout re-sends only (the spec-mandated `-32004` corrective continuation — select-and-continue with a mutual version — is a separate negotiation step and is never counted against it). Negotiation can also be configured pre-connect on an +already-constructed instance via `client.setVersionNegotiation(options)` (equivalent to the constructor option; throws after connecting). + +Once a modern era is negotiated, the client **automatically attaches the per-request `_meta` envelope** (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification — you never set it by hand. Any `_meta` keys you pass +in a request are preserved over the auto-attached ones. After connect, `client.getProtocolEra()` returns `'legacy'` or `'modern'` and `client.getNegotiatedProtocolVersion()` the exact revision. + On the server side, `server/discover` (advertising only the modern revisions) is served by instances hosted through one of the 2026-era serving entries; a hand-constructed `Server`/`McpServer` is byte-identical to before (it keeps answering `-32601`, and the `initialize` -handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the next section; serving it on stdio (and other -long-lived connections) is the `serveStdio` entry point described after that. The client can also issue the request directly via `client.discover()` on a 2026-era connection — a full typed round trip needs each request to carry the per-request `_meta` envelope (the negotiation -probe already does; automatic envelope emission for every request is a client-side follow-up) — while on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that protocol revision. +handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the next section; serving it on stdio (and +other long-lived connections) is the `serveStdio` entry point described after that. The client can also issue `client.discover()` directly on a 2026-era connection; on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that +protocol revision. ### Serving the 2026-07-28 draft revision over HTTP: `createMcpHandler` diff --git a/examples/client/src/clientGuide.examples.ts b/examples/client/src/clientGuide.examples.ts index 99a8383bc8..b44f78a223 100644 --- a/examples/client/src/clientGuide.examples.ts +++ b/examples/client/src/clientGuide.examples.ts @@ -78,6 +78,19 @@ async function connect_sseFallback(url: string) { //#endregion connect_sseFallback } +/** Example: Opt into 2026-07-28 protocol version negotiation. */ +async function Client_versionNegotiation(transport: StreamableHTTPClientTransport) { + //#region Client_versionNegotiation + // Auto-negotiate: probe with server/discover, fall back to the 2025 handshake + // against a 2025-only server. + const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(transport); + + client.getProtocolEra(); // 'modern' or 'legacy' + client.getNegotiatedProtocolVersion(); // '2026-07-28' or '2025-11-25' + //#endregion Client_versionNegotiation +} + // --------------------------------------------------------------------------- // Disconnecting // --------------------------------------------------------------------------- @@ -599,6 +612,7 @@ async function resumptionToken_basic(client: Client) { void connect_streamableHttp; void connect_stdio; void connect_sseFallback; +void Client_versionNegotiation; void disconnect_streamableHttp; void serverInstructions_basic; void auth_tokenProvider; diff --git a/examples/client/src/dualEraStdioClient.ts b/examples/client/src/dualEraStdioClient.ts index a8b4a6e317..9a9f6fe864 100644 --- a/examples/client/src/dualEraStdioClient.ts +++ b/examples/client/src/dualEraStdioClient.ts @@ -6,10 +6,8 @@ * 1. a plain 2025 client — the `initialize` handshake, served exactly as today; * 2. a 2026-capable client (`versionNegotiation: { mode: 'auto' }`) — the * `server/discover` probe negotiates the 2026-07-28 revision on the pipe - * (no `initialize` is ever sent), and each modern request carries the - * per-request `_meta` envelope. (Attaching the envelope explicitly is a - * stop-gap: automatic per-request envelope emission is a client-side - * follow-up.) + * (no `initialize` is ever sent), and the client attaches the per-request + * `_meta` envelope to every outgoing request itself. * * The client spawns the server example directly from source over stdio: * @@ -17,7 +15,7 @@ */ import path from 'node:path'; -import { Client, CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/client'; +import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; // Spawn the sibling server example straight from its source (no build step), @@ -46,20 +44,9 @@ async function modernLeg(): Promise { const client = new Client({ name: 'modern-demo-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(new StdioClientTransport(SERVER)); - const negotiated = client.getNegotiatedProtocolVersion(); - console.log('negotiated protocol version:', negotiated); - - // The per-request envelope every 2026-era request carries on the wire. - const envelope = { - [PROTOCOL_VERSION_META_KEY]: negotiated, - [CLIENT_INFO_META_KEY]: { name: 'modern-demo-client', version: '1.0.0' }, - [CLIENT_CAPABILITIES_META_KEY]: {} - }; + console.log('negotiated protocol version:', client.getNegotiatedProtocolVersion()); - const result = await client.request({ - method: 'tools/call', - params: { name: 'greet', arguments: { name: '2026 client' }, _meta: envelope } - }); + const result = await client.callTool({ name: 'greet', arguments: { name: '2026 client' } }); console.log('greet result:', JSON.stringify(result.content)); await client.close(); } diff --git a/examples/client/src/multiRoundTripClient.ts b/examples/client/src/multiRoundTripClient.ts index 68068806a1..13921bdd95 100644 --- a/examples/client/src/multiRoundTripClient.ts +++ b/examples/client/src/multiRoundTripClient.ts @@ -16,33 +16,13 @@ * Start the server first, then: * * tsx examples/client/src/multiRoundTripClient.ts - * - * (Attaching the per-request `_meta` envelope explicitly is a stop-gap; - * automatic envelope emission for every request is a client-side follow-up.) */ import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/client'; -import { - Client, - CLIENT_CAPABILITIES_META_KEY, - CLIENT_INFO_META_KEY, - isInputRequiredResult, - PROTOCOL_VERSION_META_KEY, - StreamableHTTPClientTransport -} from '@modelcontextprotocol/client'; +import { Client, isInputRequiredResult, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; const URL = process.env.MCP_SERVER_URL ?? 'http://localhost:3000/'; const CLIENT_INFO = { name: 'mrtr-example-client', version: '1.0.0' }; -// Per-request envelope every 2026-era request carries on the wire. The -// declared client capabilities are what the server's −32003 check reads. -function envelope(negotiated: string): Record { - return { - [PROTOCOL_VERSION_META_KEY]: negotiated, - [CLIENT_INFO_META_KEY]: CLIENT_INFO, - [CLIENT_CAPABILITIES_META_KEY]: { elicitation: { form: {}, url: {} } } - }; -} - async function autoFulfilLeg(): Promise { console.log('--- auto-fulfilment (the default) ---'); const client = new Client(CLIENT_INFO, { @@ -62,15 +42,11 @@ async function autoFulfilLeg(): Promise { }); await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); - const negotiated = client.getNegotiatedProtocolVersion()!; - console.log('negotiated protocol version:', negotiated); + console.log('negotiated protocol version:', client.getNegotiatedProtocolVersion()); // callTool returns a plain CallToolResult — the interactive rounds happen // inside the call. - const result = await client.request({ - method: 'tools/call', - params: { name: 'deploy', arguments: { env: 'prod' }, _meta: envelope(negotiated) } - }); + const result = await client.callTool({ name: 'deploy', arguments: { env: 'prod' } }); console.log('deploy result:', JSON.stringify(result.content)); await client.close(); } @@ -83,7 +59,6 @@ async function manualLeg(): Promise { inputRequired: { autoFulfill: false } }); await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); - const negotiated = client.getNegotiatedProtocolVersion()!; let inputResponses: Record | undefined; let requestState: string | undefined; @@ -98,7 +73,6 @@ async function manualLeg(): Promise { params: { name: 'deploy', arguments: { env: 'staging' }, - _meta: envelope(negotiated), ...(inputResponses && { inputResponses }), ...(requestState && { requestState }) } diff --git a/examples/server/src/dualEraStreamableHttp.ts b/examples/server/src/dualEraStreamableHttp.ts index 5891bb5bc4..0ade70f793 100644 --- a/examples/server/src/dualEraStreamableHttp.ts +++ b/examples/server/src/dualEraStreamableHttp.ts @@ -21,12 +21,10 @@ * Run with `tsx examples/server/src/dualEraStreamableHttp.ts`, then point any * plain 2025 client at http://localhost:3000/mcp (served through the legacy * fallback unless `reject` is selected). A `versionNegotiation: { mode: 'auto' }` - * client negotiates 2026-07-28 against the same endpoint, but automatic - * envelope emission for every request is still a client-side follow-up: - * ordinary typed calls (for example `callTool`) must attach the per-request - * `_meta` envelope explicitly for now (see - * `test/integration/test/server/createMcpHandler.test.ts` for the pattern), - * or the endpoint rejects them on the header/body cross-check. + * client negotiates 2026-07-28 against the same endpoint and attaches the + * per-request `_meta` envelope itself once a modern era is negotiated, so + * ordinary typed calls (for example `callTool`) work against the modern leg + * without any per-call plumbing. */ import { createMcpExpressApp } from '@modelcontextprotocol/express'; import type { CallToolResult, CreateMcpHandlerOptions, McpRequestContext } from '@modelcontextprotocol/server'; diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 0d9d40c6af..4eef2f2ec8 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -34,6 +34,7 @@ import type { MessageExtraInfo, NonCompleteResultFlow, NotificationMethod, + ProtocolEra, ProtocolOptions, ReadResourceRequest, ReadResourceResult, @@ -49,6 +50,8 @@ import type { UnsubscribeRequest } from '@modelcontextprotocol/core'; import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, codecForVersion, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, @@ -61,6 +64,7 @@ import { mergeCapabilities, parseSchema, Protocol, + PROTOCOL_VERSION_META_KEY, ProtocolError, ProtocolErrorCode, resolveInputRequiredDriverConfig, @@ -165,8 +169,11 @@ export type ClientOptions = ProtocolOptions & { /** * 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). + * **The default is `'legacy'`**: absent (or `mode: 'legacy'`), `connect()` + * runs the plain 2025 sequence, byte-identical to today's behavior (no + * probe, no new headers). Opt into `'auto'` or pin to talk to a 2026-07-28 + * server. + * * - `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` @@ -179,8 +186,15 @@ export type ClientOptions = ProtocolOptions & { * - `mode: { pin: '2026-07-28' }` — modern era at exactly the pinned revision; * no probe-and-fallback: anything else fails loudly. * - * Probe policy lives under `probe: { timeoutMs? }`; the probe inherits the - * client's standard request timeout unless overridden. + * Probe policy lives under `probe: { timeoutMs?, maxRetries? }`; the probe + * inherits the client's standard request timeout unless overridden, and + * `maxRetries` (default `0`) governs timeout re-sends only — the + * spec-mandated `-32004` corrective continuation is never counted against it. + * + * Once a modern era is negotiated, the client automatically attaches the + * per-request `_meta` envelope (the reserved protocol-version / client-info / + * client-capabilities keys) to every outgoing request and notification; + * user-supplied `_meta` keys take precedence over the auto-attached ones. */ versionNegotiation?: VersionNegotiationOptions; @@ -334,6 +348,30 @@ export class Client extends Protocol { return undefined; } + /** + * Per-request `_meta` envelope auto-emission (protocol revision 2026-07-28): + * on a connection that negotiated a modern era — auto-negotiated or pinned — + * every outgoing request and notification automatically carries the reserved + * protocol-version / client-info / client-capabilities `_meta` keys (the + * same envelope the connect-time `server/discover` probe sends). + * User-supplied `_meta` keys take precedence over the auto-attached ones. + * + * Legacy-era connections return `undefined`: the envelope seam is a no-op + * and outbound traffic is byte-identical to a 2025 client (the legacy + * `'auto'` fallback included). + */ + protected override _outboundMetaEnvelope(): Readonly> | undefined { + const version = this._negotiatedProtocolVersion; + if (version === undefined || !isModernProtocolVersion(version)) { + return undefined; + } + return { + [PROTOCOL_VERSION_META_KEY]: version, + [CLIENT_INFO_META_KEY]: this._clientInfo, + [CLIENT_CAPABILITIES_META_KEY]: this._capabilities + }; + } + /** * Wires the multi-round-trip auto-fulfilment engine (protocol revision * 2026-07-28) into the response funnel: an `input_required` answer is @@ -412,6 +450,21 @@ export class Client extends Protocol { this._capabilities = mergeCapabilities(this._capabilities, capabilities); } + /** + * Configure protocol version negotiation before connecting (equivalent to + * passing `versionNegotiation` at construction time). Can only be called + * before connecting to a transport. Passing `undefined` clears a previously + * configured negotiation, restoring the default `'legacy'` posture. + * + * See {@linkcode ClientOptions | ClientOptions.versionNegotiation} for the mode semantics. + */ + public setVersionNegotiation(options: VersionNegotiationOptions | undefined): void { + if (this.transport) { + throw new Error('Cannot configure version negotiation after connecting to transport'); + } + this._versionNegotiation = options; + } + /** * Enforces client-side validation for `elicitation/create` and `sampling/createMessage` * regardless of how the handler was registered. @@ -779,6 +832,19 @@ export class Client extends Protocol { return this._negotiatedProtocolVersion; } + /** + * After initialization has completed, this returns the protocol era of the + * connection: `'modern'` when the connection negotiated a 2026-07-28+ + * revision (via `server/discover`), `'legacy'` for the 2025-era + * `initialize` handshake, or `undefined` before the connection is + * established. + */ + getProtocolEra(): ProtocolEra | undefined { + const version = this._negotiatedProtocolVersion; + if (version === undefined) return undefined; + return isModernProtocolVersion(version) ? 'modern' : 'legacy'; + } + /** * After initialization has completed, this may be populated with information about the server's instructions. */ diff --git a/packages/client/src/client/versionNegotiation.ts b/packages/client/src/client/versionNegotiation.ts index f4b80511ca..710b4d399b 100644 --- a/packages/client/src/client/versionNegotiation.ts +++ b/packages/client/src/client/versionNegotiation.ts @@ -49,6 +49,17 @@ export interface VersionNegotiationProbeOptions { * @default the standard request timeout (`DEFAULT_REQUEST_TIMEOUT_MSEC`, or the `timeout` passed to `connect()`) */ timeoutMs?: number; + + /** + * Number of times to re-send the probe after a timeout before reaching the + * timeout verdict. Governs timeout re-sends only — the spec-mandated + * `-32004` corrective continuation (select-and-continue with a mutual + * version) is a separate negotiation step and is never counted against + * `maxRetries`. + * + * @default 0 (no retries) + */ + maxRetries?: number; } /** @@ -323,6 +334,7 @@ export async function negotiateEra( deps: NegotiationDeps ): Promise { const timeoutMs = negotiation.probe.timeoutMs ?? deps.defaultTimeoutMs; + const maxRetries = Math.max(0, negotiation.probe.maxRetries ?? 0); const clientModernVersions = negotiation.kind === 'pin' ? [negotiation.version] : negotiation.modernVersions; const fallbackAvailable = negotiation.kind === 'auto' && negotiation.fallbackAvailable; @@ -334,12 +346,20 @@ export async function negotiateEra( // mutual version equals the just-rejected one); the loop guard arms on // the second rejection. let correctiveUsed = false; + // `maxRetries` governs timeout re-sends only — independent of (and + // never counted against) the corrective continuation. + let timeoutRetriesRemaining = maxRetries; for (;;) { const reply = await window.exchange( id => buildProbeRequest(id, requestedVersion, deps.clientInfo, deps.capabilities), timeoutMs ); + if (reply.kind === 'timeout' && timeoutRetriesRemaining > 0) { + timeoutRetriesRemaining--; + continue; + } + const outcome = normalizeReply(reply, timeoutMs); const verdict: ProbeVerdict = classifyProbeOutcome(outcome, { clientModernVersions, diff --git a/packages/client/test/client/envelopeAutoEmission.test.ts b/packages/client/test/client/envelopeAutoEmission.test.ts new file mode 100644 index 0000000000..307baa1a9e --- /dev/null +++ b/packages/client/test/client/envelopeAutoEmission.test.ts @@ -0,0 +1,248 @@ +/** + * Per-request `_meta` envelope auto-emission (protocol revision 2026-07-28): + * on a connection that negotiated the modern era — auto-negotiated or pinned — + * the client automatically attaches the reserved protocol-version / + * client-info / client-capabilities `_meta` keys to every outgoing request and + * notification. User-supplied `_meta` keys win over the auto-attached ones. + * Legacy-era connections never gain these keys (D9b byte-identity holds). + */ +import type { JSONRPCMessage } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + InMemoryTransport, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { Client } from '../../src/client/client.js'; + +const MODERN = '2026-07-28'; + +const flush = () => new Promise(resolve => setTimeout(resolve, 20)); + +function metaOf(message: JSONRPCMessage): Record | undefined { + const params = (message as { params?: { _meta?: Record } }).params; + return params?._meta; +} + +/** + * A scripted server side of an in-memory pair: answers `server/discover` (so a + * negotiating client lands on the modern era) or `initialize` (legacy era), and + * records everything the client writes. + */ +async function scriptedServerSide(era: 'modern' | 'legacy', answerToolsList = true) { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + serverTx.onmessage = message => { + written.push(message); + const request = message as { id?: number | string; method?: string }; + if (request.method === 'server/discover' && request.id !== undefined) { + if (era === 'modern') { + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: {} }, + serverInfo: { name: 'scripted-modern-server', version: '1.0.0' } + } + }); + } else { + void serverTx.send({ jsonrpc: '2.0', id: request.id, error: { code: -32_601, message: 'Method not found' } }); + } + return; + } + if (request.method === 'initialize' && request.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: 'scripted-legacy-server', version: '1.0.0' } + } + }); + return; + } + if (request.method === 'tools/list' && request.id !== undefined && answerToolsList) { + const result: Record = + era === 'modern' ? { resultType: 'complete', tools: [], ttlMs: 0, cacheScope: 'public' } : { tools: [] }; + void serverTx.send({ jsonrpc: '2.0', id: request.id, result }); + } + }; + await serverTx.start(); + return { clientTx, written }; +} + +describe('per-request _meta envelope auto-emission on modern-era connections', () => { + it('attaches the reserved envelope keys to every outgoing request and notification', async () => { + const { clientTx, written } = await scriptedServerSide('modern'); + const clientInfo = { name: 'envelope-client', version: '1.2.3' }; + const client = new Client(clientInfo, { + versionNegotiation: { mode: 'auto' }, + capabilities: { elicitation: { form: {} } } + }); + await client.connect(clientTx); + expect(client.getProtocolEra()).toBe('modern'); + + await client.listTools(); + await client.notification({ method: 'notifications/progress', params: { progressToken: 't', progress: 1 } }); + await flush(); + + const listToolsMessage = written.find(m => (m as { method?: string }).method === 'tools/list'); + expect(listToolsMessage).toBeDefined(); + expect(metaOf(listToolsMessage!)).toEqual({ + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: clientInfo, + [CLIENT_CAPABILITIES_META_KEY]: { elicitation: { form: {} } } + }); + + const progressMessage = written.find(m => (m as { method?: string }).method === 'notifications/progress'); + expect(progressMessage).toBeDefined(); + expect(metaOf(progressMessage!)?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN); + expect(metaOf(progressMessage!)?.[CLIENT_INFO_META_KEY]).toEqual(clientInfo); + + await client.close(); + }); + + it('reflects registered client capabilities in the auto-attached client-capabilities key', async () => { + const { clientTx, written } = await scriptedServerSide('modern'); + const client = new Client({ name: 'envelope-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + client.registerCapabilities({ sampling: {} }); + await client.connect(clientTx); + + await client.listTools(); + const listToolsMessage = written.find(m => (m as { method?: string }).method === 'tools/list'); + expect(metaOf(listToolsMessage!)?.[CLIENT_CAPABILITIES_META_KEY]).toEqual({ sampling: {} }); + + await client.close(); + }); + + it('user-supplied _meta keys win over the auto-attached envelope keys; non-envelope keys are preserved', async () => { + const { clientTx, written } = await scriptedServerSide('modern'); + const client = new Client({ name: 'envelope-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(clientTx); + + await client.request({ + method: 'tools/list', + params: { _meta: { [PROTOCOL_VERSION_META_KEY]: 'consumer-override', 'x-consumer': 'kept' } } + }); + const listToolsMessage = written.find(m => (m as { method?: string }).method === 'tools/list'); + const meta = metaOf(listToolsMessage!); + expect(meta?.[PROTOCOL_VERSION_META_KEY]).toBe('consumer-override'); + expect(meta?.['x-consumer']).toBe('kept'); + // The other envelope keys are still auto-attached. + expect(meta?.[CLIENT_INFO_META_KEY]).toEqual({ name: 'envelope-client', version: '1.0.0' }); + + await client.close(); + }); + + it('attaches the envelope to the cancellation notification of a modern-era request', async () => { + const { clientTx, written } = await scriptedServerSide('modern', /* answerToolsList */ false); + const client = new Client({ name: 'envelope-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(clientTx); + + const controller = new AbortController(); + const pending = client.listTools(undefined, { signal: controller.signal }).catch(() => {}); + await flush(); + controller.abort('test cancel'); + await pending; + await flush(); + + const cancelMessage = written.find(m => (m as { method?: string }).method === 'notifications/cancelled'); + expect(cancelMessage).toBeDefined(); + expect(metaOf(cancelMessage!)?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN); + + await client.close(); + }); + + it('legacy-era connections never gain the envelope keys (byte-identity with a 2025 client)', async () => { + const { clientTx, written } = await scriptedServerSide('legacy'); + const client = new Client({ name: 'envelope-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + expect(client.getProtocolEra()).toBe('legacy'); + + await client.listTools(); + await flush(); + + // initialize, notifications/initialized, tools/list — none carry envelope keys. + const postProbe = written.filter(m => (m as { method?: string }).method !== 'server/discover'); + expect(postProbe.length).toBeGreaterThanOrEqual(3); + for (const message of postProbe) { + const meta = metaOf(message); + expect(meta?.[PROTOCOL_VERSION_META_KEY]).toBeUndefined(); + expect(meta?.[CLIENT_INFO_META_KEY]).toBeUndefined(); + expect(meta?.[CLIENT_CAPABILITIES_META_KEY]).toBeUndefined(); + } + + await client.close(); + }); + + it('the plain legacy default (no versionNegotiation) emits no envelope keys at all', async () => { + const { clientTx, written } = await scriptedServerSide('legacy'); + const client = new Client({ name: 'envelope-client', version: '1.0.0' }); + await client.connect(clientTx); + expect(client.getProtocolEra()).toBe('legacy'); + + await client.listTools(); + await flush(); + + for (const message of written) { + const meta = metaOf(message); + expect(meta?.[PROTOCOL_VERSION_META_KEY]).toBeUndefined(); + } + // initialize body matches today's plain client (no probe was ever sent). + expect(written.some(m => (m as { method?: string }).method === 'server/discover')).toBe(false); + + await client.close(); + }); +}); + +describe('setVersionNegotiation()', () => { + it('configures negotiation pre-connect (equivalent to the constructor option)', async () => { + const { clientTx } = await scriptedServerSide('modern'); + const client = new Client({ name: 'setter-client', version: '1.0.0' }); + client.setVersionNegotiation({ mode: { pin: MODERN } }); + await client.connect(clientTx); + expect(client.getProtocolEra()).toBe('modern'); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + await client.close(); + }); + + it('throws after connecting to a transport', async () => { + const { clientTx } = await scriptedServerSide('legacy'); + const client = new Client({ name: 'setter-client', version: '1.0.0' }); + await client.connect(clientTx); + expect(() => client.setVersionNegotiation({ mode: 'auto' })).toThrow(/after connecting/); + await client.close(); + }); + + it('passing undefined clears a previously configured negotiation (back to the legacy default)', async () => { + const { clientTx } = await scriptedServerSide('legacy'); + const client = new Client({ name: 'setter-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + client.setVersionNegotiation(undefined); + await client.connect(clientTx); + expect(client.getProtocolEra()).toBe('legacy'); + await client.close(); + }); +}); + +describe('getProtocolEra()', () => { + it('is undefined before connect, "legacy" after a 2025 handshake, "modern" after a 2026-07-28 negotiation', async () => { + const legacy = await scriptedServerSide('legacy'); + const legacyClient = new Client({ name: 'era-client', version: '1.0.0' }); + expect(legacyClient.getProtocolEra()).toBeUndefined(); + await legacyClient.connect(legacy.clientTx); + expect(legacyClient.getProtocolEra()).toBe('legacy'); + await legacyClient.close(); + + const modern = await scriptedServerSide('modern'); + const modernClient = new Client({ name: 'era-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await modernClient.connect(modern.clientTx); + expect(modernClient.getProtocolEra()).toBe('modern'); + await modernClient.close(); + }); +}); diff --git a/packages/client/test/client/versionNegotiation.test.ts b/packages/client/test/client/versionNegotiation.test.ts index a358ca0c30..9187fa141b 100644 --- a/packages/client/test/client/versionNegotiation.test.ts +++ b/packages/client/test/client/versionNegotiation.test.ts @@ -397,6 +397,44 @@ describe('probe timeout policy (transport-aware)', () => { ); expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); }); + + test('maxRetries (default 0) governs timeout re-sends only; the timeout verdict applies after retries are exhausted', async () => { + // HTTP-class: even with retries, a server that never answers produces a + // typed timeout error after maxRetries+1 probe sends — never a legacy verdict. + const transport = new ScriptedTransport(silentScript); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 20, maxRetries: 2 } } } + ); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout + ); + const probes = transport.sent.filter(m => 'method' in m && m.method === 'server/discover'); + expect(probes).toHaveLength(3); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); + + test('maxRetries: a server that answers on the first retry resolves normally (the retry budget is timeout-only)', async () => { + let discoverCalls = 0; + const slowThenFastScript: Script = (message, t) => { + if (!isJSONRPCRequest(message) || message.method !== 'server/discover') return; + discoverCalls++; + // Ignore the first probe (forces a timeout); answer the retry. + if (discoverCalls === 1) return; + t.reply({ jsonrpc: '2.0', id: message.id, result: discoverResult([MODERN]) }); + }; + const transport = new ScriptedTransport(slowThenFastScript); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 20, maxRetries: 1 } } } + ); + + await client.connect(transport); + expect(discoverCalls).toBe(2); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + await client.close(); + }); }); /* ------------------------------------------------------------------------- * diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 3257f6df2d..b5e3d29c3b 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -90,6 +90,9 @@ export { TRACESTATE_META_KEY } from '../../types/constants.js'; +// Protocol-era helpers +export type { ProtocolEra } from '../../shared/protocolEras.js'; + // Enums export { ProtocolErrorCode } from '../../types/enums.js'; diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index d60bfc423a..873aa6f92a 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -569,6 +569,39 @@ export abstract class Protocol { return undefined; } + /** + * The per-request `_meta` envelope this instance attaches to every outgoing + * request and notification, when one applies. The base implementation + * returns `undefined` (no envelope — the 2025-era posture, so legacy-era + * outbound traffic is byte-identical to a build without this seam). + * `Client` overrides it on a connection that negotiated a modern (2026-07-28+) + * era to return the reserved protocol-version / client-info / + * client-capabilities keys. User-supplied `_meta` keys take precedence over + * the auto-attached ones. + */ + protected _outboundMetaEnvelope(): Readonly> | undefined { + return undefined; + } + + /** + * Attach this instance's outbound `_meta` envelope (when one is configured) + * to a request or notification. A no-op when the seam returns `undefined` + * — the message returns by reference, so the legacy-era wire stays + * byte-identical. User-supplied `_meta` keys are spread last so they win + * over the auto-attached envelope keys. + */ + private _envelopeOutbound(message: T): T { + const envelope = this._outboundMetaEnvelope(); + if (envelope === undefined) { + return message; + } + const params = (message.params ?? {}) as { _meta?: Record }; + return { + ...message, + params: { ...params, _meta: { ...envelope, ...params._meta } } + }; + } + /** * Extension point for non-`complete` decoded results in the response * funnel: a result the wire codec discriminated into a kind other than @@ -1262,6 +1295,12 @@ export abstract class Protocol { }; } + // Per-request envelope auto-attach (after the progressToken merge so + // both share the same `_meta`): a no-op on the legacy era — the + // envelope seam returns undefined and the request goes out exactly as + // built above. + const outbound = this._envelopeOutbound(jsonrpcRequest); + let responseReceived = false; const cancel = (reason: unknown) => { @@ -1272,14 +1311,14 @@ export abstract class Protocol { this._transport ?.send( - { + this._envelopeOutbound({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: messageId, reason: String(reason) } - }, + }), { relatedRequestId, resumptionToken, onresumptiontoken } ) .catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`))); @@ -1367,7 +1406,7 @@ export abstract class Protocol { this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); - this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { + this._transport.send(outbound, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { this._progressHandlers.delete(messageId); reject(error); }); @@ -1415,7 +1454,7 @@ export abstract class Protocol { this.assertNotificationCapability(notification.method); - const jsonrpcNotification: JSONRPCNotification = { jsonrpc: '2.0', ...notification }; + const jsonrpcNotification = this._envelopeOutbound({ jsonrpc: '2.0' as const, ...notification }); const debouncedMethods = this._options?.debouncedNotificationMethods ?? []; // A notification can only be debounced if it's in the list AND it's "simple" diff --git a/packages/core/src/shared/protocolEras.ts b/packages/core/src/shared/protocolEras.ts index a85135fa06..bfe85242e2 100644 --- a/packages/core/src/shared/protocolEras.ts +++ b/packages/core/src/shared/protocolEras.ts @@ -11,6 +11,13 @@ * modern revisions. */ +/** + * The protocol era of a connection: `'legacy'` for the 2025-11-25 family and + * earlier (negotiated via `initialize`), `'modern'` for 2026-07-28 and later + * (negotiated via `server/discover`; every request carries a `_meta` envelope). + */ +export type ProtocolEra = 'legacy' | 'modern'; + /** * The first protocol revision of the modern (2026-07-28) era. Revision identifiers * are ISO dates, so lexicographic comparison orders them chronologically. diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts index a619678bec..3b61675c96 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -14,12 +14,9 @@ import { Client, - CLIENT_CAPABILITIES_META_KEY, - CLIENT_INFO_META_KEY, ClientCredentialsProvider, CrossAppAccessProvider, PrivateKeyJwtProvider, - PROTOCOL_VERSION_META_KEY, requestJwtAuthorizationGrant, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; @@ -118,19 +115,6 @@ function isModernConformanceRun(): boolean { return version !== undefined && MODERN_SPEC_VERSIONS.has(version); } -/** - * The per-request `_meta` envelope every 2026-era request carries on the wire. - * Automatic envelope emission is not implemented in the client yet (it is a - * client-side follow-up), so modern-era requests attach it explicitly. - */ -function modernEnvelope(clientInfo: { name: string; version: string }, capabilities: object, protocolVersion: string | undefined) { - return { - [PROTOCOL_VERSION_META_KEY]: protocolVersion ?? '2026-07-28', - [CLIENT_INFO_META_KEY]: clientInfo, - [CLIENT_CAPABILITIES_META_KEY]: capabilities - }; -} - // ============================================================================ // Basic scenarios (initialize, tools_call) // ============================================================================ @@ -181,27 +165,25 @@ async function runToolsCallClient(serverUrl: string): Promise { } // tools_call under a 2026-07-28 run: negotiate the modern era via -// server/discover (versionNegotiation), then drive the same tool flow with -// the per-request _meta envelope attached to every request. +// server/discover (versionNegotiation), then drive the same tool flow — the +// client attaches the per-request _meta envelope to every request itself. async function runToolsCallModernClient(serverUrl: string): Promise { - const clientInfo = { name: 'test-client', version: '1.0.0' }; - const client = new Client(clientInfo, { capabilities: {}, versionNegotiation: { mode: 'auto' } }); + const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {}, versionNegotiation: { mode: 'auto' } }); const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); await client.connect(transport); logger.debug('Negotiated protocol version:', client.getNegotiatedProtocolVersion()); - const envelope = modernEnvelope(clientInfo, {}, client.getNegotiatedProtocolVersion()); - const tools = await client.request({ method: 'tools/list', params: { _meta: envelope } }); + const tools = await client.listTools(); logger.debug('Successfully listed tools'); // Call the add_numbers tool const addTool = tools.tools.find(t => t.name === 'add_numbers'); if (addTool) { - const result = await client.request({ - method: 'tools/call', - params: { name: 'add_numbers', arguments: { a: 5, b: 3 }, _meta: envelope } + const result = await client.callTool({ + name: 'add_numbers', + arguments: { a: 5, b: 3 } }); logger.debug('Tool call result:', JSON.stringify(result, null, 2)); } @@ -302,21 +284,19 @@ async function runMrtrClient(serverUrl: string): Promise { await client.connect(transport); logger.debug('Negotiated protocol version:', client.getNegotiatedProtocolVersion()); - const envelope = modernEnvelope(clientInfo, capabilities, client.getNegotiatedProtocolVersion()); - // requestState echo flow: the driver must echo the opaque state byte-exact // and retry on a fresh JSON-RPC id. - const echoResult = await client.callTool({ name: 'test_mrtr_echo_state', arguments: {}, _meta: envelope }); + const echoResult = await client.callTool({ name: 'test_mrtr_echo_state', arguments: {} }); logger.debug('test_mrtr_echo_state result:', JSON.stringify(echoResult)); // No-state flow: the InputRequiredResult carries no requestState, so the // retry must not include one. - const noStateResult = await client.callTool({ name: 'test_mrtr_no_state', arguments: {}, _meta: envelope }); + const noStateResult = await client.callTool({ name: 'test_mrtr_no_state', arguments: {} }); logger.debug('test_mrtr_no_state result:', JSON.stringify(noStateResult)); // Unrelated call: must not carry inputResponses or requestState from the // multi-round-trip flows above. - const unrelatedResult = await client.callTool({ name: 'test_mrtr_unrelated', arguments: {}, _meta: envelope }); + const unrelatedResult = await client.callTool({ name: 'test_mrtr_unrelated', arguments: {} }); logger.debug('test_mrtr_unrelated result:', JSON.stringify(unrelatedResult)); // Result without resultType: the check passes as long as the client does @@ -324,7 +304,7 @@ async function runMrtrClient(serverUrl: string): Promise { // a 2026-negotiated server as a protocol violation and rejects locally // without retrying, so this call is expected to throw. try { - const noResultTypeResult = await client.callTool({ name: 'test_mrtr_no_result_type', arguments: {}, _meta: envelope }); + const noResultTypeResult = await client.callTool({ name: 'test_mrtr_no_result_type', arguments: {} }); logger.debug('test_mrtr_no_result_type result:', JSON.stringify(noResultTypeResult)); } catch (error) { logger.debug('test_mrtr_no_result_type rejected locally (no retry):', error instanceof Error ? error.message : String(error)); diff --git a/test/e2e/CLAUDE.md b/test/e2e/CLAUDE.md index 3e16961b60..d44ba8a03a 100644 --- a/test/e2e/CLAUDE.md +++ b/test/e2e/CLAUDE.md @@ -63,8 +63,8 @@ entry points back via `supersededBy` (requires `removedInSpecVersion`). A covera Two transport arms host the dual-era HTTP entry (`createMcpHandler`) in process via an injected fetch, exactly like the other HTTP arms. They are era-fixed (`TRANSPORT_SPEC_VERSIONS`), so each registers cells on exactly one spec-version axis: - `entryStateless` — the entry with its stateless legacy fallback (`legacy: 'stateless'`, the entry's default posture, passed explicitly so the arm stays era-pinned); the scenario's plain client is served per request through the fallback. Cells run on the 2025-11-25 axis only. -- `entryModern` — the entry hosted modern-only strict (`legacy: 'reject'`); the scenario's client is put into pinned 2026-07-28 negotiation by the arm and the per-request `_meta` envelope is attached to every outgoing request/notification by the arm (a harness stop-gap until the - client emits it itself). Cells run on the 2026-07-28 axis only. +- `entryModern` — the entry hosted modern-only strict (`legacy: 'reject'`); the arm pins the scenario's client to the 2026-07-28 revision via `setVersionNegotiation()`, and the client attaches the per-request `_meta` envelope to every outgoing request/notification itself. Cells + run on the 2026-07-28 axis only. The pin is unconditional, so a scenario that needs to assert non-pin negotiation behavior (e.g. `mode: 'auto'` probing) must restrict off `entryModern` or drive a non-entry transport. Both arms are part of the default transport list, so unrestricted requirements run through the entry automatically. When a requirement cannot run on an entry arm, annotate it with a machine-readable reason instead of bending the test: diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index ce4a19e93e..85b6ca5f6d 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -23,8 +23,7 @@ import type { JSONRPCMessage, McpRequestContext, McpServer, - Server, - Transport as SdkTransport + Server } from '@modelcontextprotocol/server'; import { createMcpHandler, @@ -140,9 +139,11 @@ export async function wire( // client through the entry's stateless legacy fallback (the default, // passed explicitly to keep the arm era-pinned); `entryModern` hosts the // endpoint modern-only strict (`legacy: 'reject'` — strict is no longer - // the entry default) and connects the client on the 2026-07-28 revision - // (pin-mode negotiation + the per-request envelope stop-gap). Every HTTP - // exchange is recorded on `httpLog`. + // the entry default) and pins the scenario's client to the 2026-07-28 + // revision via the public negotiation setter. The client attaches the + // per-request `_meta` envelope itself once a modern era is negotiated, + // so no harness wrap is needed. Every HTTP exchange is recorded on + // `httpLog`. const handler = createMcpHandler( makeServer, transport === 'entryStateless' ? { legacy: 'stateless', ...sniff.entry } : { legacy: 'reject', ...sniff.entry } @@ -161,14 +162,13 @@ export async function wire( }); return response; }; - let clientTx = new StreamableHTTPClientTransport(url, { fetch }); + const clientTx = new StreamableHTTPClientTransport(url, { fetch }); // entryModern is the era-fixed 2026-07-28 arm: it is the only arm // whose wire may legitimately carry input_required results, so it // opts the sniffer into accepting them (other arms stay strict). let armSniff: WireOptions = sniff; if (transport === 'entryModern') { - pinModernNegotiation(client); - clientTx = attachModernEnvelope(clientTx); + client.setVersionNegotiation({ mode: { pin: MODERN_REVISION } }); armSniff = { allowInputRequiredResults: true, ...sniff }; } await client.connect(sniffTransport(clientTx, 'client', armSniff)); @@ -336,8 +336,8 @@ const MODERN_REVISION: SpecVersion = '2026-07-28'; * The per-request `_meta` envelope of a 2026-07-28 request, for scenario bodies * that put raw HTTP requests on the wire (via `wired.fetch`) rather than going * through the wired client. Typed calls through the wired client never need - * this — the entryModern arm attaches the envelope itself (see - * {@linkcode attachModernEnvelope}). + * this — the client attaches the envelope itself once a modern era is + * negotiated. */ export function modernEnvelopeMeta(clientInfo?: Implementation): Record { return { @@ -347,19 +347,6 @@ export function modernEnvelopeMeta(clientInfo?: Implementation): Record(transport: T): T { - let envelope: Record | undefined; - const origSend = transport.send.bind(transport); - transport.send = async (message, opts) => { - let outbound = message; - if ('method' in message) { - const params = (message.params ?? {}) as { _meta?: Record }; - const meta = params._meta; - if (meta?.[PROTOCOL_VERSION_META_KEY] !== undefined) { - envelope = { - [PROTOCOL_VERSION_META_KEY]: meta[PROTOCOL_VERSION_META_KEY], - [CLIENT_INFO_META_KEY]: meta[CLIENT_INFO_META_KEY], - [CLIENT_CAPABILITIES_META_KEY]: meta[CLIENT_CAPABILITIES_META_KEY] - }; - } else if (envelope !== undefined) { - outbound = { ...message, params: { ...params, _meta: { ...envelope, ...meta } } }; - } - } - return origSend(outbound, opts); - }; - return transport; -} - // ─────────────────────────────────────────────────────────────────────────────── // In-process stdio client — TEST-ONLY // diff --git a/test/e2e/scenarios/hosting-entry-stamping.test.ts b/test/e2e/scenarios/hosting-entry-stamping.test.ts index 6ef259ba16..6cf4c51078 100644 --- a/test/e2e/scenarios/hosting-entry-stamping.test.ts +++ b/test/e2e/scenarios/hosting-entry-stamping.test.ts @@ -98,7 +98,7 @@ function wireResultWith(bodies: string[], key: string): Record } verifies('typescript:hosting:entry:modern-cacheable-stamping', async ({ transport }: TestArgs) => { - const client = new Client({ name: 'e2e-stamping-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + const client = new Client({ name: 'e2e-stamping-client', version: '1.0.0' }); await using wired = await wire(transport, cacheConfiguredFactory, client); expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); diff --git a/test/e2e/scenarios/hosting-entry-streaming.test.ts b/test/e2e/scenarios/hosting-entry-streaming.test.ts index 6b6ec0c0cd..0f0360af09 100644 --- a/test/e2e/scenarios/hosting-entry-streaming.test.ts +++ b/test/e2e/scenarios/hosting-entry-streaming.test.ts @@ -11,8 +11,9 @@ * - `responseMode: 'json'` never streams and drops mid-call notifications — * only the terminal result is delivered. * - * Every body drives the harness-hosted entry with the auto-negotiating client; - * the typed result and the raw wire bytes (status, content-type, SSE frames) + * Every body drives the harness-hosted entry through the wired client (the + * entryModern arm pins it to 2026-07-28); the typed result and the raw wire + * bytes (status, content-type, SSE frames) * are asserted side by side via the arm-recorded `wired.httpLog`. */ import { Client } from '@modelcontextprotocol/client'; @@ -71,8 +72,8 @@ function sseDataFrames(body: string): Array> { .map(line => JSON.parse(line.slice('data: '.length)) as Record); } -function newAutoClient(): Client { - return new Client({ name: 'e2e-streaming-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); +function newClient(): Client { + return new Client({ name: 'e2e-streaming-client', version: '1.0.0' }); } function callTool(client: Client, name: 'quiet' | 'chatty'): Promise { @@ -80,7 +81,7 @@ function callTool(client: Client, name: 'quiet' | 'chatty'): Promise { - const client = newAutoClient(); + const client = newClient(); await using wired = await wire(transport, streamingFactory, client); expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); @@ -116,7 +117,7 @@ verifies('typescript:hosting:entry:modern-response-mode', async ({ transport }: // responseMode 'sse': even a handler that emits nothing streams its result. { - const client = newAutoClient(); + const client = newClient(); await using wired = await wire(transport, streamingFactory, client, { entry: { responseMode: 'sse' } }); expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); @@ -136,7 +137,7 @@ verifies('typescript:hosting:entry:modern-response-mode', async ({ transport }: // responseMode 'json': mid-call notifications are dropped — the response // is a plain JSON body whose only payload is the terminal result. { - const client = newAutoClient(); + const client = newClient(); await using wired = await wire(transport, streamingFactory, client, { entry: { responseMode: 'json' } }); expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); diff --git a/test/e2e/scenarios/hosting-entry.test.ts b/test/e2e/scenarios/hosting-entry.test.ts index 1958a59d0f..12e82aad5e 100644 --- a/test/e2e/scenarios/hosting-entry.test.ts +++ b/test/e2e/scenarios/hosting-entry.test.ts @@ -33,11 +33,9 @@ function greetFactory(ctx?: McpRequestContext): McpServer { verifies('typescript:hosting:entry:dual-era-one-factory', async ({ transport }: TestArgs) => { // Both cells host the same handler shape — one ctx-taking factory, the - // 'stateless' legacy posture — and differ only in the client driving it. - const client = - transport === 'entryModern' - ? new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }) - : new Client({ name: 'plain-2025-client', version: '1.0.0' }); + // 'stateless' legacy posture — driven by a plain client; the entry arm + // decides which era serves it (entryModern pins the client to 2026-07-28). + const client = new Client({ name: 'dual-era-client', version: '1.0.0' }); await using wired = await wire(transport, greetFactory, client, { entry: { legacy: 'stateless' } }); if (transport === 'entryStateless') { @@ -51,7 +49,7 @@ verifies('typescript:hosting:entry:dual-era-one-factory', async ({ transport }: return; } - // 2026-era leg: the auto-negotiating client reaches 2026-07-28 via + // 2026-era leg: the arm-pinned client reaches 2026-07-28 via // server/discover — never initialize — and tools/call is served with the // per-request envelope (the modern factory leg answers, not the slot). expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); diff --git a/test/e2e/scenarios/mrtr.test.ts b/test/e2e/scenarios/mrtr.test.ts index 5899a4bafd..3e45e30263 100644 --- a/test/e2e/scenarios/mrtr.test.ts +++ b/test/e2e/scenarios/mrtr.test.ts @@ -61,10 +61,7 @@ verifies('typescript:mrtr:tools-call:write-once-roundtrip', async ({ transport } return server; }; - const client = new Client( - { name: 'mrtr-client', version: '1.0.0' }, - { versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { form: {} } } } - ); + const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } }); const handled: unknown[] = []; client.setRequestHandler('elicitation/create', async request => { handled.push(request.params); @@ -104,10 +101,7 @@ verifies('typescript:mrtr:push-api:loud-fail-2026', async ({ transport }: TestAr return server; }; - const client = new Client( - { name: 'mrtr-client', version: '1.0.0' }, - { versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { form: {} } } } - ); + const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } }); client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: {} })); await using wired = await wire(transport, makeServer, client); @@ -143,10 +137,7 @@ verifies('typescript:mrtr:url-elicitation:no-32042-on-2026', async ({ transport return server; }; - const client = new Client( - { name: 'mrtr-client', version: '1.0.0' }, - { versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { url: {} } } } - ); + const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { elicitation: { url: {} } } }); const seenUrlRequests: unknown[] = []; client.setRequestHandler('elicitation/create', async request => { seenUrlRequests.push(request.params); @@ -183,7 +174,7 @@ verifies('typescript:mrtr:rounds-cap', async ({ transport }: TestArgs) => { const client = new Client( { name: 'mrtr-client', version: '1.0.0' }, - { versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { form: {} } }, inputRequired: { maxRounds: 2 } } + { capabilities: { elicitation: { form: {} } }, inputRequired: { maxRounds: 2 } } ); client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { confirm: true } })); diff --git a/test/e2e/scenarios/protocol.test.ts b/test/e2e/scenarios/protocol.test.ts index 4cb0406763..75df591691 100644 --- a/test/e2e/scenarios/protocol.test.ts +++ b/test/e2e/scenarios/protocol.test.ts @@ -365,8 +365,11 @@ verifies('protocol:error:invalid-params', async ({ transport }: TestArgs) => { await expect(call).rejects.toBeInstanceOf(ProtocolError); // The malformed request did reach the wire (failure is server-side, not client-side validation). + // toMatchObject: on a 2026-07-28 connection the client auto-attaches the per-request `_meta` + // envelope, which is additive and not part of the assertion's intent. const sent = outbound.filter(m => isRequest(m)).find(m => m.method === 'tools/call'); - expect(sent?.params).toEqual({ arguments: {} }); + expect(sent?.params).toMatchObject({ arguments: {} }); + expect((sent?.params as { name?: unknown }).name).toBeUndefined(); expect(ProtocolErrorCode.InvalidParams).toBe(-32_602); await expect(call).rejects.toMatchObject({ code: ProtocolErrorCode.InvalidParams }); diff --git a/test/e2e/scenarios/stdio-dual-era.test.ts b/test/e2e/scenarios/stdio-dual-era.test.ts index 319e85cf4c..503540454c 100644 --- a/test/e2e/scenarios/stdio-dual-era.test.ts +++ b/test/e2e/scenarios/stdio-dual-era.test.ts @@ -17,8 +17,6 @@ import { fileURLToPath } from 'node:url'; import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; -import type { CallToolResult } from '@modelcontextprotocol/server'; import { expect } from 'vitest'; import { verifies } from '../helpers/verifies.js'; @@ -30,8 +28,6 @@ const FIXTURE_PATH = fileURLToPath(new URL('../fixtures/dual-era-stdio-server.ts /** E2E package root — spawn cwd so node/tsx resolve the local toolchain and workspace packages. */ const E2E_ROOT = fileURLToPath(new URL('../', import.meta.url)); -const MODERN = '2026-07-28'; - verifies('typescript:transport:stdio:dual-era-serving', async ({ protocolVersion }: TestArgs) => { const transport = new StdioClientTransport({ command: process.execPath, @@ -75,15 +71,7 @@ verifies('typescript:transport:stdio:dual-era-serving', async ({ protocolVersion expect(sentMethods).not.toContain('initialize'); expect(sentMethods[0]).toBe('server/discover'); - const envelope = { - [PROTOCOL_VERSION_META_KEY]: MODERN, - [CLIENT_INFO_META_KEY]: { name: 'auto-client', version: '0' }, - [CLIENT_CAPABILITIES_META_KEY]: {} - }; - const result = (await client.request({ - method: 'tools/call', - params: { name: 'echo', arguments: { text: 'modern leg' }, _meta: envelope } - })) as CallToolResult; + const result = await client.callTool({ name: 'echo', arguments: { text: 'modern leg' } }); expect(result.content).toEqual([{ type: 'text', text: 'modern leg' }]); } finally { await client.close(); diff --git a/test/integration/test/server/createMcpHandler.test.ts b/test/integration/test/server/createMcpHandler.test.ts index e1b2693667..fc3619277e 100644 --- a/test/integration/test/server/createMcpHandler.test.ts +++ b/test/integration/test/server/createMcpHandler.test.ts @@ -9,7 +9,7 @@ import { createServer } from 'node:http'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; -import type { CallToolResult, CreateMcpHandlerOptions, McpHttpHandler, McpRequestContext } from '@modelcontextprotocol/server'; +import type { CreateMcpHandlerOptions, McpHttpHandler, McpRequestContext } from '@modelcontextprotocol/server'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; import { afterEach, describe, expect, it } from 'vitest'; @@ -47,14 +47,6 @@ describe('createMcpHandler over HTTP (legacy postures end to end)', () => { return { baseUrl, handler }; } - function modernEnvelope() { - return { - [PROTOCOL_VERSION_META_KEY]: MODERN, - [CLIENT_INFO_META_KEY]: { name: 'integration-client', version: '1.0.0' }, - [CLIENT_CAPABILITIES_META_KEY]: {} - }; - } - it('serves the modern era to an auto-negotiating client (default endpoint)', async () => { const { baseUrl } = await startEndpoint(); @@ -66,13 +58,7 @@ describe('createMcpHandler over HTTP (legacy postures end to end)', () => { expect(client.getServerVersion()).toEqual({ name: 'dual-era-endpoint', version: '1.0.0' }); expect(client.getInstructions()).toBe('dual era endpoint'); - // A typed tools/call round trip; the per-request envelope is attached - // explicitly here (automatic envelope emission for every modern request - // is a client-side follow-up). - const result = (await client.request({ - method: 'tools/call', - params: { name: 'greet', arguments: { name: 'modern' }, _meta: modernEnvelope() } - })) as CallToolResult; + const result = await client.callTool({ name: 'greet', arguments: { name: 'modern' } }); expect(result.content).toEqual([{ type: 'text', text: 'hello modern (modern)' }]); }); @@ -100,10 +86,7 @@ describe('createMcpHandler over HTTP (legacy postures end to end)', () => { cleanups.push(() => modernClient.close()); expect(modernClient.getNegotiatedProtocolVersion()).toBe(MODERN); - const modernResult = (await modernClient.request({ - method: 'tools/call', - params: { name: 'greet', arguments: { name: 'new friend' }, _meta: modernEnvelope() } - })) as CallToolResult; + const modernResult = await modernClient.callTool({ name: 'greet', arguments: { name: 'new friend' } }); expect(modernResult.content).toEqual([{ type: 'text', text: 'hello new friend (modern)' }]); }); @@ -140,7 +123,11 @@ describe('createMcpHandler over HTTP (legacy postures end to end)', () => { params: { name: 'greet', arguments: { name: 'x' }, - _meta: { ...modernEnvelope(), [PROTOCOL_VERSION_META_KEY]: '2030-01-01' } + _meta: { + [PROTOCOL_VERSION_META_KEY]: '2030-01-01', + [CLIENT_INFO_META_KEY]: { name: 'integration-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + } } }) }); diff --git a/test/integration/test/server/dualEraStdio.test.ts b/test/integration/test/server/dualEraStdio.test.ts index 10a32d20f2..ff74afdfd9 100644 --- a/test/integration/test/server/dualEraStdio.test.ts +++ b/test/integration/test/server/dualEraStdio.test.ts @@ -143,24 +143,21 @@ describe('serveStdio over a real child-process pipe (one connection per spawned expect(outbound.some(message => (message as { method?: string }).method === 'initialize')).toBe(false); expect((outbound[0] as { method?: string }).method).toBe('server/discover'); - // Modern vertical: list → call, every request carrying the per-request envelope. - // (Attaching it explicitly is the documented stop-gap until automatic - // per-request envelope emission lands client-side.) - const envelope = modernEnvelope('modern-pipe-client'); + // Modern vertical: list → call. The raw list carries a hand-built + // envelope so the resultType marker can be read on the wire; the + // typed call goes through the client, which attaches the envelope + // itself on the modern-negotiated connection. const modernList = await rawRequest(transport, inbound, { jsonrpc: '2.0', id: 'raw-modern-list', method: 'tools/list', - params: { _meta: envelope } + params: { _meta: modernEnvelope('modern-pipe-client') } }); const modernListResult = (modernList as { result?: { tools?: Array<{ name: string }>; resultType?: string } }).result; expect(modernListResult?.tools?.map(tool => tool.name)).toEqual(['echo']); expect(modernListResult?.resultType).toBe('complete'); - const result = await client.request({ - method: 'tools/call', - params: { name: 'echo', arguments: { text: 'modern leg' }, _meta: envelope } - }); + const result = await client.callTool({ name: 'echo', arguments: { text: 'modern leg' } }); expect(result.content).toEqual([{ type: 'text', text: 'modern leg' }]); // The connection is pinned to the 2026 era: a late claim-less