feat(client): accept a cached era verdict on ConnectOptions.prior (#2511)

This commit is contained in:
Felix Weinberger 2026-07-17 17:25:02 +01:00 • committed by GitHub
commit f60dff0674
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 523 additions and 86 deletions

View file

@ -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).

View file

@ -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' });

View file

@ -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');
}

View file

@ -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