feat(client): accept a cached era verdict on ConnectOptions.prior (#2511)
This commit is contained in:
parent
1480241e2a
commit
f60dff0674
4 changed files with 523 additions and 86 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# gateway
|
||||
|
||||
`connect({ prior: DiscoverResult })` — zero-round-trip connect for gateways and distributed clients (protocol revision 2026-07-28).
|
||||
`connect({ prior: { kind: 'modern', discover } })` — zero-round-trip connect for gateways and distributed clients (protocol revision 2026-07-28).
|
||||
|
||||
```bash
|
||||
pnpm --filter @mcp-examples/gateway server -- --http --port 3000
|
||||
|
|
@ -21,11 +21,11 @@ await bootstrap.close();
|
|||
|
||||
// 2. Every worker: zero-round-trip connect from the persisted blob.
|
||||
const worker = new Client({ name: 'worker', version: '1.0.0' });
|
||||
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
|
||||
await worker.connect(new StreamableHTTPClientTransport(url), { prior: { kind: 'modern', discover: JSON.parse(persisted) } });
|
||||
await worker.callTool({ name: 'echo', arguments: { text: 'hi' } }); // first wire traffic
|
||||
```
|
||||
|
||||
`getDiscoverResult()` is populated by the `'auto'`/pinned probe path, by `client.discover()`, and by `connect({ prior })` itself. The value round-trips through `JSON.stringify`/`JSON.parse`.
|
||||
`getDiscoverResult()` is populated by the `'auto'`/pinned probe path, by `client.discover()`, and by a modern-verdict `connect({ prior })` (a legacy verdict leaves it `undefined`). The value round-trips through `JSON.stringify`/`JSON.parse`.
|
||||
|
||||
## What this story asserts
|
||||
|
||||
|
|
@ -47,4 +47,4 @@ The server exposes a `request_count` tool returning how many MCP requests reache
|
|||
Only reuse a persisted `DiscoverResult` across workers that present the **same authorization context** as the bootstrap client (key the blob on a credential hash). Adopting a wider `prior` does not grant access — the server authorizes every request — but it can mislead
|
||||
client-side capability gating.
|
||||
|
||||
`connect({ prior })` is **modern-only**: no mutual 2026-07-28+ revision → `SdkError(EraNegotiationFailed)`. Use `versionNegotiation: { mode: 'auto' }` for legacy-era fallback.
|
||||
The modern verdict — `prior: { kind: 'modern', discover }` wrapping a persisted `DiscoverResult` — is **modern-only**: no mutual 2026-07-28+ revision → `SdkError(EraNegotiationFailed)`. For a server known to be legacy, pass the negative verdict instead — `prior: { kind: 'legacy' }` skips the probe and goes straight to `initialize`. Freshness is the host's responsibility: date cached legacy verdicts in your own storage and stop supplying them past your policy horizon (a stale one succeeds silently against an upgraded server; with no `prior`, a `mode: 'auto'` client re-probes).
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
* 2. The result is `JSON.stringify`-ed (the "persist" step — in a real gateway
|
||||
* you would write this to Redis, a config map, or a process-local cache).
|
||||
* 3. Three fresh worker clients connect with
|
||||
* `connect(transport, { prior: JSON.parse(persisted) })`: each connect()
|
||||
* sends nothing on the wire, and `callTool` works immediately.
|
||||
* `connect(transport, { prior: { kind: 'modern', discover: JSON.parse(persisted) } })`:
|
||||
* each connect() sends nothing on the wire, and `callTool` works immediately.
|
||||
* 4. The server's `request_count` tool proves it: after three worker connects
|
||||
* the count is unchanged (no extra discover/initialize from the workers).
|
||||
*
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
* `DiscoverResult` across principals.
|
||||
*/
|
||||
import { check, parseExampleArgs } from '@mcp-examples/shared';
|
||||
import type { DiscoverResult } from '@modelcontextprotocol/client';
|
||||
import type { DiscoverResult, PriorDiscovery } from '@modelcontextprotocol/client';
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
async function requestCount(client: Client): Promise<number> {
|
||||
|
|
@ -56,7 +56,7 @@ await bootstrap.close();
|
|||
// round trips each. Every worker presents the same authorization context
|
||||
// as the bootstrap (unauthenticated here), so reuse is safe.
|
||||
// ---------------------------------------------------------------------
|
||||
const prior: DiscoverResult = JSON.parse(persisted) as DiscoverResult;
|
||||
const prior: PriorDiscovery = { kind: 'modern', discover: JSON.parse(persisted) as DiscoverResult };
|
||||
const workers = await Promise.all(
|
||||
['worker-a', 'worker-b', 'worker-c'].map(async name => {
|
||||
const worker = new Client({ name, version: '1.0.0' });
|
||||
|
|
|
|||
|
|
@ -30,6 +30,14 @@ import * as z from 'zod/v4';
|
|||
|
||||
let requests = 0;
|
||||
|
||||
/** Everything the page prints, for the self-verifying asserts at teardown. */
|
||||
const logged: string[] = [];
|
||||
const realLog = console.log;
|
||||
console.log = (...args: unknown[]): void => {
|
||||
logged.push(args.map(String).join(' '));
|
||||
realLog(...args);
|
||||
};
|
||||
|
||||
const handler = createMcpHandler(() => {
|
||||
requests++;
|
||||
const server = new McpServer({ name: 'gateway-target', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } });
|
||||
|
|
@ -65,7 +73,7 @@ const persisted = JSON.stringify(bootstrap.getDiscoverResult());
|
|||
|
||||
// … then every other client connects with zero round trips.
|
||||
const worker = new Client({ name: 'worker', version: '1.0.0' });
|
||||
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
|
||||
await worker.connect(new StreamableHTTPClientTransport(url), { prior: { kind: 'modern', discover: JSON.parse(persisted) } });
|
||||
//#endregion connect_prior
|
||||
|
||||
// ## Probe once at bootstrap
|
||||
|
|
@ -77,9 +85,10 @@ console.log(bootstrap.getDiscoverResult());
|
|||
// ## Persist the advertisement
|
||||
|
||||
//#region persist_advertisement
|
||||
import type { DiscoverResult } from '@modelcontextprotocol/client';
|
||||
import type { DiscoverResult, PriorDiscovery } from '@modelcontextprotocol/client';
|
||||
|
||||
const prior = JSON.parse(persisted) as DiscoverResult;
|
||||
const discover = JSON.parse(persisted) as DiscoverResult;
|
||||
const prior: PriorDiscovery = { kind: 'modern', discover };
|
||||
//#endregion persist_advertisement
|
||||
|
||||
// ## Fan out to workers
|
||||
|
|
@ -111,11 +120,11 @@ await subscription.close();
|
|||
//#region prior_stale
|
||||
import { SdkError, SdkErrorCode } from '@modelcontextprotocol/client';
|
||||
|
||||
const stale: DiscoverResult = { ...prior, supportedVersions: ['2025-06-18'] };
|
||||
const stale: DiscoverResult = { ...discover, supportedVersions: ['2025-06-18'] };
|
||||
|
||||
const late = new Client({ name: 'worker-d', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
try {
|
||||
await late.connect(new StreamableHTTPClientTransport(url), { prior: stale });
|
||||
await late.connect(new StreamableHTTPClientTransport(url), { prior: { kind: 'modern', discover: stale } });
|
||||
} catch (error) {
|
||||
if (!(error instanceof SdkError) || error.code !== SdkErrorCode.EraNegotiationFailed) throw error;
|
||||
console.log(error.code);
|
||||
|
|
@ -126,10 +135,91 @@ try {
|
|||
}
|
||||
//#endregion prior_stale
|
||||
|
||||
// ## Skip the probe for a known-legacy server
|
||||
|
||||
//#region prior_legacy
|
||||
const pinnedLegacy = new Client({ name: 'worker-e', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
await pinnedLegacy.connect(new StreamableHTTPClientTransport(url), {
|
||||
prior: { kind: 'legacy' }
|
||||
});
|
||||
console.log(pinnedLegacy.getProtocolEra());
|
||||
//#endregion prior_legacy
|
||||
|
||||
// ## Caching discovery verdicts
|
||||
|
||||
//#region prior_cache
|
||||
// Host-side verdict cache. A real deployment keeps this in Redis (a key TTL
|
||||
// does the dating: an expired read is no prior) or a database row with a
|
||||
// created_at column; the Map stores the timestamp explicitly.
|
||||
const verdicts = new Map<string, { verdict: PriorDiscovery; storedAt: number }>();
|
||||
const HORIZON_MS = 24 * 60 * 60 * 1000; // fixed-horizon freshness policy
|
||||
const fresh = (entry: { storedAt: number }): boolean => Date.now() - entry.storedAt < HORIZON_MS;
|
||||
|
||||
// Count server/discover probes at the fetch layer (the wire trace).
|
||||
let probes = 0;
|
||||
const tracingFetch: typeof fetch = async (input, init) => {
|
||||
if (typeof init?.body === 'string' && init.body.includes('"server/discover"')) probes++;
|
||||
return fetch(input, init);
|
||||
};
|
||||
|
||||
async function connectCached(key: string): Promise<Client> {
|
||||
const entry = verdicts.get(key);
|
||||
// An entry past the horizon is not supplied — under mode: 'auto' that IS
|
||||
// the re-probe, and the fresh outcome below re-populates the cache.
|
||||
const prior = entry && fresh(entry) ? entry.verdict : undefined;
|
||||
|
||||
const client = new Client({ name: 'cached-worker', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
await client.connect(new StreamableHTTPClientTransport(url, { fetch: tracingFetch }), { prior });
|
||||
|
||||
if (prior === undefined) {
|
||||
// Fresh outcome: getDiscoverResult() is the DiscoverResult on a modern
|
||||
// server; its absence on a connected client means the era is legacy.
|
||||
const discover = client.getDiscoverResult();
|
||||
// Date the entry with the host's own clock — only this timestamp ever
|
||||
// retires a legacy verdict (a stale one succeeds silently forever).
|
||||
verdicts.set(key, {
|
||||
verdict: discover ? { kind: 'modern', discover } : { kind: 'legacy' },
|
||||
storedAt: Date.now()
|
||||
});
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
const first = await connectCached('gateway-target'); // no entry: probes
|
||||
console.log('probes after first connect:', probes);
|
||||
|
||||
const second = await connectCached('gateway-target'); // fresh entry: verdict supplied, no probe
|
||||
console.log('probes after second connect:', probes);
|
||||
|
||||
verdicts.get('gateway-target')!.storedAt -= HORIZON_MS + 1; // the horizon passes
|
||||
const third = await connectCached('gateway-target'); // stale: dropped, re-probed, re-cached
|
||||
console.log('probes after the horizon passes:', probes);
|
||||
//#endregion prior_cache
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness teardown.
|
||||
// Harness teardown + self-verification of the string lines the page quotes
|
||||
// (object dumps stringify uselessly and are not checked here).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
for (const client of [bootstrap, worker, late, ...fleet]) await client.close();
|
||||
for (const client of [bootstrap, worker, late, pinnedLegacy, first, second, third, ...fleet]) await client.close();
|
||||
await handler.close();
|
||||
globalThis.fetch = realFetch;
|
||||
console.log = realLog;
|
||||
|
||||
const mustHaveLogged = [
|
||||
'ERA_NEGOTIATION_FAILED',
|
||||
're-probed: 2026-07-28',
|
||||
'legacy',
|
||||
'probes after first connect: 1',
|
||||
'probes after second connect: 1',
|
||||
'probes after the horizon passes: 2'
|
||||
];
|
||||
for (const line of mustHaveLogged) {
|
||||
if (!logged.includes(line)) throw new Error(`page output mismatch: expected "${line}" in:\n${logged.join('\n')}`);
|
||||
}
|
||||
const recached = verdicts.get('gateway-target');
|
||||
if (recached?.verdict.kind !== 'modern' || !fresh(recached)) {
|
||||
// fresh() proves storedAt was rewritten after the horizon rollback — the
|
||||
// re-probe really re-populated the cache, not just left the old entry.
|
||||
throw new Error('expected the horizon re-probe to re-populate the cache with a fresh modern verdict');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,11 @@ console.log(client.getServerCapabilities());
|
|||
console.log(client.getInstructions());
|
||||
//#endregion connect_introspect
|
||||
|
||||
//#region connect_discoverResult
|
||||
// The default mode ran the legacy initialize handshake — no DiscoverResult.
|
||||
console.log(client.getDiscoverResult());
|
||||
//#endregion connect_discoverResult
|
||||
|
||||
// ## Disconnect cleanly
|
||||
|
||||
//#region connect_close
|
||||
|
|
|
|||
Loading…
Reference in a new issue