feat(client): accept a cached era verdict on ConnectOptions.prior (#2511)
This commit is contained in:
parent
1480241e2a
commit
f60dff0674
17 changed files with 523 additions and 86 deletions
5
.changeset/prior-legacy-verdict.md
Normal file
5
.changeset/prior-legacy-verdict.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@modelcontextprotocol/client': minor
|
||||
---
|
||||
|
||||
`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`.
|
||||
|
|
@ -7,7 +7,7 @@ A **gateway** — a proxy, a worker pool, any process that fronts one MCP server
|
|||
|
||||
## Connect with a prior discover result
|
||||
|
||||
`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.
|
||||
`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.
|
||||
|
||||
```ts source="../../examples/guides/advanced/gateway.examples.ts#connect_prior"
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
|
@ -21,12 +21,12 @@ const persisted = JSON.stringify(bootstrap.getDiscoverResult());
|
|||
|
||||
// … then every other client connects with zero round trips.
|
||||
const worker = new Client({ name: 'worker', version: '1.0.0' });
|
||||
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
|
||||
await worker.connect(new StreamableHTTPClientTransport(url), { prior: { kind: 'modern', discover: JSON.parse(persisted) } });
|
||||
```
|
||||
|
||||
`worker` is connected: `callTool` works immediately, and the server has not heard from it yet.
|
||||
|
||||
`connect({ prior })` is 2026-07-28+ only — see [Protocol versions](../protocol-versions.md).
|
||||
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).
|
||||
|
||||
## Probe once at bootstrap
|
||||
|
||||
|
|
@ -50,17 +50,18 @@ The recorded value is the server's whole advertisement — supported versions, c
|
|||
```
|
||||
|
||||
::: tip
|
||||
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.
|
||||
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.
|
||||
:::
|
||||
|
||||
## Persist the advertisement
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
```ts source="../../examples/guides/advanced/gateway.examples.ts#persist_advertisement"
|
||||
import type { DiscoverResult } from '@modelcontextprotocol/client';
|
||||
import type { DiscoverResult, PriorDiscovery } from '@modelcontextprotocol/client';
|
||||
|
||||
const prior = JSON.parse(persisted) as DiscoverResult;
|
||||
const discover = JSON.parse(persisted) as DiscoverResult;
|
||||
const prior: PriorDiscovery = { kind: 'modern', discover };
|
||||
```
|
||||
|
||||
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
|
|||
|
||||
## Open a listen stream when a worker needs notifications
|
||||
|
||||
`connect({ prior })` never auto-opens a `subscriptions/listen` stream — prior-connected clients are request-only until you open one yourself.
|
||||
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()`.)
|
||||
|
||||
```ts source="../../examples/guides/advanced/gateway.examples.ts#listen_worker"
|
||||
const subscription = await worker.listen({ toolsListChanged: true });
|
||||
|
|
@ -116,21 +117,21 @@ The server acknowledges the filter it agreed to honor:
|
|||
From here the stream behaves like any other subscription — [Subscriptions](../clients/subscriptions.md) covers the notification handlers and the close semantics.
|
||||
|
||||
::: info
|
||||
A `listChanged` option configured on a prior-connected client registers its handlers but stays silent: no stream opens until you call `listen()`.
|
||||
A `listChanged` option configured on a modern-verdict client registers its handlers but stays silent: no stream opens until you call `listen()`.
|
||||
:::
|
||||
|
||||
## Handle a stale or incompatible advertisement
|
||||
|
||||
A `prior` that shares no 2026-07-28+ revision with the client rejects with `SdkError(EraNegotiationFailed)` before anything reaches the transport.
|
||||
A modern verdict whose `discover` shares no 2026-07-28+ revision with the client rejects with `SdkError(EraNegotiationFailed)` before anything reaches the transport.
|
||||
|
||||
```ts source="../../examples/guides/advanced/gateway.examples.ts#prior_stale"
|
||||
import { SdkError, SdkErrorCode } from '@modelcontextprotocol/client';
|
||||
|
||||
const stale: DiscoverResult = { ...prior, supportedVersions: ['2025-06-18'] };
|
||||
const stale: DiscoverResult = { ...discover, supportedVersions: ['2025-06-18'] };
|
||||
|
||||
const late = new Client({ name: 'worker-d', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
try {
|
||||
await late.connect(new StreamableHTTPClientTransport(url), { prior: stale });
|
||||
await late.connect(new StreamableHTTPClientTransport(url), { prior: { kind: 'modern', discover: stale } });
|
||||
} catch (error) {
|
||||
if (!(error instanceof SdkError) || error.code !== SdkErrorCode.EraNegotiationFailed) throw error;
|
||||
console.log(error.code);
|
||||
|
|
@ -150,11 +151,95 @@ re-probed: 2026-07-28
|
|||
|
||||
Replace the persisted blob with the fresh `getDiscoverResult()` and the rest of the fleet recovers on its next read.
|
||||
|
||||
## Skip the probe for a known-legacy server
|
||||
|
||||
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.
|
||||
|
||||
```ts source="../../examples/guides/advanced/gateway.examples.ts#prior_legacy"
|
||||
const pinnedLegacy = new Client({ name: 'worker-e', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
await pinnedLegacy.connect(new StreamableHTTPClientTransport(url), {
|
||||
prior: { kind: 'legacy' }
|
||||
});
|
||||
console.log(pinnedLegacy.getProtocolEra());
|
||||
```
|
||||
|
||||
```
|
||||
legacy
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
## Caching discovery verdicts
|
||||
|
||||
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.
|
||||
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
```ts source="../../examples/guides/advanced/gateway.examples.ts#prior_cache"
|
||||
// Host-side verdict cache. A real deployment keeps this in Redis (a key TTL
|
||||
// does the dating: an expired read is no prior) or a database row with a
|
||||
// created_at column; the Map stores the timestamp explicitly.
|
||||
const verdicts = new Map<string, { verdict: PriorDiscovery; storedAt: number }>();
|
||||
const HORIZON_MS = 24 * 60 * 60 * 1000; // fixed-horizon freshness policy
|
||||
const fresh = (entry: { storedAt: number }): boolean => Date.now() - entry.storedAt < HORIZON_MS;
|
||||
|
||||
// Count server/discover probes at the fetch layer (the wire trace).
|
||||
let probes = 0;
|
||||
const tracingFetch: typeof fetch = async (input, init) => {
|
||||
if (typeof init?.body === 'string' && init.body.includes('"server/discover"')) probes++;
|
||||
return fetch(input, init);
|
||||
};
|
||||
|
||||
async function connectCached(key: string): Promise<Client> {
|
||||
const entry = verdicts.get(key);
|
||||
// An entry past the horizon is not supplied — under mode: 'auto' that IS
|
||||
// the re-probe, and the fresh outcome below re-populates the cache.
|
||||
const prior = entry && fresh(entry) ? entry.verdict : undefined;
|
||||
|
||||
const client = new Client({ name: 'cached-worker', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
await client.connect(new StreamableHTTPClientTransport(url, { fetch: tracingFetch }), { prior });
|
||||
|
||||
if (prior === undefined) {
|
||||
// Fresh outcome: getDiscoverResult() is the DiscoverResult on a modern
|
||||
// server; its absence on a connected client means the era is legacy.
|
||||
const discover = client.getDiscoverResult();
|
||||
// Date the entry with the host's own clock — only this timestamp ever
|
||||
// retires a legacy verdict (a stale one succeeds silently forever).
|
||||
verdicts.set(key, {
|
||||
verdict: discover ? { kind: 'modern', discover } : { kind: 'legacy' },
|
||||
storedAt: Date.now()
|
||||
});
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
const first = await connectCached('gateway-target'); // no entry: probes
|
||||
console.log('probes after first connect:', probes);
|
||||
|
||||
const second = await connectCached('gateway-target'); // fresh entry: verdict supplied, no probe
|
||||
console.log('probes after second connect:', probes);
|
||||
|
||||
verdicts.get('gateway-target')!.storedAt -= HORIZON_MS + 1; // the horizon passes
|
||||
const third = await connectCached('gateway-target'); // stale: dropped, re-probed, re-cached
|
||||
console.log('probes after the horizon passes:', probes);
|
||||
```
|
||||
|
||||
```
|
||||
probes after first connect: 1
|
||||
probes after second connect: 1
|
||||
probes after the horizon passes: 2
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Recap
|
||||
|
||||
- `connect(transport, { prior })` adopts a persisted `DiscoverResult` with zero round trips.
|
||||
- `connect(transport, { prior: { kind: 'modern', discover } })` adopts a persisted `DiscoverResult` with zero round trips.
|
||||
- The advertisement comes from one `'auto'`-mode or pinned probe — or an explicit `client.discover()` — and `getDiscoverResult()` reads it back.
|
||||
- The value is plain JSON: stringify it into a shared cache, parse it in any process that fronts the same server.
|
||||
- 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.
|
||||
- Reuse a `DiscoverResult` only across clients that present the same authorization context.
|
||||
- Prior-connected clients are request-only; call `listen()` on the one that needs notifications.
|
||||
- Modern-verdict clients are request-only; call `listen()` on the one that needs notifications.
|
||||
- An incompatible `prior` rejects with `SdkError(EraNegotiationFailed)`; fall back to a fresh probe and re-persist.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -94,6 +94,10 @@ Fresh or not, the cache also evicts itself when the server signals a change: a `
|
|||
Cache hints are a 2026-07-28 surface — see [Protocol versions](../protocol-versions.md). Against a 2025-era server, `defaultCacheTtlMs` is the only lever.
|
||||
:::
|
||||
|
||||
## Two caches, two owners
|
||||
|
||||
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.
|
||||
|
||||
## Recap
|
||||
|
||||
- Caching is one feature with two halves: the server attaches `ttlMs` / `cacheScope`, the client honours them — by default neither half does anything alone.
|
||||
|
|
|
|||
|
|
@ -85,6 +85,19 @@ Call list-trips before book-trip. Dates are ISO 8601.
|
|||
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
```ts source="../../examples/guides/clients/connect.examples.ts#connect_discoverResult"
|
||||
// The default mode ran the legacy initialize handshake — no DiscoverResult.
|
||||
console.log(client.getDiscoverResult());
|
||||
```
|
||||
|
||||
```
|
||||
undefined
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Disconnect cleanly
|
||||
|
||||
Over Streamable HTTP, terminate the server-side session, then close the client.
|
||||
|
|
@ -101,6 +114,6 @@ await client.close();
|
|||
- `new Client({ name, version })`, a transport, and `connect()` are the whole setup; `connect()` runs the `initialize` handshake.
|
||||
- `StreamableHTTPClientTransport` connects to remote servers; `StdioClientTransport`, from `@modelcontextprotocol/client/stdio`, spawns local ones; `SSEClientTransport` is the fallback for SSE-only servers.
|
||||
- `InMemoryTransport.createLinkedPair()` links a client and a server in one process.
|
||||
- After `connect()`, `getServerVersion()`, `getServerCapabilities()`, and `getInstructions()` return what the server declared.
|
||||
- After `connect()`, `getServerVersion()`, `getServerCapabilities()`, and `getInstructions()` return what the server declared; `getDiscoverResult()` tells the eras apart (present = modern, absent = legacy).
|
||||
- `close()` tears down the transport and rejects in-flight requests.
|
||||
- Protocol-revision differences live on the protocol versions page, not here.
|
||||
|
|
|
|||
|
|
@ -109,7 +109,12 @@ The probe request itself already carries the per-request `_meta` envelope
|
|||
the envelope to every outgoing request and notification. Tooling that classifies
|
||||
traffic must not treat "saw an envelope" as "modern era negotiated": the legacy-fallback
|
||||
path also begins with one enveloped probe. A gateway/worker fleet can skip the
|
||||
probe entirely with `client.connect(transport, { prior: persistedDiscoverResult })`.
|
||||
probe entirely with `client.connect(transport, { prior: { kind: 'modern', discover } })`
|
||||
(wrapping a persisted `DiscoverResult`) — or, for a server known out-of-band to be
|
||||
legacy, with `{ prior: { kind: 'legacy' } }`, which goes straight to `initialize`.
|
||||
Freshness of a cached legacy verdict is the host's responsibility (a stale one succeeds
|
||||
silently against an upgraded server); stop supplying it and the configured
|
||||
mode decides again (an `'auto'` client re-probes).
|
||||
|
||||
### Server over HTTP: `createMcpHandler`
|
||||
|
||||
|
|
|
|||
|
|
@ -1756,11 +1756,18 @@ the named class from the explicit subpath
|
|||
(`@modelcontextprotocol/{client,server}/validators/ajv` or `…/cf-worker`) — importing
|
||||
from a subpath means the corresponding peer dep must be in your `package.json`.
|
||||
|
||||
### `Client.connect(transport, { prior })` — zero-round-trip connect
|
||||
### `Client.connect(transport, { prior })` — connect from a cached era verdict
|
||||
|
||||
Probe once, persist `client.getDiscoverResult()` (`JSON.stringify`), and feed it to
|
||||
every worker as `client.connect(transport, { prior })` — 2026-07-28+ only. New exported
|
||||
type `ConnectOptions` (extends `RequestOptions` with `prior?: DiscoverResult`).
|
||||
every worker as `client.connect(transport, { prior: { kind: 'modern', discover } })`.
|
||||
New exported types
|
||||
`ConnectOptions` (extends `RequestOptions` with `prior?: PriorDiscovery`)
|
||||
and `PriorDiscovery` — a cached era verdict: the modern arm wraps a `DiscoverResult`
|
||||
(zero round trips), the legacy arm (`{ kind: 'legacy' }`) skips the probe and runs the
|
||||
plain `initialize` handshake for servers known to be pre-2026. Freshness is the
|
||||
supplying 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).
|
||||
|
||||
### Serving the 2026-07-28 revision
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,12 @@ The rejection is a typed, local `SdkError` — nothing reaches the server beyond
|
|||
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)
|
||||
```
|
||||
|
||||
## Skip the probe with a cached verdict
|
||||
|
||||
`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`, the exported `PriorDiscovery` type: `{ kind: 'modern', discover }` adopts a previously obtained `DiscoverResult` with zero round trips, and `{ kind: 'legacy' }` goes straight to the `initialize` handshake.
|
||||
|
||||
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.
|
||||
|
||||
## Understand the probe
|
||||
|
||||
`probe` bounds the `server/discover` round trip that `'auto'` and a pin run before anything else.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# gateway
|
||||
|
||||
`connect({ prior: DiscoverResult })` — zero-round-trip connect for gateways and distributed clients (protocol revision 2026-07-28).
|
||||
`connect({ prior: { kind: 'modern', discover } })` — zero-round-trip connect for gateways and distributed clients (protocol revision 2026-07-28).
|
||||
|
||||
```bash
|
||||
pnpm --filter @mcp-examples/gateway server -- --http --port 3000
|
||||
|
|
@ -21,11 +21,11 @@ await bootstrap.close();
|
|||
|
||||
// 2. Every worker: zero-round-trip connect from the persisted blob.
|
||||
const worker = new Client({ name: 'worker', version: '1.0.0' });
|
||||
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
|
||||
await worker.connect(new StreamableHTTPClientTransport(url), { prior: { kind: 'modern', discover: JSON.parse(persisted) } });
|
||||
await worker.callTool({ name: 'echo', arguments: { text: 'hi' } }); // first wire traffic
|
||||
```
|
||||
|
||||
`getDiscoverResult()` is populated by the `'auto'`/pinned probe path, by `client.discover()`, and by `connect({ prior })` itself. The value round-trips through `JSON.stringify`/`JSON.parse`.
|
||||
`getDiscoverResult()` is populated by the `'auto'`/pinned probe path, by `client.discover()`, and by a modern-verdict `connect({ prior })` (a legacy verdict leaves it `undefined`). The value round-trips through `JSON.stringify`/`JSON.parse`.
|
||||
|
||||
## What this story asserts
|
||||
|
||||
|
|
@ -47,4 +47,4 @@ The server exposes a `request_count` tool returning how many MCP requests reache
|
|||
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
|
||||
client-side capability gating.
|
||||
|
||||
`connect({ prior })` is **modern-only**: no mutual 2026-07-28+ revision → `SdkError(EraNegotiationFailed)`. Use `versionNegotiation: { mode: 'auto' }` for legacy-era fallback.
|
||||
The modern verdict — `prior: { kind: 'modern', discover }` wrapping a persisted `DiscoverResult` — 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).
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
* 2. The result is `JSON.stringify`-ed (the "persist" step — in a real gateway
|
||||
* you would write this to Redis, a config map, or a process-local cache).
|
||||
* 3. Three fresh worker clients connect with
|
||||
* `connect(transport, { prior: JSON.parse(persisted) })`: each connect()
|
||||
* sends nothing on the wire, and `callTool` works immediately.
|
||||
* `connect(transport, { prior: { kind: 'modern', discover: JSON.parse(persisted) } })`:
|
||||
* each connect() sends nothing on the wire, and `callTool` works immediately.
|
||||
* 4. The server's `request_count` tool proves it: after three worker connects
|
||||
* the count is unchanged (no extra discover/initialize from the workers).
|
||||
*
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
* `DiscoverResult` across principals.
|
||||
*/
|
||||
import { check, parseExampleArgs } from '@mcp-examples/shared';
|
||||
import type { DiscoverResult } from '@modelcontextprotocol/client';
|
||||
import type { DiscoverResult, PriorDiscovery } from '@modelcontextprotocol/client';
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
async function requestCount(client: Client): Promise<number> {
|
||||
|
|
@ -56,7 +56,7 @@ await bootstrap.close();
|
|||
// round trips each. Every worker presents the same authorization context
|
||||
// as the bootstrap (unauthenticated here), so reuse is safe.
|
||||
// ---------------------------------------------------------------------
|
||||
const prior: DiscoverResult = JSON.parse(persisted) as DiscoverResult;
|
||||
const prior: PriorDiscovery = { kind: 'modern', discover: JSON.parse(persisted) as DiscoverResult };
|
||||
const workers = await Promise.all(
|
||||
['worker-a', 'worker-b', 'worker-c'].map(async name => {
|
||||
const worker = new Client({ name, version: '1.0.0' });
|
||||
|
|
|
|||
|
|
@ -30,6 +30,14 @@ import * as z from 'zod/v4';
|
|||
|
||||
let requests = 0;
|
||||
|
||||
/** Everything the page prints, for the self-verifying asserts at teardown. */
|
||||
const logged: string[] = [];
|
||||
const realLog = console.log;
|
||||
console.log = (...args: unknown[]): void => {
|
||||
logged.push(args.map(String).join(' '));
|
||||
realLog(...args);
|
||||
};
|
||||
|
||||
const handler = createMcpHandler(() => {
|
||||
requests++;
|
||||
const server = new McpServer({ name: 'gateway-target', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } });
|
||||
|
|
@ -65,7 +73,7 @@ const persisted = JSON.stringify(bootstrap.getDiscoverResult());
|
|||
|
||||
// … then every other client connects with zero round trips.
|
||||
const worker = new Client({ name: 'worker', version: '1.0.0' });
|
||||
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
|
||||
await worker.connect(new StreamableHTTPClientTransport(url), { prior: { kind: 'modern', discover: JSON.parse(persisted) } });
|
||||
//#endregion connect_prior
|
||||
|
||||
// ## Probe once at bootstrap
|
||||
|
|
@ -77,9 +85,10 @@ console.log(bootstrap.getDiscoverResult());
|
|||
// ## Persist the advertisement
|
||||
|
||||
//#region persist_advertisement
|
||||
import type { DiscoverResult } from '@modelcontextprotocol/client';
|
||||
import type { DiscoverResult, PriorDiscovery } from '@modelcontextprotocol/client';
|
||||
|
||||
const prior = JSON.parse(persisted) as DiscoverResult;
|
||||
const discover = JSON.parse(persisted) as DiscoverResult;
|
||||
const prior: PriorDiscovery = { kind: 'modern', discover };
|
||||
//#endregion persist_advertisement
|
||||
|
||||
// ## Fan out to workers
|
||||
|
|
@ -111,11 +120,11 @@ await subscription.close();
|
|||
//#region prior_stale
|
||||
import { SdkError, SdkErrorCode } from '@modelcontextprotocol/client';
|
||||
|
||||
const stale: DiscoverResult = { ...prior, supportedVersions: ['2025-06-18'] };
|
||||
const stale: DiscoverResult = { ...discover, supportedVersions: ['2025-06-18'] };
|
||||
|
||||
const late = new Client({ name: 'worker-d', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
try {
|
||||
await late.connect(new StreamableHTTPClientTransport(url), { prior: stale });
|
||||
await late.connect(new StreamableHTTPClientTransport(url), { prior: { kind: 'modern', discover: stale } });
|
||||
} catch (error) {
|
||||
if (!(error instanceof SdkError) || error.code !== SdkErrorCode.EraNegotiationFailed) throw error;
|
||||
console.log(error.code);
|
||||
|
|
@ -126,10 +135,91 @@ try {
|
|||
}
|
||||
//#endregion prior_stale
|
||||
|
||||
// ## Skip the probe for a known-legacy server
|
||||
|
||||
//#region prior_legacy
|
||||
const pinnedLegacy = new Client({ name: 'worker-e', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
await pinnedLegacy.connect(new StreamableHTTPClientTransport(url), {
|
||||
prior: { kind: 'legacy' }
|
||||
});
|
||||
console.log(pinnedLegacy.getProtocolEra());
|
||||
//#endregion prior_legacy
|
||||
|
||||
// ## Caching discovery verdicts
|
||||
|
||||
//#region prior_cache
|
||||
// Host-side verdict cache. A real deployment keeps this in Redis (a key TTL
|
||||
// does the dating: an expired read is no prior) or a database row with a
|
||||
// created_at column; the Map stores the timestamp explicitly.
|
||||
const verdicts = new Map<string, { verdict: PriorDiscovery; storedAt: number }>();
|
||||
const HORIZON_MS = 24 * 60 * 60 * 1000; // fixed-horizon freshness policy
|
||||
const fresh = (entry: { storedAt: number }): boolean => Date.now() - entry.storedAt < HORIZON_MS;
|
||||
|
||||
// Count server/discover probes at the fetch layer (the wire trace).
|
||||
let probes = 0;
|
||||
const tracingFetch: typeof fetch = async (input, init) => {
|
||||
if (typeof init?.body === 'string' && init.body.includes('"server/discover"')) probes++;
|
||||
return fetch(input, init);
|
||||
};
|
||||
|
||||
async function connectCached(key: string): Promise<Client> {
|
||||
const entry = verdicts.get(key);
|
||||
// An entry past the horizon is not supplied — under mode: 'auto' that IS
|
||||
// the re-probe, and the fresh outcome below re-populates the cache.
|
||||
const prior = entry && fresh(entry) ? entry.verdict : undefined;
|
||||
|
||||
const client = new Client({ name: 'cached-worker', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
await client.connect(new StreamableHTTPClientTransport(url, { fetch: tracingFetch }), { prior });
|
||||
|
||||
if (prior === undefined) {
|
||||
// Fresh outcome: getDiscoverResult() is the DiscoverResult on a modern
|
||||
// server; its absence on a connected client means the era is legacy.
|
||||
const discover = client.getDiscoverResult();
|
||||
// Date the entry with the host's own clock — only this timestamp ever
|
||||
// retires a legacy verdict (a stale one succeeds silently forever).
|
||||
verdicts.set(key, {
|
||||
verdict: discover ? { kind: 'modern', discover } : { kind: 'legacy' },
|
||||
storedAt: Date.now()
|
||||
});
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
const first = await connectCached('gateway-target'); // no entry: probes
|
||||
console.log('probes after first connect:', probes);
|
||||
|
||||
const second = await connectCached('gateway-target'); // fresh entry: verdict supplied, no probe
|
||||
console.log('probes after second connect:', probes);
|
||||
|
||||
verdicts.get('gateway-target')!.storedAt -= HORIZON_MS + 1; // the horizon passes
|
||||
const third = await connectCached('gateway-target'); // stale: dropped, re-probed, re-cached
|
||||
console.log('probes after the horizon passes:', probes);
|
||||
//#endregion prior_cache
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness teardown.
|
||||
// Harness teardown + self-verification of the string lines the page quotes
|
||||
// (object dumps stringify uselessly and are not checked here).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
for (const client of [bootstrap, worker, late, ...fleet]) await client.close();
|
||||
for (const client of [bootstrap, worker, late, pinnedLegacy, first, second, third, ...fleet]) await client.close();
|
||||
await handler.close();
|
||||
globalThis.fetch = realFetch;
|
||||
console.log = realLog;
|
||||
|
||||
const mustHaveLogged = [
|
||||
'ERA_NEGOTIATION_FAILED',
|
||||
're-probed: 2026-07-28',
|
||||
'legacy',
|
||||
'probes after first connect: 1',
|
||||
'probes after second connect: 1',
|
||||
'probes after the horizon passes: 2'
|
||||
];
|
||||
for (const line of mustHaveLogged) {
|
||||
if (!logged.includes(line)) throw new Error(`page output mismatch: expected "${line}" in:\n${logged.join('\n')}`);
|
||||
}
|
||||
const recached = verdicts.get('gateway-target');
|
||||
if (recached?.verdict.kind !== 'modern' || !fresh(recached)) {
|
||||
// fresh() proves storedAt was rewritten after the horizon rollback — the
|
||||
// re-probe really re-populated the cache, not just left the old entry.
|
||||
throw new Error('expected the horizon re-probe to re-populate the cache with a fresh modern verdict');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,11 @@ console.log(client.getServerCapabilities());
|
|||
console.log(client.getInstructions());
|
||||
//#endregion connect_introspect
|
||||
|
||||
//#region connect_discoverResult
|
||||
// The default mode ran the legacy initialize handshake — no DiscoverResult.
|
||||
console.log(client.getDiscoverResult());
|
||||
//#endregion connect_discoverResult
|
||||
|
||||
// ## Disconnect cleanly
|
||||
|
||||
//#region connect_close
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ import {
|
|||
SUPPORTED_MODERN_PROTOCOL_VERSIONS
|
||||
} from '@modelcontextprotocol/core-internal';
|
||||
|
||||
import type { PriorDiscovery } from './probeClassifier';
|
||||
import type { CacheMode, CacheScope, ResponseCacheStore } from './responseCache';
|
||||
import { ClientResponseCache, InMemoryResponseCacheStore, MAX_CACHE_TTL_MS } from './responseCache';
|
||||
import type { ResolvedVersionNegotiation, VersionNegotiationOptions } from './versionNegotiation';
|
||||
|
|
@ -322,19 +323,44 @@ export type ClientOptions = ProtocolOptions & {
|
|||
/**
|
||||
* Options for {@linkcode Client.connect}. Extends {@linkcode RequestOptions}
|
||||
* (the timeout/signal apply to the connect-time handshake or probe) with the
|
||||
* zero-round-trip reconnect knob.
|
||||
* cached-era-verdict knob.
|
||||
*/
|
||||
export type ConnectOptions = RequestOptions & {
|
||||
/**
|
||||
* A previously-obtained {@linkcode DiscoverResult} (see
|
||||
* {@linkcode Client.getDiscoverResult}). When supplied, `connect()` adopts
|
||||
* it directly — zero round trips. 2026-07-28+ only: throws
|
||||
* `SdkError(EraNegotiationFailed)` when there is no modern overlap. Only
|
||||
* reuse across clients presenting the same authorization context.
|
||||
* A cached era verdict, taking precedence over `versionNegotiation`:
|
||||
* `{ kind: 'modern', discover }` adopts a prior {@linkcode DiscoverResult}
|
||||
* with zero round trips (throws `SdkError(EraNegotiationFailed)` on no
|
||||
* 2026-07-28+ overlap); `{ kind: 'legacy' }` skips the probe and runs the
|
||||
* plain legacy `initialize` handshake. Freshness is the supplying host's
|
||||
* responsibility — see {@linkcode PriorDiscovery}. Reuse only within one
|
||||
* authorization context.
|
||||
*/
|
||||
prior?: DiscoverResult;
|
||||
prior?: PriorDiscovery;
|
||||
};
|
||||
|
||||
/**
|
||||
* Rejects malformed persisted blobs with a typed `SdkError` before any state
|
||||
* change: unrecognized `kind`, a legacy verdict also carrying
|
||||
* DiscoverResult-shaped members (a corrupt blob must never era-choose), or a
|
||||
* modern `discover` payload failing the wire path's schema.
|
||||
*/
|
||||
function validatePrior(prior: PriorDiscovery): PriorDiscovery {
|
||||
if (typeof prior === 'object' && prior !== null) {
|
||||
if (prior.kind === 'legacy' && !('supportedVersions' in prior) && !('discover' in prior)) {
|
||||
return prior;
|
||||
}
|
||||
// Validation only: the blob is adopted verbatim, so unknown nested
|
||||
// members survive re-persistence via getDiscoverResult().
|
||||
if (prior.kind === 'modern' && DiscoverResultSchema.safeParse(prior.discover).success) {
|
||||
return prior;
|
||||
}
|
||||
}
|
||||
throw new SdkError(
|
||||
SdkErrorCode.EraNegotiationFailed,
|
||||
"connect({ prior }): unrecognized prior — expected { kind: 'modern', discover } or { kind: 'legacy' }"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@linkcode RequestOptions} extended with the per-call cache disposition for
|
||||
* the cacheable verbs (`listTools()` / `listPrompts()` / `listResources()` /
|
||||
|
|
@ -927,16 +953,25 @@ export class Client extends Protocol<ClientContext> {
|
|||
* ```
|
||||
*/
|
||||
override async connect(transport: Transport, options?: ConnectOptions): Promise<void> {
|
||||
if (options?.prior !== undefined) {
|
||||
// Zero-round-trip reconnect from a previously-obtained
|
||||
// DiscoverResult: bypasses versionNegotiation resolution entirely.
|
||||
return this._connectFromPrior(transport, options.prior);
|
||||
// `!= null`: a persisted prior slot naturally revives as JSON `null`
|
||||
// — treat it like an absent option, not a shape error.
|
||||
if (options?.prior != null) {
|
||||
// Cached era verdict: bypasses versionNegotiation resolution entirely.
|
||||
return this._connectFromPrior(transport, validatePrior(options.prior), options);
|
||||
}
|
||||
// The configured versionNegotiation mode decides the era.
|
||||
const negotiation = resolveVersionNegotiation(this._versionNegotiation, this._supportedProtocolVersionsOption);
|
||||
if (negotiation.kind !== 'legacy') {
|
||||
return this._connectNegotiated(transport, negotiation, options);
|
||||
}
|
||||
// Plain legacy connect — the pinned 2025 sequence, byte-untouched.
|
||||
return this._connectPlainLegacy(transport, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain legacy connect — the pinned 2025 sequence, byte-untouched. The
|
||||
* `mode: 'legacy'` connect body, shared with the `prior` legacy verdict.
|
||||
*/
|
||||
private async _connectPlainLegacy(transport: Transport, options?: RequestOptions): Promise<void> {
|
||||
await super.connect(transport);
|
||||
// When transport sessionId is already set this means we are trying to reconnect.
|
||||
// Restore the protocol version negotiated during the original initialize handshake
|
||||
|
|
@ -1186,32 +1221,39 @@ export class Client extends Protocol<ClientContext> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Connect from a previously-obtained {@linkcode DiscoverResult}. Always
|
||||
* zero-round-trip; throws `EraNegotiationFailed` when there is no
|
||||
* 2026-07-28+ overlap (no legacy fallback). See {@linkcode ConnectOptions}.
|
||||
* Connect from a validated {@linkcode PriorDiscovery}: the modern arm
|
||||
* adopts the `DiscoverResult` (zero round trips; `EraNegotiationFailed`
|
||||
* on no 2026-07-28+ overlap), the legacy arm runs the plain legacy connect.
|
||||
*/
|
||||
private async _connectFromPrior(transport: Transport, prior: DiscoverResult): Promise<void> {
|
||||
private async _connectFromPrior(transport: Transport, prior: PriorDiscovery, options?: RequestOptions): Promise<void> {
|
||||
if (prior.kind === 'legacy') {
|
||||
// Known-legacy verdict: byte-identical to a mode: 'legacy' connect.
|
||||
return this._connectPlainLegacy(transport, options);
|
||||
}
|
||||
const discover = prior.discover;
|
||||
this._resetConnectionState();
|
||||
|
||||
const explicit = this._supportedProtocolVersionsOption;
|
||||
const clientModern =
|
||||
explicit && modernProtocolVersions(explicit).length > 0 ? modernProtocolVersions(explicit) : SUPPORTED_MODERN_PROTOCOL_VERSIONS;
|
||||
const version = clientModern.find(v => prior.supportedVersions.includes(v));
|
||||
const version = clientModern.find(v => discover.supportedVersions.includes(v));
|
||||
if (version === undefined) {
|
||||
throw new SdkError(
|
||||
SdkErrorCode.EraNegotiationFailed,
|
||||
"connect({ prior }) requires a 2026-07-28+ mutual protocol version; the supplied DiscoverResult and this client's " +
|
||||
"supportedProtocolVersions have no modern overlap. Use versionNegotiation: { mode: 'auto' } for legacy-era fallback."
|
||||
'connect({ prior }) with a modern verdict requires a 2026-07-28+ mutual protocol version; the supplied DiscoverResult and ' +
|
||||
"this client's supportedProtocolVersions have no modern overlap. For a server known to be legacy, pass " +
|
||||
"prior: { kind: 'legacy' } to skip the probe and initialize directly, or use " +
|
||||
"versionNegotiation: { mode: 'auto' } to re-probe with legacy fallback."
|
||||
);
|
||||
}
|
||||
|
||||
await super.connect(transport);
|
||||
|
||||
this._discoverResult = prior;
|
||||
this._serverCapabilities = prior.capabilities;
|
||||
this._serverVersion = prior.serverInfo;
|
||||
this._discoverResult = discover;
|
||||
this._serverCapabilities = discover.capabilities;
|
||||
this._serverVersion = discover.serverInfo;
|
||||
this._cache.setServerIdentity(this._deriveServerIdentity(transport));
|
||||
this._instructions = prior.instructions;
|
||||
this._instructions = discover.instructions;
|
||||
this._negotiatedProtocolVersion = version;
|
||||
transport.setProtocolVersion?.(version);
|
||||
|
||||
|
|
@ -1284,8 +1326,11 @@ export class Client extends Protocol<ClientContext> {
|
|||
|
||||
/**
|
||||
* The {@linkcode DiscoverResult} from the last `'auto'`/pinned probe,
|
||||
* {@linkcode discover} call, or `connect({ prior })`. Persistable via
|
||||
* `JSON.stringify`; feed to {@linkcode ConnectOptions} `prior`.
|
||||
* {@linkcode discover} call, or `connect({ prior })` that adopted a
|
||||
* modern verdict (a legacy verdict leaves this `undefined` — there is no
|
||||
* `DiscoverResult` on that path). Persistable via `JSON.stringify`; wrap
|
||||
* as `{ kind: 'modern', discover }` and feed to {@linkcode ConnectOptions}
|
||||
* `prior`.
|
||||
*/
|
||||
getDiscoverResult(): DiscoverResult | undefined {
|
||||
return this._discoverResult;
|
||||
|
|
|
|||
|
|
@ -87,6 +87,22 @@ export type ProbeVerdict =
|
|||
/** Typed connect error — never converted to an era verdict. */
|
||||
| { kind: 'error'; error: Error };
|
||||
|
||||
/**
|
||||
* A cached era verdict for `connect({ prior })` — the persistable subset of
|
||||
* the {@linkcode ProbeVerdict} vocabulary (`'modern'` = speaks 2026-07-28+,
|
||||
* `'legacy'` = 2025-era `initialize` only). Freshness is the supplying host's
|
||||
* responsibility: a stale modern verdict fails loudly at the first request,
|
||||
* but a stale legacy verdict succeeds silently forever (an upgraded server
|
||||
* still answers `initialize`) — date cached legacy verdicts in your own
|
||||
* storage and stop supplying them past your policy horizon. Full recipe:
|
||||
* the gateway guide (`docs/advanced/gateway.md`).
|
||||
*/
|
||||
export type PriorDiscovery =
|
||||
/** Adopt `discover` directly: zero round trips. */
|
||||
| { kind: 'modern'; discover: DiscoverResult }
|
||||
/** Known-legacy server: skip the `server/discover` probe and run the plain legacy `initialize` handshake. */
|
||||
| { kind: 'legacy' };
|
||||
|
||||
/** The `-32022` UnsupportedProtocolVersion protocol error code (negotiation-phase recognition). */
|
||||
const UNSUPPORTED_PROTOCOL_VERSION = -32_022;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export type { DiscoverAndRequestJwtAuthGrantOptions, JwtAuthGrantResult, Request
|
|||
export { discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant, requestJwtAuthorizationGrant } from './client/crossAppAccess';
|
||||
export type { LoggingOptions, Middleware, RequestLogger } from './client/middleware';
|
||||
export { applyMiddlewares, createMiddleware, withLogging, withOAuth } from './client/middleware';
|
||||
export type { PriorDiscovery } from './client/probeClassifier';
|
||||
export type {
|
||||
CacheEntry,
|
||||
CacheKey,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
/**
|
||||
* `connect({ prior: DiscoverResult })` — zero-round-trip reconnect for the
|
||||
* gateway / distributed-client pattern (issue #79). A previously-obtained
|
||||
* `DiscoverResult` adopted directly: on a modern overlap nothing reaches the
|
||||
* wire during connect; no modern overlap throws `EraNegotiationFailed` (no
|
||||
* legacy fallback). Populates `getDiscoverResult()` (alongside the
|
||||
* `'auto'`-mode probe path) and round-trips through JSON.
|
||||
* `connect({ prior })` — connect from a cached era verdict (`PriorDiscovery`),
|
||||
* for the gateway / distributed-client pattern (issue #79). A
|
||||
* `{ kind: 'modern', discover }` verdict is adopted directly: on a modern
|
||||
* overlap nothing reaches the wire during connect; no modern overlap throws
|
||||
* `EraNegotiationFailed`. A `{ kind: 'legacy' }` verdict skips the probe and
|
||||
* runs the plain `initialize` handshake. Freshness is the supplying host's
|
||||
* responsibility — the SDK adopts whatever verdict it is handed. Populates
|
||||
* `getDiscoverResult()` (modern arm only) and round-trips through JSON.
|
||||
* Malformed persisted blobs — wrong shape, corrupt `discover` payload —
|
||||
* reject with a typed `SdkError` before anything reaches the wire.
|
||||
*/
|
||||
import type { DiscoverResult, JSONRPCMessage, Transport } from '@modelcontextprotocol/core-internal';
|
||||
import { isJSONRPCRequest, SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { Client } from '../../src/client/client';
|
||||
import type { PriorDiscovery } from '../../src/client/probeClassifier';
|
||||
|
||||
const MODERN = '2026-07-28';
|
||||
|
||||
|
|
@ -40,31 +45,33 @@ class ScriptedTransport implements Transport {
|
|||
}
|
||||
}
|
||||
|
||||
const prior = (supportedVersions: string[]): DiscoverResult => ({
|
||||
const discoverResult = (supportedVersions: string[]): DiscoverResult => ({
|
||||
supportedVersions,
|
||||
capabilities: { tools: { listChanged: true } },
|
||||
serverInfo: { name: 'persisted-server', version: '1.0.0' },
|
||||
instructions: 'persisted instructions'
|
||||
});
|
||||
|
||||
describe('connect({ prior }) — modern overlap: zero round trips', () => {
|
||||
const modernPrior = (supportedVersions: string[]): PriorDiscovery => ({ kind: 'modern', discover: discoverResult(supportedVersions) });
|
||||
|
||||
describe('connect({ prior }) — modern verdict with overlap: zero round trips', () => {
|
||||
test('nothing reaches the wire during connect; era state is the post-probe state', async () => {
|
||||
const transport = new ScriptedTransport();
|
||||
const client = new Client({ name: 'worker', version: '0' });
|
||||
|
||||
await client.connect(transport, { prior: prior([MODERN]) });
|
||||
await client.connect(transport, { prior: modernPrior([MODERN]) });
|
||||
|
||||
// ZERO requests sent during connect.
|
||||
expect(transport.sent).toHaveLength(0);
|
||||
// The transport's protocol-version slot is stamped exactly once.
|
||||
expect(transport.setProtocolVersionCalls).toEqual([MODERN]);
|
||||
// Adopted directly from prior.
|
||||
// Adopted directly from the verdict's discover payload.
|
||||
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
|
||||
expect(client.getProtocolEra()).toBe('modern');
|
||||
expect(client.getServerCapabilities()).toEqual({ tools: { listChanged: true } });
|
||||
expect(client.getServerVersion()).toEqual({ name: 'persisted-server', version: '1.0.0' });
|
||||
expect(client.getInstructions()).toBe('persisted instructions');
|
||||
expect(client.getDiscoverResult()).toEqual(prior([MODERN]));
|
||||
expect(client.getDiscoverResult()).toEqual(discoverResult([MODERN]));
|
||||
|
||||
await client.close();
|
||||
});
|
||||
|
|
@ -81,7 +88,7 @@ describe('connect({ prior }) — modern overlap: zero round trips', () => {
|
|||
}
|
||||
});
|
||||
const client = new Client({ name: 'worker', version: '0' });
|
||||
await client.connect(transport, { prior: prior([MODERN]) });
|
||||
await client.connect(transport, { prior: modernPrior([MODERN]) });
|
||||
|
||||
// First wire traffic is the tools/call itself.
|
||||
expect(transport.sent).toHaveLength(0);
|
||||
|
|
@ -97,21 +104,22 @@ describe('connect({ prior }) — modern overlap: zero round trips', () => {
|
|||
test('prior bypasses versionNegotiation resolution (no probe even with mode: auto)', async () => {
|
||||
const transport = new ScriptedTransport();
|
||||
const client = new Client({ name: 'worker', version: '0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
await client.connect(transport, { prior: prior([MODERN]) });
|
||||
await client.connect(transport, { prior: modernPrior([MODERN]) });
|
||||
expect(transport.sent).toHaveLength(0);
|
||||
await client.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('connect({ prior }) — no modern overlap: throws (no legacy fallback)', () => {
|
||||
test('legacy-only prior → SdkError(EraNegotiationFailed) steering to mode: auto', async () => {
|
||||
test('legacy-only discover payload → SdkError(EraNegotiationFailed) steering to the legacy verdict and mode: auto', async () => {
|
||||
const transport = new ScriptedTransport();
|
||||
const client = new Client({ name: 'worker', version: '0' });
|
||||
await expect(client.connect(transport, { prior: prior(['2025-06-18']) })).rejects.toSatisfy(
|
||||
await expect(client.connect(transport, { prior: modernPrior(['2025-06-18']) })).rejects.toSatisfy(
|
||||
error =>
|
||||
error instanceof SdkError &&
|
||||
error.code === SdkErrorCode.EraNegotiationFailed &&
|
||||
/2026-07-28\+ mutual/.test(error.message) &&
|
||||
/kind: 'legacy'/.test(error.message) &&
|
||||
/mode: 'auto'/.test(error.message)
|
||||
);
|
||||
// Nothing reached the transport (the throw is before super.connect()).
|
||||
|
|
@ -122,7 +130,7 @@ describe('connect({ prior }) — no modern overlap: throws (no legacy fallback)'
|
|||
test('disjoint modern lists → SdkError(EraNegotiationFailed)', async () => {
|
||||
const transport = new ScriptedTransport();
|
||||
const client = new Client({ name: 'worker', version: '0' });
|
||||
await expect(client.connect(transport, { prior: prior(['2099-01-01']) })).rejects.toSatisfy(
|
||||
await expect(client.connect(transport, { prior: modernPrior(['2099-01-01']) })).rejects.toSatisfy(
|
||||
error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed
|
||||
);
|
||||
expect(transport.sent).toHaveLength(0);
|
||||
|
|
@ -130,7 +138,7 @@ describe('connect({ prior }) — no modern overlap: throws (no legacy fallback)'
|
|||
});
|
||||
|
||||
describe('getDiscoverResult() round-trip', () => {
|
||||
test("'auto'-mode probe populates it; JSON.stringify/parse round-trips into connect({ prior })", async () => {
|
||||
test("'auto'-mode probe populates it; JSON round-trips into connect({ prior: { kind: 'modern', discover } })", async () => {
|
||||
// Bootstrap: a real probe against a scripted modern server.
|
||||
const bootstrapTransport = new ScriptedTransport((message, t) => {
|
||||
if (!isJSONRPCRequest(message)) return;
|
||||
|
|
@ -159,10 +167,10 @@ describe('getDiscoverResult() round-trip', () => {
|
|||
const persisted = JSON.stringify(probed);
|
||||
const revived = JSON.parse(persisted) as DiscoverResult;
|
||||
|
||||
// Worker: zero-round-trip connect from the revived blob.
|
||||
// Worker: zero-round-trip connect from the revived blob, wrapped as a verdict.
|
||||
const workerTransport = new ScriptedTransport();
|
||||
const worker = new Client({ name: 'worker', version: '0' });
|
||||
await worker.connect(workerTransport, { prior: revived });
|
||||
await worker.connect(workerTransport, { prior: { kind: 'modern', discover: revived } });
|
||||
expect(workerTransport.sent).toHaveLength(0);
|
||||
expect(worker.getServerVersion()).toEqual({ name: 'probed-server', version: '2.0.0' });
|
||||
expect(worker.getDiscoverResult()).toEqual(revived);
|
||||
|
|
@ -188,7 +196,7 @@ describe('getDiscoverResult() round-trip', () => {
|
|||
}
|
||||
});
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
await client.connect(transport, { prior: prior([MODERN]) });
|
||||
await client.connect(transport, { prior: modernPrior([MODERN]) });
|
||||
expect(client.getDiscoverResult()?.serverInfo.name).toBe('persisted-server');
|
||||
const fresh = await client.discover();
|
||||
expect(fresh.serverInfo.name).toBe('rediscovered');
|
||||
|
|
@ -196,3 +204,148 @@ describe('getDiscoverResult() round-trip', () => {
|
|||
await client.close();
|
||||
});
|
||||
});
|
||||
|
||||
/** Scripted legacy server: answers `initialize` (echoing the offered version), nothing else. */
|
||||
const legacyServerScript = (message: JSONRPCMessage, t: ScriptedTransport): void => {
|
||||
if (!isJSONRPCRequest(message)) return;
|
||||
if (message.method === 'initialize') {
|
||||
t.reply({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
result: {
|
||||
protocolVersion: (message.params as { protocolVersion: string }).protocolVersion,
|
||||
capabilities: { prompts: {} },
|
||||
serverInfo: { name: 'legacy-server', version: '1.0.0' }
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const requestMethods = (t: ScriptedTransport): string[] => t.sent.filter(isJSONRPCRequest).map(m => m.method);
|
||||
|
||||
describe("connect({ prior: { kind: 'legacy' } }) — known-legacy verdict", () => {
|
||||
test('no server/discover reaches the wire, straight initialize (even under mode: auto)', async () => {
|
||||
const transport = new ScriptedTransport(legacyServerScript);
|
||||
const client = new Client({ name: 'worker', version: '0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
|
||||
await client.connect(transport, { prior: { kind: 'legacy' } });
|
||||
|
||||
// Transport-level message capture: NO probe, the initialize handshake only.
|
||||
expect(requestMethods(transport)).toEqual(['initialize']);
|
||||
expect(transport.sent.map(m => ('method' in m ? m.method : ''))).not.toContain('server/discover');
|
||||
expect(client.getProtocolEra()).toBe('legacy');
|
||||
expect(client.getServerVersion()).toEqual({ name: 'legacy-server', version: '1.0.0' });
|
||||
expect(client.getServerCapabilities()).toEqual({ prompts: {} });
|
||||
// No DiscoverResult on this path.
|
||||
expect(client.getDiscoverResult()).toBeUndefined();
|
||||
// The handshake stamped the transport with the negotiated legacy version.
|
||||
expect(transport.setProtocolVersionCalls).toEqual([client.getNegotiatedProtocolVersion()]);
|
||||
|
||||
await client.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('connect({ prior }) — malformed persisted blobs (runtime hardening)', () => {
|
||||
test('prior: null (a persisted slot revived from JSON) is treated as absent', async () => {
|
||||
const transport = new ScriptedTransport(legacyServerScript);
|
||||
const client = new Client({ name: 'worker', version: '0' });
|
||||
|
||||
await client.connect(transport, { prior: JSON.parse('null') as PriorDiscovery });
|
||||
|
||||
expect(requestMethods(transport)).toEqual(['initialize']);
|
||||
expect(client.getProtocolEra()).toBe('legacy');
|
||||
|
||||
await client.close();
|
||||
});
|
||||
|
||||
test('object without a kind (e.g. a raw DiscoverResult) → typed SdkError, nothing sent', async () => {
|
||||
const transport = new ScriptedTransport();
|
||||
const client = new Client({ name: 'worker', version: '0' });
|
||||
|
||||
await expect(client.connect(transport, { prior: discoverResult([MODERN]) as unknown as PriorDiscovery })).rejects.toSatisfy(
|
||||
error =>
|
||||
error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed && /unrecognized prior/.test(error.message)
|
||||
);
|
||||
expect(transport.sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("DiscoverResult-shaped blob with a stray kind: 'legacy' → typed SdkError, never era-chooses", async () => {
|
||||
const transport = new ScriptedTransport(legacyServerScript);
|
||||
const client = new Client({ name: 'worker', version: '0' });
|
||||
|
||||
// Looks like a legacy verdict AND a DiscoverResult at once: corrupt.
|
||||
// Must fail typed — running initialize against this (modern) server's
|
||||
// advertisement would be a silent wrong-era outcome.
|
||||
const decoy = { ...discoverResult([MODERN]), kind: 'legacy' } as unknown as PriorDiscovery;
|
||||
await expect(client.connect(transport, { prior: decoy })).rejects.toSatisfy(
|
||||
error =>
|
||||
error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed && /unrecognized prior/.test(error.message)
|
||||
);
|
||||
expect(transport.sent).toHaveLength(0);
|
||||
expect(client.getProtocolEra()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('modern verdict that lost its discover payload → typed SdkError, nothing sent', async () => {
|
||||
const transport = new ScriptedTransport();
|
||||
const client = new Client({ name: 'worker', version: '0' });
|
||||
|
||||
await expect(client.connect(transport, { prior: { kind: 'modern' } as PriorDiscovery })).rejects.toSatisfy(
|
||||
error =>
|
||||
error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed && /unrecognized prior/.test(error.message)
|
||||
);
|
||||
expect(transport.sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('unrecognized kind (e.g. a case typo) → typed SdkError, not a TypeError', async () => {
|
||||
const transport = new ScriptedTransport();
|
||||
const client = new Client({ name: 'worker', version: '0' });
|
||||
|
||||
await expect(client.connect(transport, { prior: { kind: 'Legacy' } as unknown as PriorDiscovery })).rejects.toSatisfy(
|
||||
error =>
|
||||
error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed && /unrecognized prior/.test(error.message)
|
||||
);
|
||||
expect(transport.sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('modern verdict whose discover.supportedVersions is not an array → typed SdkError, not a TypeError', async () => {
|
||||
const transport = new ScriptedTransport();
|
||||
const client = new Client({ name: 'worker', version: '0' });
|
||||
|
||||
await expect(
|
||||
client.connect(transport, { prior: JSON.parse('{"kind":"modern","discover":{"supportedVersions":null}}') as PriorDiscovery })
|
||||
).rejects.toSatisfy(
|
||||
error =>
|
||||
error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed && /unrecognized prior/.test(error.message)
|
||||
);
|
||||
expect(transport.sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('partially-corrupt modern verdict (valid kind, corrupt discover payload) → typed SdkError before any state change', async () => {
|
||||
const transport = new ScriptedTransport();
|
||||
const client = new Client({ name: 'worker', version: '0' });
|
||||
|
||||
// Valid outer discriminant, garbage inside: must fail the schema at
|
||||
// the seam, not deeper in the adopt path.
|
||||
const blob = '{"kind":"modern","discover":{"supportedVersions":["2026-07-28"],"capabilities":"garbage"}}';
|
||||
await expect(client.connect(transport, { prior: JSON.parse(blob) as PriorDiscovery })).rejects.toSatisfy(
|
||||
error =>
|
||||
error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed && /unrecognized prior/.test(error.message)
|
||||
);
|
||||
expect(transport.sent).toHaveLength(0);
|
||||
// The throw happened before any connection state changed.
|
||||
expect(client.getProtocolEra()).toBeUndefined();
|
||||
expect(client.getDiscoverResult()).toBeUndefined();
|
||||
expect(client.getServerCapabilities()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('corrupt primitive blob → typed SdkError, not a TypeError', async () => {
|
||||
const transport = new ScriptedTransport();
|
||||
const client = new Client({ name: 'worker', version: '0' });
|
||||
|
||||
await expect(client.connect(transport, { prior: JSON.parse('0') as PriorDiscovery })).rejects.toSatisfy(
|
||||
error =>
|
||||
error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed && /unrecognized prior/.test(error.message)
|
||||
);
|
||||
expect(transport.sent).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -175,10 +175,10 @@ export const REQUIREMENTS: Record<string, Requirement> = {
|
|||
'typescript:client:connect:prior-zero-roundtrip': {
|
||||
source: 'sdk',
|
||||
behavior:
|
||||
'connect(transport, { prior: DiscoverResult }) against a 2026-07-28 server is zero-round-trip: a fresh client supplied with a previously-obtained DiscoverResult connects without putting any HTTP exchange on the wire, adopts the modern era directly, and callTool round-trips immediately. prior is modern-only — no modern overlap throws SdkError(EraNegotiationFailed) (no legacy fallback).',
|
||||
"connect(transport, { prior: { kind: 'modern', discover } }) against a 2026-07-28 server is zero-round-trip: a fresh client supplied with a previously-obtained DiscoverResult (wrapped as a PriorDiscovery verdict) connects without putting any HTTP exchange on the wire, adopts the modern era directly, and callTool round-trips immediately. The modern verdict requires a modern overlap — none throws SdkError(EraNegotiationFailed) (no legacy fallback).",
|
||||
transports: ['entryModern'],
|
||||
addedInSpecVersion: '2026-07-28',
|
||||
note: 'Runs on the entryModern arm; the wired (negotiating) client is the bootstrap that obtains the DiscoverResult, then a fresh worker client connects to the same harness-hosted endpoint via wired.url + a fresh StreamableHTTPClientTransport over wired.fetch with { prior }. The zero-round-trip clause is asserted on the arm-recorded httpLog length.'
|
||||
note: "Runs on the entryModern arm; the wired (negotiating) client is the bootstrap that obtains the DiscoverResult, then a fresh worker client connects to the same harness-hosted endpoint via wired.url + a fresh StreamableHTTPClientTransport over wired.fetch with { prior: { kind: 'modern', discover } }. The zero-round-trip clause is asserted on the arm-recorded httpLog length."
|
||||
},
|
||||
'typescript:client:raw-result-type-first': {
|
||||
source: 'sdk',
|
||||
|
|
|
|||
|
|
@ -92,14 +92,16 @@ verifies('typescript:client:connect:prior-zero-roundtrip', async ({ transport }:
|
|||
// the modern revision) populates getDiscoverResult().
|
||||
const bootstrap = new Client({ name: 'bootstrap', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
await using wired = await wire(transport, greetFactory, bootstrap);
|
||||
const prior = bootstrap.getDiscoverResult();
|
||||
expect(prior).toBeDefined();
|
||||
expect(prior!.supportedVersions).toContain(MODERN);
|
||||
const discover = bootstrap.getDiscoverResult();
|
||||
expect(discover).toBeDefined();
|
||||
expect(discover!.supportedVersions).toContain(MODERN);
|
||||
|
||||
// Fresh worker → SAME hosted server, connect({ prior }): zero round trips.
|
||||
const before = wired.httpLog!.length;
|
||||
const worker = new Client({ name: 'worker', version: '1.0.0' });
|
||||
await worker.connect(new StreamableHTTPClientTransport(wired.url!, { fetch: wired.fetch }), { prior });
|
||||
await worker.connect(new StreamableHTTPClientTransport(wired.url!, { fetch: wired.fetch }), {
|
||||
prior: { kind: 'modern', discover: discover! }
|
||||
});
|
||||
try {
|
||||
// No HTTP exchange was added by the worker's connect().
|
||||
expect(wired.httpLog!.length).toBe(before);
|
||||
|
|
|
|||
Loading…
Reference in a new issue