You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit a831d11
Browse filesBrowse the repository at this point in the historyBrowse 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.
`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.
`worker` is connected: `callTool` works immediately, and the server has not heard from it yet.
28
28
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).
30
30
31
31
## Probe once at bootstrap
32
32
@@ -150,6 +150,93 @@ re-probed: 2026-07-28
150
150
151
151
Replace the persisted blob with the fresh `getDiscoverResult()` and the rest of the fleet recovers on its next read.
152
152
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.
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.
const first =awaitconnectCached('gateway-target'); // no entry: probes
222
+
console.log('probes after first connect:', probes);
223
+
224
+
const second =awaitconnectCached('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 =awaitconnectCached('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
+
153
240
## Recap
154
241
155
242
-`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
158
245
- Reuse a `DiscoverResult` only across clients that present the same authorization context.
159
246
- Prior-connected clients are request-only; call `listen()` on the one that needs notifications.
160
247
- 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.
Copy file name to clipboardExpand all lines: docs/clients/caching.md
+4Lines changed: 4 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -94,6 +94,10 @@ Fresh or not, the cache also evicts itself when the server signals a change: a `
94
94
Cache hints are a 2026-07-28 surface — see [Protocol versions](../protocol-versions.md). Against a 2025-era server, `defaultCacheTtlMs` is the only lever.
95
95
:::
96
96
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
+
97
101
## Recap
98
102
99
103
- Caching is one feature with two halves: the server attaches `ttlMs` / `cacheScope`, the client honours them — by default neither half does anything alone.
Copy file name to clipboardExpand all lines: docs/clients/connect.md
+14-1Lines changed: 14 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -85,6 +85,19 @@ Call list-trips before book-trip. Dates are ISO 8601.
85
85
86
86
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.
87
87
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:
// 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
+
88
101
## Disconnect cleanly
89
102
90
103
Over Streamable HTTP, terminate the server-side session, then close the client.
@@ -101,6 +114,6 @@ await client.close();
101
114
-`new Client({ name, version })`, a transport, and `connect()` are the whole setup; `connect()` runs the `initialize` handshake.
102
115
-`StreamableHTTPClientTransport` connects to remote servers; `StdioClientTransport`, from `@modelcontextprotocol/client/stdio`, spawns local ones; `SSEClientTransport` is the fallback for SSE-only servers.
103
116
-`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).
105
118
-`close()` tears down the transport and rejects in-flight requests.
106
119
- Protocol-revision differences live on the protocol versions page, not here.
Copy file name to clipboardExpand all lines: docs/protocol-versions.md
+6Lines changed: 6 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -74,6 +74,12 @@ The rejection is a typed, local `SdkError` — nothing reaches the server beyond
74
74
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)
75
75
```
76
76
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
+
77
83
## Understand the probe
78
84
79
85
`probe` bounds the `server/discover` round trip that `'auto'` and a pin run before anything else.
Copy file name to clipboardExpand all lines: examples/gateway/README.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -47,4 +47,4 @@ The server exposes a `request_count` tool returning how many MCP requests reache
47
47
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
48
48
client-side capability gating.
49
49
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