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

@ -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<ClientContext> {
* ```
*/
override async connect(transport: Transport, options?: ConnectOptions): Promise<void> {
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<void> {
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<ClientContext> {
}
/**
* 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<void> {
private async _connectFromPrior(transport: Transport, prior: PriorDiscovery, options?: RequestOptions): Promise<void> {
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<ClientContext> {
/**
* 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;

View file

@ -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;
/**

View file

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

View file

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