Implement MCP 2026-07-28 (#2286)
Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com> Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
e4227d135c
commit
1823aae891
4 changed files with 69131 additions and 9001 deletions
50
examples/gateway/README.md
Normal file
50
examples/gateway/README.md
Normal file
|
|
@ -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.
|
||||
87
examples/gateway/client.ts
Normal file
87
examples/gateway/client.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* 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 { check, parseExampleArgs } from '@mcp-examples/shared';
|
||||
import type { DiscoverResult } from '@modelcontextprotocol/client';
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
async function requestCount(client: Client): Promise<number> {
|
||||
const r = await client.callTool({ name: 'request_count' });
|
||||
return Number((r.content?.[0] as { text: string }).text);
|
||||
}
|
||||
|
||||
const { url } = parseExampleArgs();
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 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(new URL(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(new URL(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();
|
||||
26
examples/gateway/package.json
Normal file
26
examples/gateway/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "@mcp-examples/gateway",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"server": "tsx server.ts",
|
||||
"client": "tsx client.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mcp-examples/shared": "workspace:*",
|
||||
"@modelcontextprotocol/client": "workspace:*",
|
||||
"@modelcontextprotocol/node": "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."
|
||||
}
|
||||
}
|
||||
49
examples/gateway/server.ts
Normal file
49
examples/gateway/server.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* 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 { createServer } from 'node:http';
|
||||
|
||||
import { parseExampleArgs } from '@mcp-examples/shared';
|
||||
import { toNodeHandler } from '@modelcontextprotocol/node';
|
||||
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// HTTP-only — the request_count proof depends on `createMcpHandler`'s
|
||||
// per-request factory; on stdio the factory is per-connection and the 2/3/7
|
||||
// assertions would not hold.
|
||||
const { port } = parseExampleArgs();
|
||||
|
||||
const handler = createMcpHandler(buildServer);
|
||||
createServer(toNodeHandler(handler)).listen(port, () => {
|
||||
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
|
||||
});
|
||||
Loading…
Reference in a new issue