Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/prior-legacy-verdict.md
Original file line number Diff line number Diff line change
@@ -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`.
115 changes: 100 additions & 15 deletions docs/advanced/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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 });
Expand All @@ -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);
Expand All @@ -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

Comment thread
claude[bot] marked this conversation as resolved.
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.
Comment thread
claude[bot] marked this conversation as resolved.
4 changes: 4 additions & 0 deletions docs/clients/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 14 additions & 1 deletion docs/clients/connect.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
7 changes: 6 additions & 1 deletion docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
13 changes: 10 additions & 3 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading