feat(client): accept a cached era verdict on ConnectOptions.prior (#2511)
This commit is contained in:
parent
1480241e2a
commit
f60dff0674
1 changed files with 523 additions and 86 deletions
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue