diff --git a/.changeset/prior-legacy-verdict.md b/.changeset/prior-legacy-verdict.md new file mode 100644 index 0000000000..0a1b7b0f73 --- /dev/null +++ b/.changeset/prior-legacy-verdict.md @@ -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`. diff --git a/docs/advanced/gateway.md b/docs/advanced/gateway.md index 6da8289e28..f567c69847 100644 --- a/docs/advanced/gateway.md +++ b/docs/advanced/gateway.md @@ -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(); +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 { + 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. diff --git a/docs/clients/caching.md b/docs/clients/caching.md index 6c495d9416..1cb5df5404 100644 --- a/docs/clients/caching.md +++ b/docs/clients/caching.md @@ -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. diff --git a/docs/clients/connect.md b/docs/clients/connect.md index 4fb6b72f33..36fb5964c6 100644 --- a/docs/clients/connect.md +++ b/docs/clients/connect.md @@ -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. diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index c781aea0c2..fdc221d930 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -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` diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 3041bd7567..3cd8b775b3 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -1755,11 +1755,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 diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index b3adcaef01..6b06754ab5 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -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. diff --git a/examples/gateway/README.md b/examples/gateway/README.md index 55e4a9c11a..fd99784182 100644 --- a/examples/gateway/README.md +++ b/examples/gateway/README.md @@ -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). diff --git a/examples/gateway/client.ts b/examples/gateway/client.ts index b7824cb85a..9ab5ee9771 100644 --- a/examples/gateway/client.ts +++ b/examples/gateway/client.ts @@ -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 { @@ -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' }); diff --git a/examples/guides/advanced/gateway.examples.ts b/examples/guides/advanced/gateway.examples.ts index 2375c77e88..993c780750 100644 --- a/examples/guides/advanced/gateway.examples.ts +++ b/examples/guides/advanced/gateway.examples.ts @@ -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(); +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 { + 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'); +} diff --git a/examples/guides/clients/connect.examples.ts b/examples/guides/clients/connect.examples.ts index 91933ec58f..83e1109f0d 100644 --- a/examples/guides/clients/connect.examples.ts +++ b/examples/guides/clients/connect.examples.ts @@ -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 diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 15737f2e2b..9f678cf377 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -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 { * ``` */ override async connect(transport: Transport, options?: ConnectOptions): Promise { - 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 { 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 { } /** - * 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 { + private async _connectFromPrior(transport: Transport, prior: PriorDiscovery, options?: RequestOptions): Promise { + 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 { /** * 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; diff --git a/packages/client/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts index bcc4ff5fca..1b78744964 100644 --- a/packages/client/src/client/probeClassifier.ts +++ b/packages/client/src/client/probeClassifier.ts @@ -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; /** diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 2c9bcaaa9e..cfe9f88389 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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, diff --git a/packages/client/test/client/connectPrior.test.ts b/packages/client/test/client/connectPrior.test.ts index c2b443e476..0060e04dd7 100644 --- a/packages/client/test/client/connectPrior.test.ts +++ b/packages/client/test/client/connectPrior.test.ts @@ -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); + }); +}); diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 4fda9e661f..995d5096b9 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -175,10 +175,10 @@ export const REQUIREMENTS: Record = { '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', diff --git a/test/e2e/scenarios/hosting-entry.test.ts b/test/e2e/scenarios/hosting-entry.test.ts index 4c986e3b75..91187c6890 100644 --- a/test/e2e/scenarios/hosting-entry.test.ts +++ b/test/e2e/scenarios/hosting-entry.test.ts @@ -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);