Skip to content

Commit a831d11

Browse files
feat(client): accept a known-legacy era verdict on ConnectOptions.prior
Hosts that already know a server's era from out-of-band metadata shouldn't pay a server/discover probe round trip that fails against every legacy server — and mode: 'legacy' pins the client to initialize forever. `prior` now takes a discriminated PriorDiscovery verdict alongside the bare DiscoverResult: the modern arm wraps a DiscoverResult (zero round trips, as before), the legacy arm skips the probe and runs the plain legacy initialize handshake. Freshness is the supplying host's responsibility — a stale legacy verdict succeeds silently against an upgraded server, so hosts must date cached legacy verdicts in their own storage and stop supplying them past their policy horizon; with no prior, the configured versionNegotiation mode decides (an 'auto' client re-probes). Persisted-blob plumbing fails typed: null priors are treated as absent, and corrupt or unrecognized shapes — including a bare result whose supportedVersions is not an array — reject with SdkError(EraNegotiationFailed) before anything reaches the wire.
1 parent e81758c commit a831d11

14 files changed

Lines changed: 484 additions & 39 deletions

File tree

‎.changeset/prior-legacy-verdict.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': minor
3+
---
4+
5+
`ConnectOptions.prior` now accepts a cached era verdict (new exported type `PriorDiscovery`), not only a positive `DiscoverResult`. Hosts that already know a server's era from out-of-band metadata can pass `{ kind: 'legacy' }` to skip the `server/discover` probe and run the plain `initialize` handshake directly — without pinning the client to `mode: 'legacy'`: stop supplying the verdict and `connect()` falls back to the configured `versionNegotiation` mode (under `'auto'`, it re-probes and rediscovers an upgraded server). Freshness is the supplying host's responsibility — a stale legacy verdict succeeds silently against an upgraded server, so hosts must date cached legacy verdicts in their own storage and stop supplying them past their policy horizon. The positive verdict has a symmetric wrapped form, `{ kind: 'modern', discover }`; the bare `DiscoverResult` form stays accepted. Persisted-blob plumbing is hardened: `prior: null` is treated as absent, and an unrecognized shape rejects with a typed `SdkError(EraNegotiationFailed)` instead of a `TypeError`. Previously, a host fronting a known-legacy server had no way to avoid a probe round trip that fails against every legacy server — `connect({ prior })` threw `EraNegotiationFailed` on anything without a modern overlap.

‎docs/advanced/gateway.md‎

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse
2626

2727
`worker` is connected: `callTool` works immediately, and the server has not heard from it yet.
2828

29-
`connect({ prior })` is 2026-07-28+ only — see [Protocol versions](../protocol-versions.md).
29+
Adopting a `DiscoverResult` is 2026-07-28+ only — see [Protocol versions](../protocol-versions.md). For a server known to be legacy, `prior` also takes a negative verdict — see [Skip the probe for a known-legacy server](#skip-the-probe-for-a-known-legacy-server).
3030

3131
## Probe once at bootstrap
3232

@@ -150,6 +150,93 @@ re-probed: 2026-07-28
150150

151151
Replace the persisted blob with the fresh `getDiscoverResult()` and the rest of the fleet recovers on its next read.
152152

153+
## Skip the probe for a known-legacy server
154+
155+
When out-of-band metadata already says the server is pre-2026 — a registry entry, an earlier connection's outcome — an `'auto'`-mode probe is a round trip that fails on every single connect. Supply the negative verdict instead: `prior` also takes a `PriorDiscovery`, and its `{ kind: 'legacy' }` arm skips the probe and goes straight to the `initialize` handshake.
156+
157+
```ts source="../../examples/guides/advanced/gateway.examples.ts#prior_legacy"
158+
const pinnedLegacy = new Client({ name: 'worker-e', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
159+
await pinnedLegacy.connect(new StreamableHTTPClientTransport(url), {
160+
prior: { kind: 'legacy' }
161+
});
162+
console.log(pinnedLegacy.getProtocolEra());
163+
```
164+
165+
```
166+
legacy
167+
```
168+
169+
Freshness is your responsibility: the SDK adopts whatever verdict you hand it. A stale modern verdict fails loudly at the first request, but a stale legacy verdict succeeds silently forever — an upgraded server still answers `initialize`, so nothing ever corrects it. Date cached legacy verdicts in your own storage and stop supplying them past your policy horizon; with no `prior`, the configured mode decides again (under `mode: 'auto'` the connect re-probes, so the upgrade is discovered).
170+
171+
The positive verdict has a wrapped form too: `{ kind: 'modern', discover }` adopts `discover` exactly like the bare `DiscoverResult`.
172+
173+
## Caching discovery verdicts
174+
175+
The pieces above compose into the full host-side loop: probe once, cache the verdict under your own timestamp, and gate every later connect on a freshness check you control.
176+
177+
The first connect under `mode: 'auto'` pays one probe. Afterwards the outcome is readable on the client: `getDiscoverResult()` returns the `DiscoverResult` on a modern server, and on a connected client its absence means the era is legacy. Store that verdict together with when you stored it — the `Map` below keeps `storedAt` explicitly; in a real deployment the store does the dating for you (a Redis key TTL, where an expired read simply comes back empty, or a database row's `created_at` column).
178+
179+
Before each connect, supply the cached verdict only while your own policy says it is fresh — a fixed horizon here, any predicate in practice. Supplying `undefined` under `mode: 'auto'` *is* the re-probe, and the fresh outcome re-populates the cache. The timestamp matters most for the legacy branch: a stale legacy verdict succeeds silently forever (an upgraded server still answers `initialize`), so only the timestamp ever retires it; a stale modern verdict fails loudly at the first request.
180+
181+
```ts source="../../examples/guides/advanced/gateway.examples.ts#prior_cache"
182+
import type { PriorDiscovery } from '@modelcontextprotocol/client';
183+
184+
// Host-side verdict cache. A real deployment keeps this in Redis (a key TTL
185+
// does the dating: an expired read is no prior) or a database row with a
186+
// created_at column; the Map stores the timestamp explicitly.
187+
const verdicts = new Map<string, { verdict: PriorDiscovery; storedAt: number }>();
188+
const HORIZON_MS = 24 * 60 * 60 * 1000; // fixed-horizon freshness policy
189+
const fresh = (entry: { storedAt: number }): boolean => Date.now() - entry.storedAt < HORIZON_MS;
190+
191+
// Count server/discover probes at the fetch layer (the wire trace).
192+
let probes = 0;
193+
const tracingFetch: typeof fetch = async (input, init) => {
194+
if (typeof init?.body === 'string' && init.body.includes('"server/discover"')) probes++;
195+
return fetch(input, init);
196+
};
197+
198+
async function connectCached(key: string): Promise<Client> {
199+
const entry = verdicts.get(key);
200+
// An entry past the horizon is not supplied — under mode: 'auto' that IS
201+
// the re-probe, and the fresh outcome below re-populates the cache.
202+
const prior = entry && fresh(entry) ? entry.verdict : undefined;
203+
204+
const client = new Client({ name: 'cached-worker', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
205+
await client.connect(new StreamableHTTPClientTransport(url, { fetch: tracingFetch }), { prior });
206+
207+
if (prior === undefined) {
208+
// Fresh outcome: getDiscoverResult() is the DiscoverResult on a modern
209+
// server; its absence on a connected client means the era is legacy.
210+
const discover = client.getDiscoverResult();
211+
// Date the entry with the host's own clock — only this timestamp ever
212+
// retires a legacy verdict (a stale one succeeds silently forever).
213+
verdicts.set(key, {
214+
verdict: discover ? { kind: 'modern', discover } : { kind: 'legacy' },
215+
storedAt: Date.now()
216+
});
217+
}
218+
return client;
219+
}
220+
221+
const first = await connectCached('gateway-target'); // no entry: probes
222+
console.log('probes after first connect:', probes);
223+
224+
const second = await connectCached('gateway-target'); // fresh entry: verdict supplied, no probe
225+
console.log('probes after second connect:', probes);
226+
227+
verdicts.get('gateway-target')!.storedAt -= HORIZON_MS + 1; // the horizon passes
228+
const third = await connectCached('gateway-target'); // stale: dropped, re-probed, re-cached
229+
console.log('probes after the horizon passes:', probes);
230+
```
231+
232+
```
233+
probes after first connect: 1
234+
probes after second connect: 1
235+
probes after the horizon passes: 2
236+
```
237+
238+
The wire trace shows the shape of the loop: one probe to fill the cache, none while the verdict is fresh, one more when the horizon forces rediscovery.
239+
153240
## Recap
154241

155242
- `connect(transport, { prior })` adopts a persisted `DiscoverResult` with zero round trips.
@@ -158,3 +245,4 @@ Replace the persisted blob with the fresh `getDiscoverResult()` and the rest of
158245
- Reuse a `DiscoverResult` only across clients that present the same authorization context.
159246
- Prior-connected clients are request-only; call `listen()` on the one that needs notifications.
160247
- An incompatible `prior` rejects with `SdkError(EraNegotiationFailed)`; fall back to a fresh probe and re-persist.
248+
- A known-legacy server takes `prior: { kind: 'legacy' }` — no probe, straight to `initialize`. Stale legacy verdicts fail silently (an upgraded server still answers `initialize`), so date them in your own storage and stop supplying them past your policy horizon.

‎docs/clients/caching.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ Fresh or not, the cache also evicts itself when the server signals a change: a `
9494
Cache hints are a 2026-07-28 surface — see [Protocol versions](../protocol-versions.md). Against a 2025-era server, `defaultCacheTtlMs` is the only lever.
9595
:::
9696

97+
## Two caches, two owners
98+
99+
The response cache on this page is the only cache the SDK manages: the server declares each entry's lifetime (`ttlMs`) and the SDK enforces it. A host that also caches **discovery verdicts** — the connect-time era outcome supplied as `ConnectOptions.prior` — owns that policy itself: the SDK never expires a supplied verdict. See [Protocol versions](../protocol-versions.md#skip-the-probe-with-a-cached-verdict) for the verdict shapes and [Caching discovery verdicts](../advanced/gateway.md#caching-discovery-verdicts) for the full host-side loop.
100+
97101
## Recap
98102

99103
- Caching is one feature with two halves: the server attaches `ttlMs` / `cacheScope`, the client honours them — by default neither half does anything alone.

‎docs/clients/connect.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,19 @@ Call list-trips before book-trip. Dates are ISO 8601.
8585

8686
The capability object gates every verb on [the next page](./calling.md): only ask for what the server advertised. `getInstructions()` is the server's usage guide for the model — put it in the system prompt.
8787

88+
A fourth accessor, `getDiscoverResult()`, tells the eras apart at connect time. Present, it is the modern-era `DiscoverResult` — persistable with `JSON.stringify` and usable on a later connect as `ConnectOptions.prior` to skip the probe. Absent on a connected client, the era is legacy. This page's client used the default legacy handshake:
89+
90+
```ts source="../../examples/guides/clients/connect.examples.ts#connect_discoverResult"
91+
// The default mode ran the legacy initialize handshake — no DiscoverResult.
92+
console.log(client.getDiscoverResult());
93+
```
94+
95+
```
96+
undefined
97+
```
98+
99+
Under `versionNegotiation: { mode: 'auto' }` against a 2026-era server it returns the advertisement — see [Protocol versions](../protocol-versions.md#skip-the-probe-with-a-cached-verdict) for the cached-verdict shapes and [Caching discovery verdicts](../advanced/gateway.md#caching-discovery-verdicts) for the full host-side loop.
100+
88101
## Disconnect cleanly
89102

90103
Over Streamable HTTP, terminate the server-side session, then close the client.
@@ -101,6 +114,6 @@ await client.close();
101114
- `new Client({ name, version })`, a transport, and `connect()` are the whole setup; `connect()` runs the `initialize` handshake.
102115
- `StreamableHTTPClientTransport` connects to remote servers; `StdioClientTransport`, from `@modelcontextprotocol/client/stdio`, spawns local ones; `SSEClientTransport` is the fallback for SSE-only servers.
103116
- `InMemoryTransport.createLinkedPair()` links a client and a server in one process.
104-
- After `connect()`, `getServerVersion()`, `getServerCapabilities()`, and `getInstructions()` return what the server declared.
117+
- After `connect()`, `getServerVersion()`, `getServerCapabilities()`, and `getInstructions()` return what the server declared; `getDiscoverResult()` tells the eras apart (present = modern, absent = legacy).
105118
- `close()` tears down the transport and rejects in-flight requests.
106119
- Protocol-revision differences live on the protocol versions page, not here.

‎docs/migration/support-2026-07-28.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,12 @@ The probe request itself already carries the per-request `_meta` envelope
109109
the envelope to every outgoing request and notification. Tooling that classifies
110110
traffic must not treat "saw an envelope" as "modern era negotiated": the legacy-fallback
111111
path also begins with one enveloped probe. A gateway/worker fleet can skip the
112-
probe entirely with `client.connect(transport, { prior: persistedDiscoverResult })`.
112+
probe entirely with `client.connect(transport, { prior: persistedDiscoverResult })` —
113+
or, for a server known out-of-band to be legacy, with
114+
`{ prior: { kind: 'legacy' } }`, which goes straight to `initialize`. Freshness
115+
of a cached legacy verdict is the host's responsibility (a stale one succeeds
116+
silently against an upgraded server); stop supplying it and the configured
117+
mode decides again (an `'auto'` client re-probes).
113118

114119
### Server over HTTP: `createMcpHandler`
115120

‎docs/migration/upgrade-to-v2.md‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1758,8 +1758,14 @@ from a subpath means the corresponding peer dep must be in your `package.json`.
17581758
### `Client.connect(transport, { prior })` — zero-round-trip connect
17591759
17601760
Probe once, persist `client.getDiscoverResult()` (`JSON.stringify`), and feed it to
1761-
every worker as `client.connect(transport, { prior })` — 2026-07-28+ only. New exported
1762-
type `ConnectOptions` (extends `RequestOptions` with `prior?: DiscoverResult`).
1761+
every worker as `client.connect(transport, { prior })`. New exported types
1762+
`ConnectOptions` (extends `RequestOptions` with `prior?: DiscoverResult | PriorDiscovery`)
1763+
and `PriorDiscovery` — a cached era verdict: the modern arm wraps a `DiscoverResult`
1764+
(zero round trips), the legacy arm (`{ kind: 'legacy' }`) skips the probe and runs the
1765+
plain `initialize` handshake for servers known to be pre-2026. Freshness is the
1766+
supplying host's responsibility — date cached legacy verdicts in your own storage and
1767+
stop supplying them past your policy horizon (a stale one succeeds silently against an
1768+
upgraded server).
17631769
17641770
### Serving the 2026-07-28 revision
17651771

‎docs/protocol-versions.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ The rejection is a typed, local `SdkError` — nothing reaches the server beyond
7474
ERA_NEGOTIATION_FAILED: Version negotiation failed: the server did not offer pinned protocol version 2026-07-28 via server/discover (no fallback in pin mode)
7575
```
7676

77+
## Skip the probe with a cached verdict
78+
79+
`mode: 'auto'` pays the probe on every fresh connect. A host that already knows the server's era — from a registry entry, or an earlier connection's outcome — skips it by supplying `ConnectOptions.prior`: a bare `DiscoverResult` (or `{ kind: 'modern', discover }`) is adopted with zero round trips, and `{ kind: 'legacy' }` goes straight to the `initialize` handshake. Both wrapped forms are the exported `PriorDiscovery` type.
80+
81+
Freshness is the host's job, not the SDK's: a stale modern verdict fails loudly at the first request, but a stale legacy verdict succeeds silently against an upgraded server — so date cached legacy verdicts in your own storage and stop supplying them past your policy horizon. [Caching discovery verdicts](./advanced/gateway.md#caching-discovery-verdicts) shows the full loop, including the re-probe that re-populates the cache.
82+
7783
## Understand the probe
7884

7985
`probe` bounds the `server/discover` round trip that `'auto'` and a pin run before anything else.

‎examples/gateway/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,4 @@ The server exposes a `request_count` tool returning how many MCP requests reache
4747
Only reuse a persisted `DiscoverResult` across workers that present the **same authorization context** as the bootstrap client (key the blob on a credential hash). Adopting a wider `prior` does not grant access — the server authorizes every request — but it can mislead
4848
client-side capability gating.
4949

50-
`connect({ prior })` is **modern-only**: no mutual 2026-07-28+ revision → `SdkError(EraNegotiationFailed)`. Use `versionNegotiation: { mode: 'auto' }` for legacy-era fallback.
50+
Adopting a `DiscoverResult` via `connect({ prior })` is **modern-only**: no mutual 2026-07-28+ revision → `SdkError(EraNegotiationFailed)`. For a server known to be legacy, pass the negative verdict instead — `prior: { kind: 'legacy' }` skips the probe and goes straight to `initialize`. Freshness is the host's responsibility: date cached legacy verdicts in your own storage and stop supplying them past your policy horizon (a stale one succeeds silently against an upgraded server; with no `prior`, a `mode: 'auto'` client re-probes).

0 commit comments

Comments
 (0)