typescript-sdk/examples/guides/protocolVersions.examples.ts

138 lines
5.5 KiB
TypeScript

/**
* Companion example for `docs/protocol-versions.md`.
*
* Every `ts` fence on that page is synced from a `//#region` in this file
* (`pnpm sync:snippets --check`). The file also runs: the harness routes
* `globalThis.fetch` for the page's two URLs in-process — `localhost:3000` is
* the `createMcpHandler` entry built below (serves both eras) and
* `localhost:4000` is a 2025-only server — so the probe, the fallback, and the
* pin rejection the page quotes all execute for real without binding a port.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
* npx tsx guides/protocolVersions.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
// ## Serve both eras from one entry point
// (Defined first so the harness can route to it; the page introduces it last.)
//#region createMcpHandler_bothEras
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const handler = createMcpHandler(({ era }) => {
const server = new McpServer({ name: 'forecast', version: '1.0.0' });
server.registerTool(
'forecast',
{
description: 'Forecast for a city',
inputSchema: z.object({ city: z.string() })
},
async ({ city }) => ({ content: [{ type: 'text', text: `${city}: sunny (${era} era)` }] })
);
return server;
});
//#endregion createMcpHandler_bothEras
// ---------------------------------------------------------------------------
// Harness (not shown on the page). `localhost:4000` is a 2025-only server:
// `legacyStatelessFallback` is the same stateless 2025 serving that
// `createMcpHandler` uses for its own legacy traffic, exposed standalone. It
// never recognizes `server/discover`, so an `'auto'` probe against it falls
// back and a pin against it rejects.
// ---------------------------------------------------------------------------
const { legacyStatelessFallback } = await import('@modelcontextprotocol/server');
const legacyOnly = legacyStatelessFallback(() => new McpServer({ name: 'forecast-2025', version: '1.0.0' }));
const realFetch = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const request = new Request(input, init);
const { host } = new URL(request.url);
if (host === 'localhost:3000') return handler.fetch(request);
if (host === 'localhost:4000') return legacyOnly(request);
return realFetch(input, init);
}) as typeof fetch;
// ## Negotiate the era from the client — `'auto'` against the both-era endpoint.
//#region versionNegotiation_auto
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp')));
console.log(client.getProtocolEra());
//#endregion versionNegotiation_auto
// The same options against the 2025-only endpoint: the probe finds nothing
// modern and `connect()` falls back to `initialize` on the same connection.
//#region versionNegotiation_fallback
const fallback = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await fallback.connect(new StreamableHTTPClientTransport(new URL('http://localhost:4000/mcp')));
console.log(fallback.getProtocolEra());
//#endregion versionNegotiation_fallback
// ## Pin an era — a pin never falls back; against a 2025-only server it rejects.
//#region versionNegotiation_pin
import { SdkError } from '@modelcontextprotocol/client';
const pinned = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: '2026-07-28' } } });
try {
await pinned.connect(new StreamableHTTPClientTransport(new URL('http://localhost:4000/mcp')));
} catch (error) {
if (error instanceof SdkError) console.log(`${error.code}: ${error.message}`);
}
//#endregion versionNegotiation_pin
// ## Serve both eras from one entry point — the era reaches the factory.
//#region createMcpHandler_callBothEras
const defaultClient = new Client({ name: 'my-client', version: '1.0.0' });
await defaultClient.connect(new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp')));
for (const caller of [client, defaultClient]) {
const result = await caller.callTool({ name: 'forecast', arguments: { city: 'Berlin' } });
console.log(caller.getProtocolEra(), JSON.stringify(result.content));
}
//#endregion createMcpHandler_callBothEras
await client.close();
await fallback.close();
await defaultClient.close();
await handler.close();
globalThis.fetch = realFetch;
// ---------------------------------------------------------------------------
// ## Understand the probe — the options block is never connected; it exists to
// typecheck the `probe` shape the page shows.
// ---------------------------------------------------------------------------
function versionNegotiation_probe(): Client {
//#region versionNegotiation_probe
const cli = new Client(
{ name: 'my-client', version: '1.0.0' },
{
versionNegotiation: {
mode: 'auto',
probe: {
timeoutMs: 10_000, // default: the connection's request timeout
maxRetries: 0 // default: no probe re-sends after a timeout
}
}
}
);
//#endregion versionNegotiation_probe
return cli;
}
void versionNegotiation_probe;