Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-connect-prior.md
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.
3 changes: 3 additions & 0 deletions docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1078,6 +1078,9 @@
`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/`.

Check warning on line 1082 in docs/migration.md

View check run for this annotation

Claude / Claude Code Review

New connect({prior})/getDiscoverResult() surface is not mentioned in docs/client.md

The new public client surface (`connect({ prior })`, `getDiscoverResult()`, the exported `ConnectOptions` type) isn't mentioned in `docs/client.md` — its "Protocol version negotiation (2026-07-28 revision)" section still only documents `mode: '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 a `clientGuide.examples.ts` snippet) pointing at the migration-guide paragraph and
Comment on lines +1081 to +1082

Copy link
Copy Markdown

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 exported ConnectOptions type) isn't mentioned in docs/client.md — its "Protocol version negotiation (2026-07-28 revision)" section still only documents mode: '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 a clientGuide.examples.ts snippet) pointing at the migration-guide paragraph and examples/gateway/ would close the gap.

Extended reasoning...

What's missing. This PR adds three pieces of public client API — ConnectOptions.prior, Client.getDiscoverResult(), and discover() now writing the result cache — but the consumer-facing prose for them lands only in docs/migration.md (the paragraph at lines 1081–1082), docs/migration-SKILL.md, the changeset, and the new examples/gateway/ story. docs/client.md, the standing client guide, is untouched: grep for prior, getDiscoverResult, or ConnectOptions in it returns nothing, and no snippet was added to examples/guides/clientGuide.examples.ts.

Why client.md is the natural home. docs/client.md already 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"), and getProtocolEra()/getNegotiatedProtocolVersion() — backed by a snippet sourced from clientGuide.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 Client must re-probe the same server. They open docs/client.md, find the version-negotiation section, read that mode: '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.md gained a row, and examples/gateway/ is a full runnable story with its own README covering the pattern and the security caveat. docs/client.md also 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's DiscoverResult (from a prior 'auto' probe, client.discover(), or a persisted blob — it round-trips through JSON.stringify), pass it as connect(transport, { prior }) for a zero-round-trip connect (2026-07-28+ only; throws SdkError(EraNegotiationFailed) with no modern overlap). See examples/gateway/ and the migration guide." Optionally add a matching Client_connectPrior snippet to examples/guides/clientGuide.examples.ts so the doc stays typecheck-verified like the existing snippets.


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.

Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
50 changes: 50 additions & 0 deletions examples/gateway/README.md
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.
90 changes: 90 additions & 0 deletions examples/gateway/client.ts
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();
});
24 changes: 24 additions & 0 deletions examples/gateway/package.json
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."
}
}
40 changes: 40 additions & 0 deletions examples/gateway/server.ts
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);
Loading
Loading