-
Notifications
You must be signed in to change notification settings - Fork 2k
feat(client): connect({ prior: DiscoverResult }) — zero-round-trip connect for gateway/distributed clients #2350
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<number> { | ||
| 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(); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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." | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 The new public client surface (
connect({ prior }),getDiscoverResult(), the exportedConnectOptionstype) isn't mentioned indocs/client.md— its "Protocol version negotiation (2026-07-28 revision)" section still only documentsmode: 'auto'/{ pin }and the probe cost, so a v2 user reading the standing client guide never learns the zero-round-trip option exists. A short paragraph there (and optionally aclientGuide.examples.tssnippet) pointing at the migration-guide paragraph andexamples/gateway/would close the gap.Extended reasoning...
What's missing. This PR adds three pieces of public client API —
ConnectOptions.prior,Client.getDiscoverResult(), anddiscover()now writing the result cache — but the consumer-facing prose for them lands only indocs/migration.md(the paragraph at lines 1081–1082),docs/migration-SKILL.md, the changeset, and the newexamples/gateway/story.docs/client.md, the standing client guide, is untouched: grep forprior,getDiscoverResult, orConnectOptionsin it returns nothing, and no snippet was added toexamples/guides/clientGuide.examples.ts.Why client.md is the natural home.
docs/client.mdalready has a "Protocol version negotiation (2026-07-28 revision)" section (lines 92–112) that documents exactly this surface area for ongoing consumers:versionNegotiation: { mode: 'auto' },{ pin }, the cost of the probe ("The probe costs one round trip against an old server"), andgetProtocolEra()/getNegotiatedProtocolVersion()— backed by a snippet sourced fromclientGuide.examples.ts.connect({ prior })is precisely the answer to "how do I avoid that probe", so the section that documents the probe's cost is where a reader would expect to find it. Other client features of comparable size (listChanged, elicitation, roots) each get a section + guide snippet in this file.Concrete reader walk-through. A developer who is already on v2 (so they never open the migration guide) builds a gateway and wonders whether each worker
Clientmust re-probe the same server. They opendocs/client.md, find the version-negotiation section, read thatmode: 'auto'"costs one round trip", and conclude per-connect probing is the only behavior — even though this PR ships exactly the feature they need. The migration-SKILL text itself labels the feature "New (no v1 equivalent)", i.e. it isn't really migration material; the migration guide is just where the prose happened to land.Why this is a nit, not a blocker (addressing the dissenting view). One verifier argued the repo checklist is satisfied — and that's largely true: prose documentation was added (migration-guide paragraph, migration-SKILL note, changeset),
examples/README.mdgained a row, andexamples/gateway/is a full runnable story with its own README covering the pattern and the security caveat.docs/client.mdalso already delegates "full failure semantics" to the migration guide at line 111, so nothing in client.md now contradicts the implementation. This finding is therefore a docs-placement/completeness suggestion, not a correctness issue, and should not block the PR.Suggested fix. Add one short paragraph (or a fourth bullet) to the version-negotiation section of
docs/client.md, e.g.: "If you already hold the server'sDiscoverResult(from a prior'auto'probe,client.discover(), or a persisted blob — it round-trips throughJSON.stringify), pass it asconnect(transport, { prior })for a zero-round-trip connect (2026-07-28+ only; throwsSdkError(EraNegotiationFailed)with no modern overlap). Seeexamples/gateway/and the migration guide." Optionally add a matchingClient_connectPriorsnippet toexamples/guides/clientGuide.examples.tsso the doc stays typecheck-verified like the existing snippets.