138 lines
5.5 KiB
TypeScript
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;
|