Skip to content

Commit f60dff0

Browse files
feat(client): accept a cached era verdict on ConnectOptions.prior (modelcontextprotocol#2511)
1 parent 1480241 commit f60dff0

17 files changed

Lines changed: 523 additions & 86 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` accepts a cached era verdict — the new exported type `PriorDiscovery`. `{ kind: 'modern', discover }` adopts a previously obtained `DiscoverResult` with zero round trips; `{ kind: 'legacy' }` skips the `server/discover` probe and runs the plain `initialize` handshake directly, for servers known out-of-band to be legacy — 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. Persisted-blob plumbing is hardened: `prior: null` is treated as absent, the modern arm's `discover` payload is schema-validated before any connection state changes, and an unrecognized shape rejects with a typed `SdkError(EraNegotiationFailed)` instead of a `TypeError`.

docs/advanced/gateway.md

Lines changed: 100 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ A **gateway** — a proxy, a worker pool, any process that fronts one MCP server
77

88
## Connect with a prior discover result
99

10-
`connect()` takes an optional `prior`: a persisted `DiscoverResult` from an earlier probe. With it, `connect()` adopts the server's advertisement directly and sends nothing on the wire.
10+
`connect()` takes an optional `prior`: a cached era verdict (`PriorDiscovery`). Its modern arm, `{ kind: 'modern', discover }`, wraps a persisted `DiscoverResult` from an earlier probe `connect()` adopts the server's advertisement directly and sends nothing on the wire.
1111

1212
```ts source="../../examples/guides/advanced/gateway.examples.ts#connect_prior"
1313
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
@@ -21,12 +21,12 @@ const persisted = JSON.stringify(bootstrap.getDiscoverResult());
2121

2222
// … then every other client connects with zero round trips.
2323
const worker = new Client({ name: 'worker', version: '1.0.0' });
24-
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
24+
await worker.connect(new StreamableHTTPClientTransport(url), { prior: { kind: 'modern', discover: JSON.parse(persisted) } });
2525
```
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+
The modern verdict is 2026-07-28+ only — see [Protocol versions](../protocol-versions.md). For a server known to be legacy, supply the negative verdict instead — see [Skip the probe for a known-legacy server](#skip-the-probe-for-a-known-legacy-server).
3030

3131
## Probe once at bootstrap
3232

@@ -50,17 +50,18 @@ The recorded value is the server's whole advertisement — supported versions, c
5050
```
5151

5252
::: tip
53-
An already-connected client can re-probe at any time: `await client.discover()` sends `server/discover` and updates `getDiscoverResult()`. A default-mode connect never probes, so its `getDiscoverResult()` is `undefined`[Protocol versions](../protocol-versions.md#pin-an-era) lists the negotiation modes.
53+
A client on a modern-era connection can re-probe at any time: `await client.discover()` sends `server/discover` and updates `getDiscoverResult()`. On a legacy-era connection `discover()` throws (`server/discover` is not a 2025-era method) — to re-check a legacy verdict, reconnect without a `prior` under `mode: 'auto'`, as in [Caching discovery verdicts](#caching-discovery-verdicts). A default-mode connect never probes, so its `getDiscoverResult()` is `undefined`[Protocol versions](../protocol-versions.md#pin-an-era) lists the negotiation modes.
5454
:::
5555

5656
## Persist the advertisement
5757

58-
The value is plain JSON. Write the string to Redis, a config map, or a process-local cache; parse it back wherever a client needs it.
58+
The value is plain JSON. Write the string to Redis, a config map, or a process-local cache; parse it back and wrap it as the modern verdict wherever a client needs it.
5959

6060
```ts source="../../examples/guides/advanced/gateway.examples.ts#persist_advertisement"
61-
import type { DiscoverResult } from '@modelcontextprotocol/client';
61+
import type { DiscoverResult, PriorDiscovery } from '@modelcontextprotocol/client';
6262

63-
const prior = JSON.parse(persisted) as DiscoverResult;
63+
const discover = JSON.parse(persisted) as DiscoverResult;
64+
const prior: PriorDiscovery = { kind: 'modern', discover };
6465
```
6566

6667
Nothing about `prior` is tied to the process that probed: any client that can reach the same URL can adopt it.
@@ -100,7 +101,7 @@ Never share a persisted `DiscoverResult` across principals — key the blob on t
100101

101102
## Open a listen stream when a worker needs notifications
102103

103-
`connect({ prior })` never auto-opens a `subscriptions/listen` stream — prior-connected clients are request-only until you open one yourself.
104+
A modern-verdict `connect({ prior })` never auto-opens a `subscriptions/listen` stream — the client is request-only until you open one yourself. (A legacy-verdict connect is an ordinary 2025-era connection: unsolicited notifications, no `listen()`.)
104105

105106
```ts source="../../examples/guides/advanced/gateway.examples.ts#listen_worker"
106107
const subscription = await worker.listen({ toolsListChanged: true });
@@ -116,21 +117,21 @@ The server acknowledges the filter it agreed to honor:
116117
From here the stream behaves like any other subscription — [Subscriptions](../clients/subscriptions.md) covers the notification handlers and the close semantics.
117118

118119
::: info
119-
A `listChanged` option configured on a prior-connected client registers its handlers but stays silent: no stream opens until you call `listen()`.
120+
A `listChanged` option configured on a modern-verdict client registers its handlers but stays silent: no stream opens until you call `listen()`.
120121
:::
121122

122123
## Handle a stale or incompatible advertisement
123124

124-
A `prior` that shares no 2026-07-28+ revision with the client rejects with `SdkError(EraNegotiationFailed)` before anything reaches the transport.
125+
A modern verdict whose `discover` shares no 2026-07-28+ revision with the client rejects with `SdkError(EraNegotiationFailed)` before anything reaches the transport.
125126

126127
```ts source="../../examples/guides/advanced/gateway.examples.ts#prior_stale"
127128
import { SdkError, SdkErrorCode } from '@modelcontextprotocol/client';
128129

129-
const stale: DiscoverResult = { ...prior, supportedVersions: ['2025-06-18'] };
130+
const stale: DiscoverResult = { ...discover, supportedVersions: ['2025-06-18'] };
130131

131132
const late = new Client({ name: 'worker-d', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
132133
try {
133-
await late.connect(new StreamableHTTPClientTransport(url), { prior: stale });
134+
await late.connect(new StreamableHTTPClientTransport(url), { prior: { kind: 'modern', discover: stale } });
134135
} catch (error) {
135136
if (!(error instanceof SdkError) || error.code !== SdkErrorCode.EraNegotiationFailed) throw error;
136137
console.log(error.code);
@@ -150,11 +151,95 @@ re-probed: 2026-07-28
150151

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

154+
## Skip the probe for a known-legacy server
155+
156+
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: `PriorDiscovery`'s `{ kind: 'legacy' }` arm skips the probe and goes straight to the `initialize` handshake.
157+
158+
```ts source="../../examples/guides/advanced/gateway.examples.ts#prior_legacy"
159+
const pinnedLegacy = new Client({ name: 'worker-e', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
160+
await pinnedLegacy.connect(new StreamableHTTPClientTransport(url), {
161+
prior: { kind: 'legacy' }
162+
});
163+
console.log(pinnedLegacy.getProtocolEra());
164+
```
165+
166+
```
167+
legacy
168+
```
169+
170+
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).
171+
172+
## Caching discovery verdicts
173+
174+
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.
175+
176+
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).
177+
178+
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.
179+
180+
```ts source="../../examples/guides/advanced/gateway.examples.ts#prior_cache"
181+
// Host-side verdict cache. A real deployment keeps this in Redis (a key TTL
182+
// does the dating: an expired read is no prior) or a database row with a
183+
// created_at column; the Map stores the timestamp explicitly.
184+
const verdicts = new Map<string, { verdict: PriorDiscovery; storedAt: number }>();
185+
const HORIZON_MS = 24 * 60 * 60 * 1000; // fixed-horizon freshness policy
186+
const fresh = (entry: { storedAt: number }): boolean => Date.now() - entry.storedAt < HORIZON_MS;
187+
188+
// Count server/discover probes at the fetch layer (the wire trace).
189+
let probes = 0;
190+
const tracingFetch: typeof fetch = async (input, init) => {
191+
if (typeof init?.body === 'string' && init.body.includes('"server/discover"')) probes++;
192+
return fetch(input, init);
193+
};
194+
195+
async function connectCached(key: string): Promise<Client> {
196+
const entry = verdicts.get(key);
197+
// An entry past the horizon is not supplied — under mode: 'auto' that IS
198+
// the re-probe, and the fresh outcome below re-populates the cache.
199+
const prior = entry && fresh(entry) ? entry.verdict : undefined;
200+
201+
const client = new Client({ name: 'cached-worker', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
202+
await client.connect(new StreamableHTTPClientTransport(url, { fetch: tracingFetch }), { prior });
203+
204+
if (prior === undefined) {
205+
// Fresh outcome: getDiscoverResult() is the DiscoverResult on a modern
206+
// server; its absence on a connected client means the era is legacy.
207+
const discover = client.getDiscoverResult();
208+
// Date the entry with the host's own clock — only this timestamp ever
209+
// retires a legacy verdict (a stale one succeeds silently forever).
210+
verdicts.set(key, {
211+
verdict: discover ? { kind: 'modern', discover } : { kind: 'legacy' },
212+
storedAt: Date.now()
213+
});
214+
}
215+
return client;
216+
}
217+
218+
const first = await connectCached('gateway-target'); // no entry: probes
219+
console.log('probes after first connect:', probes);
220+
221+
const second = await connectCached('gateway-target'); // fresh entry: verdict supplied, no probe
222+
console.log('probes after second connect:', probes);
223+
224+
verdicts.get('gateway-target')!.storedAt -= HORIZON_MS + 1; // the horizon passes
225+
const third = await connectCached('gateway-target'); // stale: dropped, re-probed, re-cached
226+
console.log('probes after the horizon passes:', probes);
227+
```
228+
229+
```
230+
probes after first connect: 1
231+
probes after second connect: 1
232+
probes after the horizon passes: 2
233+
```
234+
235+
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.
236+
153237
## Recap
154238

155-
- `connect(transport, { prior })` adopts a persisted `DiscoverResult` with zero round trips.
239+
- `connect(transport, { prior: { kind: 'modern', discover } })` adopts a persisted `DiscoverResult` with zero round trips.
156240
- The advertisement comes from one `'auto'`-mode or pinned probe — or an explicit `client.discover()` — and `getDiscoverResult()` reads it back.
157-
- The value is plain JSON: stringify it into a shared cache, parse it in any process that fronts the same server.
241+
- The value is plain JSON: stringify it into a shared cache, parse it back and wrap it as the modern verdict in any process that fronts the same server.
158242
- Reuse a `DiscoverResult` only across clients that present the same authorization context.
159-
- Prior-connected clients are request-only; call `listen()` on the one that needs notifications.
243+
- Modern-verdict clients are request-only; call `listen()` on the one that needs notifications.
160244
- An incompatible `prior` rejects with `SdkError(EraNegotiationFailed)`; fall back to a fresh probe and re-persist.
245+
- 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 `prior: { kind: 'modern', discover }` 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: { kind: 'modern', discover } })`
113+
(wrapping a persisted `DiscoverResult`) — or, for a server known out-of-band to be
114+
legacy, with `{ prior: { kind: 'legacy' } }`, which goes straight to `initialize`.
115+
Freshness 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: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1756,11 +1756,18 @@ the named class from the explicit subpath
17561756
(`@modelcontextprotocol/{client,server}/validators/ajv` or `…/cf-worker`) — importing
17571757
from a subpath means the corresponding peer dep must be in your `package.json`.
17581758
1759-
### `Client.connect(transport, { prior })` — zero-round-trip connect
1759+
### `Client.connect(transport, { prior })` — connect from a cached era verdict
17601760
17611761
Probe once, persist `client.getDiscoverResult()` (`JSON.stringify`), and feed it to
1762-
every worker as `client.connect(transport, { prior })` — 2026-07-28+ only. New exported
1763-
type `ConnectOptions` (extends `RequestOptions` with `prior?: DiscoverResult`).
1762+
every worker as `client.connect(transport, { prior: { kind: 'modern', discover } })`.
1763+
New exported types
1764+
`ConnectOptions` (extends `RequestOptions` with `prior?: PriorDiscovery`)
1765+
and `PriorDiscovery` — a cached era verdict: the modern arm wraps a `DiscoverResult`
1766+
(zero round trips), the legacy arm (`{ kind: 'legacy' }`) skips the probe and runs the
1767+
plain `initialize` handshake for servers known to be pre-2026. Freshness is the
1768+
supplying host's responsibility — date cached legacy verdicts in your own storage and
1769+
stop supplying them past your policy horizon (a stale one succeeds silently against an
1770+
upgraded server).
17641771
17651772
### Serving the 2026-07-28 revision
17661773

0 commit comments

Comments
 (0)