Skip to content
Merged
11 changes: 11 additions & 0 deletions .changeset/envelope-auto-emission.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(client): per-request envelope auto-emission and probe completion by felixweinberger · Pull Request #2320 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Merged
11 changes: 11 additions & 0 deletions .changeset/envelope-auto-emission.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(client): per-request envelope auto-emission and probe completion by felixweinberger · Pull Request #2320 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Merged
11 changes: 11 additions & 0 deletions .changeset/envelope-auto-emission.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(client): per-request envelope auto-emission and probe completion by felixweinberger · Pull Request #2320 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Merged
11 changes: 11 additions & 0 deletions .changeset/envelope-auto-emission.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(client): per-request envelope auto-emission and probe completion by felixweinberger · Pull Request #2320 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Merged
11 changes: 11 additions & 0 deletions .changeset/envelope-auto-emission.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(client): per-request envelope auto-emission and probe completion by felixweinberger · Pull Request #2320 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Merged
11 changes: 11 additions & 0 deletions .changeset/envelope-auto-emission.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(client): per-request envelope auto-emission and probe completion by felixweinberger · Pull Request #2320 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Merged
11 changes: 11 additions & 0 deletions .changeset/envelope-auto-emission.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(client): per-request envelope auto-emission and probe completion by felixweinberger · Pull Request #2320 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Merged
11 changes: 11 additions & 0 deletions .changeset/envelope-auto-emission.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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