diff --git a/.changeset/add-connect-prior.md b/.changeset/add-connect-prior.md new file mode 100644 index 0000000000..add9553e50 --- /dev/null +++ b/.changeset/add-connect-prior.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': minor +--- + +Add `connect(transport, { prior: DiscoverResult })` for zero-round-trip reconnect (the gateway / distributed-client pattern). Supplying a previously-obtained `DiscoverResult` skips the `server/discover` probe: on a 2026-era server `connect()` sends nothing on the wire and `callTool()` etc. work immediately. Pair with the new `client.getDiscoverResult()` (populated by the `'auto'`-mode probe, by `client.discover()`, and by `connect({ prior })` itself) — the value round-trips through `JSON.stringify`, so a gateway can probe once, persist the blob, and feed it to every worker. Only reuse a persisted `DiscoverResult` across clients that present the same authorization context as the client that obtained it. diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 1161dde262..37dfa8e64a 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -576,6 +576,9 @@ side: auto-fulfilment is on by default (`ClientOptions.inputRequired`, `maxRound Output-schema validator compilation is now lazy (first `callTool()` against the cached `tools/list` entry) and non-throwing (an uncompilable `outputSchema` is `console.warn`-ed and validation is skipped for that tool only); `listTools()` no longer throws on an uncompilable `outputSchema`. Applies on every era — the legacy-era `listTools()` path is unchanged at the wire level only. +New (no v1 equivalent): `Client.connect(transport, { prior: DiscoverResult })` — zero-round-trip connect (2026-07-28+ only; throws `EraNegotiationFailed` otherwise). Probe once, persist `client.getDiscoverResult()` (`JSON.stringify`), feed to every worker. New exported type: +`ConnectOptions` (extends `RequestOptions` with `prior?: DiscoverResult`). + No code changes required; wire-behavior note: on a 2026-07-28 Streamable HTTP connection, aborting an in-flight client request (caller `signal` / timeout) closes that request's SSE response stream as the spec cancellation signal — `notifications/cancelled` is no longer POSTed there. 2025-era connections and stdio at any era still send `notifications/cancelled`. Custom `Transport` implementations that open one underlying request per outbound message and honor `TransportSendOptions.requestSignal` may declare `readonly hasPerRequestStream = true` to opt into the same routing. diff --git a/docs/migration.md b/docs/migration.md index e61eba558a..3c21bb1671 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1078,6 +1078,9 @@ versionNegotiation: { `maxRetries` governs timeout re-sends only (the spec-mandated `-32022` corrective continuation — select-and-continue with a mutual version — is a separate negotiation step and is never counted against it). Negotiation can also be configured pre-connect on an already-constructed instance via `client.setVersionNegotiation(options)` (equivalent to the constructor option; throws after connecting). +A gateway or worker fleet can skip the probe entirely: probe once, persist `client.getDiscoverResult()` (round-trips through `JSON.stringify`), and pass it to every worker as `client.connect(transport, { prior })` for a **zero-round-trip** connect. `prior` is 2026-07-28+ only — +no modern overlap throws `SdkError(EraNegotiationFailed)`. Only reuse across clients presenting the same authorization context. See `examples/gateway/`. + Once a modern era is negotiated, the client **automatically attaches the per-request `_meta` envelope** (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification — you never set it by hand. Any `_meta` keys you pass in a request are preserved over the auto-attached ones. After connect, `client.getProtocolEra()` returns `'legacy'` or `'modern'` and `client.getNegotiatedProtocolVersion()` the exact revision. diff --git a/examples/README.md b/examples/README.md index d41cd73a2e..b7a879ae63 100644 --- a/examples/README.md +++ b/examples/README.md @@ -36,6 +36,7 @@ Add `-- --legacy` to the client command for the 2025-era handshake. | [`sampling/`](./sampling/README.md) | Tool that requests LLM sampling from the client, both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual | | [`stickynotes/`](./stickynotes/README.md) | "Real app" capstone: tools mutate state, a resource per note, listChanged, elicitation-confirmed clear | stdio + http | dual | | [`caching/`](./caching/README.md) | `cacheHints` stamping on cacheable results (2026-07-28) | stdio + http | modern | +| [`gateway/`](./gateway/README.md) | `connect({ prior })` — probe once, zero-round-trip connect for every worker (gateway pattern) | http | modern | | [`custom-methods/`](./custom-methods/README.md) | Vendor-prefixed methods + custom notifications | stdio + http | dual | | [`schema-validators/`](./schema-validators/README.md) | ArkType, Valibot, Zod, and `outputSchema` | stdio + http | dual | | [`custom-version/`](./custom-version/README.md) | `supportedProtocolVersions` / version negotiation | stdio + http | legacy | diff --git a/examples/gateway/README.md b/examples/gateway/README.md new file mode 100644 index 0000000000..55e4a9c11a --- /dev/null +++ b/examples/gateway/README.md @@ -0,0 +1,50 @@ +# gateway + +`connect({ prior: DiscoverResult })` — zero-round-trip connect for gateways and distributed clients (protocol revision 2026-07-28). + +```bash +pnpm --filter @mcp-examples/gateway server -- --http --port 3000 +pnpm --filter @mcp-examples/gateway client -- --http http://127.0.0.1:3000/ +``` + +The 2026 protocol is **stateless on HTTP**: every request carries the per-request `_meta` envelope (protocol version, client info, client capabilities), so once you know the server's `DiscoverResult` there is nothing left to negotiate. A gateway, proxy, or worker fleet that +fronts the same server should not re-probe per worker — it probes once and every subsequent connect is free. + +## The pattern + +```ts +// 1. Bootstrap: probe once. +const bootstrap = new Client({ name: 'bootstrap', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); +await bootstrap.connect(new StreamableHTTPClientTransport(url)); +const persisted = JSON.stringify(bootstrap.getDiscoverResult()); // → write to Redis / config / process-local cache +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.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`. + +## What this story asserts + +The server exposes a `request_count` tool returning how many MCP requests reached the process (`createMcpHandler` builds one server instance per request). The client asserts: + +- after the bootstrap probe + one `request_count` call, the count is **2**; +- after three worker `connect({ prior })` calls + one `request_count` call, the count is **3** — proving the three connects sent **zero** requests; +- each worker can `callTool` immediately; +- after three `echo` calls + one `request_count` call, the count is **7**. + +## When to use `prior` + +- A gateway/proxy that holds a long-lived connection pool to one server and constructs a fresh `Client` per downstream request. +- A horizontally-scaled host where one worker's probe should seed the fleet (persist the blob to a shared cache). +- Reconnecting after a transient transport drop without re-probing. + +## Security: same-credential reuse only + +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. diff --git a/examples/gateway/client.ts b/examples/gateway/client.ts new file mode 100644 index 0000000000..441bf4d250 --- /dev/null +++ b/examples/gateway/client.ts @@ -0,0 +1,90 @@ +/** + * Gateway / distributed-client pattern: probe once, persist the + * `DiscoverResult`, feed it to every worker for a zero-round-trip connect. + * + * 1. A "bootstrap" client connects with `versionNegotiation: { mode: 'auto' }` + * — one `server/discover` round trip — and reads `getDiscoverResult()`. + * 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. + * 4. The server's `request_count` tool proves it: after three worker connects + * the count is unchanged (no extra discover/initialize from the workers). + * + * **Security:** the persisted advertisement is what the server returned for the + * bootstrap client's credential. Only reuse it across workers that present the + * SAME authorization context — here every client speaks to the same + * unauthenticated endpoint, so the constraint holds trivially. Do not share a + * `DiscoverResult` across principals. + */ +import type { DiscoverResult } from '@modelcontextprotocol/client'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +import { check, httpUrlFromArgs, runClient } from '../harness.js'; + +async function requestCount(client: Client): Promise { + const r = await client.callTool({ name: 'request_count' }); + return Number((r.content?.[0] as { text: string }).text); +} + +runClient('gateway', async () => { + const url = new globalThis.URL(httpUrlFromArgs('http://127.0.0.1:3000/')); + + // --------------------------------------------------------------------- + // Step 1: bootstrap — one server/discover probe. + // --------------------------------------------------------------------- + const bootstrap = new Client({ name: 'gateway-bootstrap', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await bootstrap.connect(new StreamableHTTPClientTransport(url)); + check.equal(bootstrap.getNegotiatedProtocolVersion(), '2026-07-28'); + + const discovered = bootstrap.getDiscoverResult(); + check.ok(discovered, 'bootstrap connect populated getDiscoverResult()'); + check.deepEqual(discovered?.serverInfo, { name: 'gateway-target', version: '1.0.0' }); + + // The probe was the only request so far; the request_count call is the + // second. (createMcpHandler builds one server instance per request.) + check.equal(await requestCount(bootstrap), 2); + + // --------------------------------------------------------------------- + // Step 2: persist. In a real gateway you'd write this to Redis / a config + // map / a process-local cache here. JSON round-trips by design. + // --------------------------------------------------------------------- + const persisted: string = JSON.stringify(discovered); + await bootstrap.close(); + + // --------------------------------------------------------------------- + // Step 3: three fresh workers connect from the persisted blob — zero + // 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 workers = await Promise.all( + ['worker-a', 'worker-b', 'worker-c'].map(async name => { + const worker = new Client({ name, version: '1.0.0' }); + await worker.connect(new StreamableHTTPClientTransport(url), { prior }); + // Adopted directly from prior — no probe, no initialize. + check.equal(worker.getNegotiatedProtocolVersion(), '2026-07-28'); + check.deepEqual(worker.getServerVersion(), { name: 'gateway-target', version: '1.0.0' }); + return worker; + }) + ); + + // --------------------------------------------------------------------- + // Step 4: prove it. Three connect() calls and the count is unchanged + // (still 2 from the bootstrap leg + this request_count call = 3). Had + // each worker probed/initialized, this would read 6. + // --------------------------------------------------------------------- + check.equal(await requestCount(workers[0]!), 3); + + // Each worker can callTool immediately. + for (const [i, worker] of workers.entries()) { + const echoed = await worker.callTool({ name: 'echo', arguments: { text: `hello from ${i}` } }); + check.equal((echoed.content?.[0] as { text: string }).text, `hello from ${i}`); + } + + // 3 (above) + 3 echo calls + this request_count call = 7. + check.equal(await requestCount(workers[0]!), 7); + + for (const worker of workers) await worker.close(); +}); diff --git a/examples/gateway/package.json b/examples/gateway/package.json new file mode 100644 index 0000000000..0f696e72a5 --- /dev/null +++ b/examples/gateway/package.json @@ -0,0 +1,24 @@ +{ + "name": "@mcp-examples/gateway", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "modern", + "//": "connect({ prior }) is zero-round-trip on 2026-07-28 only — the legacy era still needs initialize. HTTP-only because the proof counts per-request factory calls (createMcpHandler builds one server instance per request); on stdio the factory is per-connection." + } +} diff --git a/examples/gateway/server.ts b/examples/gateway/server.ts new file mode 100644 index 0000000000..b069c24399 --- /dev/null +++ b/examples/gateway/server.ts @@ -0,0 +1,40 @@ +/** + * Gateway / distributed-client target server. A plain 2026-era MCP server with + * a couple of tools and a `request_count` instrumentation tool that returns how + * many requests have reached this process — `createMcpHandler` builds one + * server instance per inbound request, so the module-level counter equals the + * number of MCP requests served (server/discover, tools/call, …). The client + * asserts against it to PROVE that `connect({ prior })` sent nothing. + */ +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { runServerFromArgs } from '../harness.js'; + +let requestCount = 0; + +function buildServer(): McpServer { + requestCount++; + const server = new McpServer({ name: 'gateway-target', version: '1.0.0' }); + + server.registerTool('echo', { description: 'Echo the input back', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] + })); + + server.registerTool('uppercase', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text: text.toUpperCase() }] + })); + + // Exposes the process-wide request count so the client can assert exactly + // which round trips happened. The factory increment for THIS call has + // already run by the time the handler executes, so the returned value + // includes the request_count call itself. + server.registerTool('request_count', { description: 'Number of MCP requests this server process has received' }, async () => ({ + content: [{ type: 'text', text: String(requestCount) }] + })); + + return server; +} + +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. +runServerFromArgs(buildServer); diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 53a8b0c2ae..07ec2757cd 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -68,6 +68,7 @@ import { legacyProtocolVersions, ListChangedOptionsBaseSchema, mergeCapabilities, + modernProtocolVersions, parseSchema, Protocol, PROTOCOL_VERSION_META_KEY, @@ -79,7 +80,8 @@ import { SdkError, SdkErrorCode, SUBSCRIPTION_ID_META_KEY, - SubscriptionFilterSchema + SubscriptionFilterSchema, + SUPPORTED_MODERN_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; import type { CacheMode, CacheScope, ResponseCacheStore } from './responseCache.js'; @@ -323,6 +325,22 @@ export type ClientOptions = ProtocolOptions & { defaultCacheTtlMs?: number; }; +/** + * 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. + */ +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. + */ + prior?: DiscoverResult; +}; + /** * {@linkcode RequestOptions} extended with the per-call cache disposition for * the cacheable verbs (`listTools()` / `listPrompts()` / `listResources()` / @@ -488,6 +506,8 @@ export class Client extends Protocol { private _nextListenId = 0; /** The auto-opened subscription backing ClientOptions.listChanged on a modern connection. */ private _autoOpenedSubscription?: McpSubscription; + /** Backing store for {@linkcode getDiscoverResult}. Per-connection. */ + private _discoverResult?: DiscoverResult; /** * Clears every per-connection field in one place. Called at the start of @@ -500,6 +520,7 @@ export class Client extends Protocol { this._serverCapabilities = undefined; this._serverVersion = undefined; this._instructions = undefined; + this._discoverResult = undefined; this._autoOpenedSubscription = undefined; // Settle every live per-listen state machine before clearing the map: // a fresh connect (or close) on a connection whose prior transport @@ -896,7 +917,12 @@ export class Client extends Protocol { * } * ``` */ - override async connect(transport: Transport, options?: RequestOptions): Promise { + override async connect(transport: Transport, options?: ConnectOptions): Promise { + if (options?.prior !== undefined) { + // Zero-round-trip reconnect from a previously-obtained + // DiscoverResult: bypasses versionNegotiation resolution entirely. + return this._connectFromPrior(transport, options.prior); + } const negotiation = resolveVersionNegotiation(this._versionNegotiation, this._supportedProtocolVersionsOption); if (negotiation.kind !== 'legacy') { return this._connectNegotiated(transport, negotiation, options); @@ -1055,6 +1081,7 @@ export class Client extends Protocol { this._serverVersion = result.discover.serverInfo; this._cache.setServerIdentity(this._deriveServerIdentity(transport)); this._instructions = result.discover.instructions; + this._discoverResult = result.discover; // Modern selection: the same connection state the legacy handshake completion sets. this._negotiatedProtocolVersion = result.version; // The single setProtocolVersion call site on this path, mirroring the legacy path after initialize. @@ -1149,6 +1176,46 @@ export class Client extends Protocol { } } + /** + * 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}. + */ + private async _connectFromPrior(transport: Transport, prior: DiscoverResult): Promise { + 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)); + 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." + ); + } + + await super.connect(transport); + + this._discoverResult = prior; + this._serverCapabilities = prior.capabilities; + this._serverVersion = prior.serverInfo; + this._cache.setServerIdentity(this._deriveServerIdentity(transport)); + this._instructions = prior.instructions; + this._negotiatedProtocolVersion = version; + transport.setProtocolVersion?.(version); + + // No auto-opened listen stream on this path (request-only workers). + if (this._listChangedConfig) { + try { + this._setupListChangedHandlers(this._listChangedConfig); + } catch (error) { + this.onerror?.(error instanceof Error ? error : new Error(String(error))); + } + } + } + /** * After initialization has completed, this will be populated with the server's reported capabilities. */ @@ -1206,6 +1273,15 @@ export class Client extends Protocol { return this._instructions; } + /** + * The {@linkcode DiscoverResult} from the last `'auto'`/pinned probe, + * {@linkcode discover} call, or `connect({ prior })`. Persistable via + * `JSON.stringify`; feed to {@linkcode ConnectOptions} `prior`. + */ + getDiscoverResult(): DiscoverResult | undefined { + return this._discoverResult; + } + protected assertCapabilityForMethod(method: RequestMethod | string): void { switch (method as ClientRequest['method']) { // Deprecated as of protocol version 2026-07-28 (SEP-2577); remains @@ -1356,18 +1432,13 @@ export class Client extends Protocol { } /** - * Asks the server to advertise its supported protocol versions, capabilities, - * and implementation info (`server/discover`, protocol revision 2026-07-28). - * - * Servers on the 2026-07-28 revision MUST implement this; the connect-time - * version negotiation issues it automatically. The method exists only on - * the 2026-07-28 era: on a connection negotiated to a 2025-era version it - * is rejected locally with a typed `SdkError` - * (`MethodNotSupportedByProtocolVersion`) before anything reaches the - * transport. + * Send `server/discover` (2026-07-28+) and record the result for + * {@linkcode getDiscoverResult}. */ async discover(options?: RequestOptions): Promise { - return this._requestWithSchema({ method: 'server/discover' }, DiscoverResultSchema, options); + const result = await this._requestWithSchema({ method: 'server/discover' }, DiscoverResultSchema, options); + this._discoverResult = result; + return result; } /** Requests argument autocompletion suggestions from the server for a prompt or resource. */ diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 05dfa807a6..6341605980 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -52,7 +52,7 @@ export { PrivateKeyJwtProvider, StaticPrivateKeyJwtProvider } from './client/authExtensions.js'; -export type { CacheableRequestOptions, CallToolRequestOptions, ClientOptions, McpSubscription } from './client/client.js'; +export type { CacheableRequestOptions, CallToolRequestOptions, ClientOptions, ConnectOptions, McpSubscription } from './client/client.js'; export { Client } from './client/client.js'; export { getSupportedElicitationModes } from './client/client.js'; export type { DiscoverAndRequestJwtAuthGrantOptions, JwtAuthGrantResult, RequestJwtAuthGrantOptions } from './client/crossAppAccess.js'; diff --git a/packages/client/test/client/connectPrior.test.ts b/packages/client/test/client/connectPrior.test.ts new file mode 100644 index 0000000000..6eaec28985 --- /dev/null +++ b/packages/client/test/client/connectPrior.test.ts @@ -0,0 +1,198 @@ +/** + * `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. + */ +import type { DiscoverResult, JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; +import { isJSONRPCRequest, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; +import { describe, expect, test } from 'vitest'; + +import { Client } from '../../src/client/client.js'; + +const MODERN = '2026-07-28'; + +class ScriptedTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + sessionId?: string; + sent: JSONRPCMessage[] = []; + setProtocolVersionCalls: string[] = []; + + constructor(private readonly script: (message: JSONRPCMessage, transport: ScriptedTransport) => void = () => {}) {} + + async start(): Promise {} + async close(): Promise { + this.onclose?.(); + } + async send(message: JSONRPCMessage): Promise { + this.sent.push(message); + queueMicrotask(() => this.script(message, this)); + } + setProtocolVersion(version: string): void { + this.setProtocolVersionCalls.push(version); + } + reply(message: JSONRPCMessage): void { + this.onmessage?.(message); + } +} + +const prior = (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', () => { + 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]) }); + + // 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. + 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])); + + await client.close(); + }); + + test('callTool works immediately after a zero-round-trip connect', async () => { + const transport = new ScriptedTransport((message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'tools/call') { + t.reply({ + jsonrpc: '2.0', + id: message.id, + result: { resultType: 'complete', content: [{ type: 'text', text: 'ok' }] } + }); + } + }); + const client = new Client({ name: 'worker', version: '0' }); + await client.connect(transport, { prior: prior([MODERN]) }); + + // First wire traffic is the tools/call itself. + expect(transport.sent).toHaveLength(0); + const result = await client.callTool({ name: 'echo' }); + expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' }); + const reqs = transport.sent.filter(isJSONRPCRequest); + expect(reqs).toHaveLength(1); + expect(reqs[0]!.method).toBe('tools/call'); + + await client.close(); + }); + + 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]) }); + 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 () => { + const transport = new ScriptedTransport(); + const client = new Client({ name: 'worker', version: '0' }); + await expect(client.connect(transport, { prior: prior(['2025-06-18']) })).rejects.toSatisfy( + error => + error instanceof SdkError && + error.code === SdkErrorCode.EraNegotiationFailed && + /2026-07-28\+ mutual/.test(error.message) && + /mode: 'auto'/.test(error.message) + ); + // Nothing reached the transport (the throw is before super.connect()). + expect(transport.sent).toHaveLength(0); + expect(client.getDiscoverResult()).toBeUndefined(); + }); + + 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( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + expect(transport.sent).toHaveLength(0); + }); +}); + +describe('getDiscoverResult() round-trip', () => { + test("'auto'-mode probe populates it; JSON.stringify/parse round-trips into connect({ prior })", async () => { + // Bootstrap: a real probe against a scripted modern server. + const bootstrapTransport = new ScriptedTransport((message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + t.reply({ + jsonrpc: '2.0', + id: message.id, + result: { + supportedVersions: [MODERN], + capabilities: { tools: {} }, + serverInfo: { name: 'probed-server', version: '2.0.0' } + } + }); + } + }); + const bootstrap = new Client({ name: 'bootstrap', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + await bootstrap.connect(bootstrapTransport); + const probed = bootstrap.getDiscoverResult(); + expect(probed?.serverInfo).toEqual({ name: 'probed-server', version: '2.0.0' }); + expect(probed?.supportedVersions).toEqual([MODERN]); + await bootstrap.close(); + // close() clears per-connection state. + expect(bootstrap.getDiscoverResult()).toBeUndefined(); + + // Persist + revive (the gateway pattern's "write to Redis/config" step). + const persisted = JSON.stringify(probed); + const revived = JSON.parse(persisted) as DiscoverResult; + + // Worker: zero-round-trip connect from the revived blob. + const workerTransport = new ScriptedTransport(); + const worker = new Client({ name: 'worker', version: '0' }); + await worker.connect(workerTransport, { prior: revived }); + expect(workerTransport.sent).toHaveLength(0); + expect(worker.getServerVersion()).toEqual({ name: 'probed-server', version: '2.0.0' }); + expect(worker.getDiscoverResult()).toEqual(revived); + await worker.close(); + }); + + test('discover() populates it on an already-connected modern client', async () => { + const transport = new ScriptedTransport((message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + t.reply({ + jsonrpc: '2.0', + id: message.id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'public', + supportedVersions: [MODERN], + capabilities: { tools: {} }, + serverInfo: { name: 'rediscovered', version: '3.0.0' } + } + }); + } + }); + const client = new Client({ name: 'c', version: '0' }); + await client.connect(transport, { prior: prior([MODERN]) }); + expect(client.getDiscoverResult()?.serverInfo.name).toBe('persisted-server'); + const fresh = await client.discover(); + expect(fresh.serverInfo.name).toBe('rediscovered'); + expect(client.getDiscoverResult()?.serverInfo.name).toBe('rediscovered'); + await client.close(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36b23be734..ce65d98b12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -460,6 +460,22 @@ importers: specifier: catalog:devTools version: 4.21.0 + examples/gateway: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + examples/hono: dependencies: '@hono/node-server': diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 02291aa6e7..265d90210f 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -172,6 +172,14 @@ export const REQUIREMENTS: Record = { behavior: 'The receiver silently ignores a cancellation notification referencing an unknown or already-completed request id; no error response is sent and no exception is raised.' }, + 'typescript:client:connect:prior-zero-roundtrip': { + source: 'sdk', + behavior: + 'connect(transport, { prior: DiscoverResult }) against a 2026-07-28 server is zero-round-trip: a fresh client supplied with a previously-obtained DiscoverResult connects without putting any HTTP exchange on the wire, adopts the modern era directly, and callTool round-trips immediately. prior is modern-only — no modern overlap throws SdkError(EraNegotiationFailed) (no legacy fallback).', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: 'Runs on the entryModern arm; the wired (negotiating) client is the bootstrap that obtains the DiscoverResult, then a fresh worker client connects to the same harness-hosted endpoint via wired.url + a fresh StreamableHTTPClientTransport over wired.fetch with { prior }. The zero-round-trip clause is asserted on the arm-recorded httpLog length.' + }, 'typescript:client:raw-result-type-first': { source: 'sdk', behavior: diff --git a/test/e2e/scenarios/hosting-entry.test.ts b/test/e2e/scenarios/hosting-entry.test.ts index 2ca08fd388..d979a810dd 100644 --- a/test/e2e/scenarios/hosting-entry.test.ts +++ b/test/e2e/scenarios/hosting-entry.test.ts @@ -87,6 +87,33 @@ verifies('typescript:hosting:entry:pin-negotiation', async ({ transport }: TestA expect(requestBodies().some(body => body.includes('"initialize"'))).toBe(false); }); +verifies('typescript:client:connect:prior-zero-roundtrip', async ({ transport }: TestArgs) => { + // Bootstrap: the wired negotiating client (the entryModern arm pins it to + // the modern revision) populates getDiscoverResult(). + const bootstrap = new Client({ name: 'bootstrap', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await using wired = await wire(transport, greetFactory, bootstrap); + const prior = bootstrap.getDiscoverResult(); + expect(prior).toBeDefined(); + expect(prior!.supportedVersions).toContain(MODERN); + + // Fresh worker → SAME hosted server, connect({ prior }): zero round trips. + const before = wired.httpLog!.length; + const worker = new Client({ name: 'worker', version: '1.0.0' }); + await worker.connect(new StreamableHTTPClientTransport(wired.url!, { fetch: wired.fetch }), { prior }); + try { + // No HTTP exchange was added by the worker's connect(). + expect(wired.httpLog!.length).toBe(before); + expect(worker.getNegotiatedProtocolVersion()).toBe(MODERN); + // First wire traffic from the worker is the tools/call itself. + const result = await worker.callTool({ name: 'greet', arguments: { name: 'prior' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hello prior (modern)' }]); + expect(wired.httpLog!.length).toBe(before + 1); + expect(wired.httpLog![before]!.requestBody).toContain('"tools/call"'); + } finally { + await worker.close().catch(() => {}); + } +}); + verifies('typescript:hosting:entry:strict-rejects-legacy', async ({ transport }: TestArgs) => { // legacy: 'reject' → modern-only strict (the entryModern arm hosting): no silent 2025 serving. const modernClient = new Client({ name: 'strict-modern-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } });