typescript-sdk/examples/gateway/client.ts

89 lines
4.7 KiB
TypeScript

/**
* 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: { kind: 'modern', discover: 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, PriorDiscovery } 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()');
// Server identity: getServerVersion() resolves it from the discover result's
// `_meta['io.modelcontextprotocol/serverInfo']` (spec PR #3002).
check.deepEqual(bootstrap.getServerVersion(), { 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: PriorDiscovery = { kind: 'modern', discover: 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();