feat(client): per-request envelope auto-emission and probe completion#2320
Conversation
…a connections On a connection that negotiated a 2026-07-28+ protocol revision (auto-negotiated or pinned), the client now automatically attaches the per-request `_meta` envelope — the reserved protocol-version / client-info / client-capabilities keys — to every outgoing request and notification, not just the connect-time `server/discover` probe. User-supplied `_meta` keys take precedence over the auto-attached ones. The auto-attached client-capabilities reflect what the client actually registered (sampling/elicitation/roots). Legacy-era connections never gain these keys: the seam returns `undefined` and outbound traffic is byte-identical to a 2025 client, so the `'auto'`-mode fallback and the plain legacy connect stay byte-untouched. Adds a small protected `Protocol._outboundMetaEnvelope()` seam (base: no-op) applied at the request, notification, and cancellation send sites. Adds the `ProtocolEra` type (`'legacy' | 'modern'`), `Client.getProtocolEra()`, and `Client.setVersionNegotiation()` for configuring negotiation pre-connect on an already-constructed instance.
Pins the final `probe: { timeoutMs?, maxRetries? }` member names. `maxRetries`
(default `0`) governs timeout re-sends only: the probe is re-sent after a
timeout up to `maxRetries` times before the transport-aware timeout verdict
applies. The spec-mandated `-32004` corrective continuation
(select-and-continue with a mutual modern version) is a separate negotiation
step and is never counted against `maxRetries`.
The client now attaches the per-request `_meta` envelope itself once a modern era is negotiated, and exposes `setVersionNegotiation()` for pre-connect configuration — so the entryModern arm no longer needs the `attachModernEnvelope` transport wrap or the `pinModernNegotiation` private-field write. The arm pins the scenario's client to 2026-07-28 via the public setter and connects. The 130 entryModern matrix cells stay green with both shims deleted. One scenario assertion relaxed (`protocol:error:invalid-params`): the recorded outbound `tools/call` params now carry the auto-attached `_meta` envelope on the entryModern arm, which is additive and not part of the assertion's intent (the body still proves the malformed request reached the wire and `name` is absent).
…est envelope Document explicitly that the v2 default of `versionNegotiation` is `'legacy'` (absent ⇒ the plain 2025 connect sequence, byte-identical to v1.x) and show how to opt into `'auto'` and pin, including what happens against a 2025-only server. Record the per-request `_meta` envelope auto-emission, the new `getProtocolEra()` / `setVersionNegotiation()` accessors, and the `probe.maxRetries` knob with its `-32004` disambiguation sentence. Adds a short version-negotiation section to the client guide and a changeset for the new public surface.
🦋 Changeset detectedLatest commit: 2451b1a The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
…w the client auto-attaches it The client now attaches the per-request `_meta` envelope itself on modern-era connections, so the example clients and the conformance fixture no longer need to build and pass it by hand. - examples/client/src/multiRoundTripClient.ts: remove the `envelope()` helper and the META_KEY imports; the auto-fulfilment leg is a plain `client.callTool()`; the manual leg keeps `client.request()` (its `allowInputRequired: true` return is the union, which `callTool()`s typed signature does not surface) but drops the manual `_meta`. Stop-gap header comment removed. - examples/client/src/dualEraStdioClient.ts: modern leg is a plain `client.callTool()`; envelope literal, META_KEY imports, and the stop-gap header comment removed. - test/conformance/src/everythingClient.ts: remove `modernEnvelope()` and the META_KEY imports; `runToolsCallModernClient` uses `listTools()` / `callTool()`; the four `sep-2322` callTool sites drop the explicit `_meta`. The local `server/discover` response shim stays (separate upstream gap).
…low-up" wording and anchor the negotiation snippet - examples/server/src/dualEraStreamableHttp.ts: header comment now states the client attaches the per-request `_meta` envelope itself once a modern era is negotiated. - test/integration/test/server/createMcpHandler.test.ts: the two typed tools/call round trips through an auto-negotiating Client use plain `client.callTool()` (no manual `_meta`); the `modernEnvelope()` helper is gone and the raw-fetch unsupported-revision negative test builds its envelope inline. - docs/client.md + examples/client/src/clientGuide.examples.ts: the "Protocol version negotiation" snippet is now sourced from a type-checked `Client_versionNegotiation` region.
…ly scenarios
The entryModern arm unconditionally pins the scenario client to 2026-07-28
via `setVersionNegotiation({mode:{pin:...}})` before connect(), so any
`versionNegotiation: {mode: 'auto'}` passed at construction time is
overridden and never observed. Drop it from the scenarios that only run
on entry arms (mrtr ×4, hosting-entry-streaming, hosting-entry-stamping,
hosting-entry dual-era-one-factory) and update the now-stale
"auto-negotiating" wording in comments. The dual-era-one-factory
discover assertion is satisfied by pin mode (pin discovers too), so its
ternary collapses to a single plain client.
Also document the unconditional pin in test/e2e/CLAUDE.md so scenarios
that need to assert non-pin negotiation behavior know to restrict off
entryModern.
… calls in dual-era stdio tests The auto-negotiating Client now attaches the per-request envelope itself on modern-era connections, so the hand-built `_meta` on `client.request`/ `client.callTool` was dead code that bypassed the auto-emission path the test exercises. Raw `rawRequest`/`transport.send` bodies (which never go through Client) keep building the envelope by hand. Also drops the stale "stop-gap until automatic envelope emission lands client-side" comment and the now-unused meta-key imports.
| // Both cells host the same handler shape — one ctx-taking factory, the | ||
| // 'stateless' legacy posture — and differ only in the client driving it. | ||
| const client = | ||
| transport === 'entryModern' | ||
| ? new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }) | ||
| : new Client({ name: 'plain-2025-client', version: '1.0.0' }); | ||
| // 'stateless' legacy posture — driven by a plain client; the entry arm | ||
| // decides which era serves it (entryModern pins the client to 2026-07-28). | ||
| const client = new Client({ name: 'dual-era-client', version: '1.0.0' }); |
There was a problem hiding this comment.
🟡 The 'typescript:hosting:entry:dual-era-one-factory' requirement in test/e2e/requirements.ts:2280-2285 still says the cell proves "an auto-negotiating client reaches 2026-07-28 via server/discover", but this PR rewrote the cell to use a plain Client that the entryModern arm pins via setVersionNegotiation({ mode: { pin: '2026-07-28' } }), so the cell now exercises pin-mode negotiation and semantically duplicates the separate 'typescript:hosting:entry:pin-negotiation' requirement. Update the manifest's behavior/note wording to match the arm-pinned client (or restore auto-mode coverage on a non-entry transport so the described behavior is still proven somewhere e2e).
Extended reasoning...
What drifted. This PR changes the modern leg of the typescript:hosting:entry:dual-era-one-factory cell (test/e2e/scenarios/hosting-entry.test.ts:35-38): the scenario no longer constructs an auto-negotiating client (versionNegotiation: { mode: 'auto' }); it builds a plain Client and relies on the entryModern arm's unconditional client.setVersionNegotiation({ mode: { pin: '2026-07-28' } }) in test/e2e/helpers/index.ts. test/e2e/CLAUDE.md was updated to document the unconditional pin ("a scenario that needs to assert non-pin negotiation behavior (e.g. mode: 'auto' probing) must restrict off entryModern"), but the requirement manifest entry was not: test/e2e/requirements.ts:2283 still reads "...an auto-negotiating client reaches 2026-07-28 via server/discover (never initialize)...".\n\nWhy it matters. Per the suite's own conventions, requirements.ts is the pure-data source of truth for what each cell proves; reviewers and the coverage gates rely on its behavior wording. The coverage gate only checks that the requirement id is cited by a verifies() call, not that the prose matches the test body, so this drift is silent. After this PR the cell exercises pin-mode negotiation on the entryModern arm — the same path already covered by typescript:hosting:entry:pin-negotiation (requirements.ts:2287-2292) — so the manifest both misdescribes the cell and masks the fact that no e2e cell now exercises 'auto'-mode negotiation against the createMcpHandler entry (only the integration suite covers auto over real HTTP, e.g. test/integration/test/server/createMcpHandler.test.ts).\n\nStep-by-step. (1) On the entryModern arm, the cell builds new Client({ name: 'dual-era-client', ... }) with no versionNegotiation. (2) wire('entryModern', ...) calls client.setVersionNegotiation({ mode: { pin: '2026-07-28' } }) before connect(). (3) connect() resolves the pin plan in resolveVersionNegotiation() — the auto probe-and-fallback state machine never runs. (4) The cell's wire assertions ("server/discover sent, never initialize") still pass, because pin mode also probes via server/discover, so nothing in CI flags that the behavior described at requirements.ts:2283 ("an auto-negotiating client...") is no longer what the cell does.\n\nWhy nothing else catches it. The PR's own follow-up commits (3a3cc3e, 3ac89ea) deliberately dropped the now-dead mode: 'auto' configs from the entryModern-only scenarios, and the existing review comment on helpers/index.ts:173 covers the unconditional pin overriding scenario clients — but neither touches the stale requirements.ts behavior text, which is a distinct, still-unaddressed item.\n\nHow to fix. Either (a) update the requirement's behavior/note wording at requirements.ts:2280-2285 to say the entryModern leg is driven by an arm-pinned client reaching 2026-07-28 via server/discover, or (b) if auto-mode coverage against the entry is meant to be preserved, restore it explicitly (e.g. a cell on a non-entry transport, or have the scenario pass its negotiation mode through wire()). Option (a) is a one-line wording change and matches what the test now actually proves.
On a connection that negotiated a 2026-07-28+ protocol revision (auto-negotiated or pinned), the client now automatically attaches the per-request
_metaenvelope — the reserved protocol-version / client-info / client-capabilities keys — to every outgoing request and notification, not just the connect-timeserver/discoverprobe. User-supplied_metakeys take precedence; 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.Also adds
Client.getProtocolEra()('legacy' | 'modern' | undefined), theProtocolEratype,Client.setVersionNegotiation()for configuring negotiation pre-connect on an already-constructed instance, and theprobe.maxRetriesknob (default0, governs probe-timeout re-sends only — the spec-mandated-32004corrective continuation is never counted against it). TheversionNegotiationdefault remains'legacy'.