Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/envelope-auto-emission.md
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
claude[bot] marked this conversation as resolved.
Comment thread
felixweinberger marked this conversation as resolved.

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.
20 changes: 20 additions & 0 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Comment thread
claude[bot] marked this conversation as resolved.
```

- **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.
Expand Down
15 changes: 11 additions & 4 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
14 changes: 14 additions & 0 deletions examples/client/src/clientGuide.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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;
Expand Down
23 changes: 5 additions & 18 deletions examples/client/src/dualEraStdioClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,16 @@
* 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:
*
* tsx examples/client/src/dualEraStdioClient.ts
*/
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),
Expand Down Expand Up @@ -46,20 +44,9 @@ async function modernLeg(): Promise<void> {
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();
}
Expand Down
32 changes: 3 additions & 29 deletions examples/client/src/multiRoundTripClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
return {
[PROTOCOL_VERSION_META_KEY]: negotiated,
[CLIENT_INFO_META_KEY]: CLIENT_INFO,
[CLIENT_CAPABILITIES_META_KEY]: { elicitation: { form: {}, url: {} } }
};
}

async function autoFulfilLeg(): Promise<void> {
console.log('--- auto-fulfilment (the default) ---');
const client = new Client(CLIENT_INFO, {
Expand All @@ -62,15 +42,11 @@ async function autoFulfilLeg(): Promise<void> {
});

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();
}
Expand All @@ -83,7 +59,6 @@ async function manualLeg(): Promise<void> {
inputRequired: { autoFulfill: false }
});
await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL)));
const negotiated = client.getNegotiatedProtocolVersion()!;

let inputResponses: Record<string, unknown> | undefined;
let requestState: string | undefined;
Expand All @@ -98,7 +73,6 @@ async function manualLeg(): Promise<void> {
params: {
name: 'deploy',
arguments: { env: 'staging' },
_meta: envelope(negotiated),
...(inputResponses && { inputResponses }),
...(requestState && { requestState })
}
Expand Down
10 changes: 4 additions & 6 deletions examples/server/src/dualEraStreamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
74 changes: 70 additions & 4 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type {
MessageExtraInfo,
NonCompleteResultFlow,
NotificationMethod,
ProtocolEra,
ProtocolOptions,
ReadResourceRequest,
ReadResourceResult,
Expand All @@ -49,6 +50,8 @@ import type {
UnsubscribeRequest
} from '@modelcontextprotocol/core';
import {
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
codecForVersion,
CreateMessageResultSchema,
CreateMessageResultWithToolsSchema,
Expand All @@ -61,6 +64,7 @@ import {
mergeCapabilities,
parseSchema,
Protocol,
PROTOCOL_VERSION_META_KEY,
ProtocolError,
ProtocolErrorCode,
resolveInputRequiredDriverConfig,
Expand Down Expand Up @@ -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`
Expand All @@ -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;

Expand Down Expand Up @@ -334,6 +348,30 @@ export class Client extends Protocol<ClientContext> {
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<Record<string, unknown>> | 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
Expand Down Expand Up @@ -412,6 +450,21 @@ export class Client extends Protocol<ClientContext> {
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.
Expand Down Expand Up @@ -779,6 +832,19 @@ export class Client extends Protocol<ClientContext> {
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.
*/
Expand Down
Loading
Loading