docs: rebuild the v2 documentation around a task-based page tree (#2397)

This commit is contained in:
Felix Weinberger 2026-06-30 21:18:42 +01:00 • committed by GitHub
commit b153505428
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 11884 additions and 4971 deletions

View file

@ -66,12 +66,12 @@ The one exception to the generic commands is the reference pair: [`cli-client/`]
## Excluded
| Directory | What it is | Why not in CI |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| [`repl/`](./repl/README.md) | Fully-featured HTTP playground server + readline client | Interactive — `client.ts` reads from stdin. Run manually in two terminals. |
| [`guides/`](./guides/README.md) | Snippet collections synced into `docs/server.md` and `docs/client.md` | Typecheck-only; not a runnable pair. |
| `server-quickstart/`, `client-quickstart/` | Website-tutorial sources | External network / API key; typecheck-only. |
| `shared/` | Argv/assert scaffold (`parseExampleArgs`/`check`/`siblingPath`); demo OAuth provider + `InMemoryEventStore` at the `./auth` subpath | Not a story — imported by every story as scaffolding. |
| Directory | What it is | Why not in CI |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| [`repl/`](./repl/README.md) | Fully-featured HTTP playground server + readline client | Interactive — `client.ts` reads from stdin. Run manually in two terminals. |
| [`guides/`](./guides/README.md) | Per-page snippet companions synced into the `docs/` guide pages | Not a client/server story pair; typechecked and executed by `pnpm docs:examples`. |
| `server-quickstart/`, `client-quickstart/` | Standalone starter projects (the original quickstart sources) | External network / API key; typecheck-only. |
| `shared/` | Argv/assert scaffold (`parseExampleArgs`/`check`/`siblingPath`); demo OAuth provider + `InMemoryEventStore` at the `./auth` subpath | Not a story — imported by every story as scaffolding. |
## Multi-node deployment patterns
@ -173,5 +173,5 @@ For scenarios where local in-memory state must be maintained on specific nodes,
## Backwards compatibility (Streamable HTTP ↔ legacy SSE)
A client that needs to fall back from Streamable HTTP to the legacy HTTP+SSE transport (for servers that only implement the older transport) follows the [`connect_sseFallback`](../docs/client.md#sse-fallback-for-legacy-servers) recipe in the client guide — try
`StreamableHTTPClientTransport` first, fall back to `SSEClientTransport` on a 4xx. There is no runnable pair for this in `examples/` (the legacy SSE server transport is deprecated); the snippet in `guides/clientGuide.examples.ts` is the complete pattern.
A client that needs to fall back from Streamable HTTP to the legacy HTTP+SSE transport (for servers that only implement the older transport) follows [Fall back to SSE for servers that predate Streamable HTTP](../docs/clients/connect.md#fall-back-to-sse-for-servers-that-predate-streamable-http) in the client docs — try
`StreamableHTTPClientTransport` first, fall back to `SSEClientTransport` on a 4xx. There is no runnable pair for this in `examples/` (the legacy SSE server transport is deprecated); the `connect_sseFallback` snippet in `guides/clients/connect.examples.ts` is the complete pattern.

View file

@ -1,5 +1,5 @@
# client-quickstart
Source for the [Client Quickstart](../../docs/client-quickstart.md) tutorial: an LLM-powered chatbot that connects to an MCP server over stdio and calls its tools. The tutorial walks through `src/index.ts` end to end.
An LLM-powered chatbot that connects to an MCP server over stdio and calls its tools (`src/index.ts`). It was the source for the retired client-quickstart tutorial; the current getting-started tutorial is [Build your first client](../../docs/get-started/first-client.md).
The `package.json` and `tsconfig.json` here are monorepo-internal (`workspace:`/`catalog:` protocols; typecheck-only in CI). To build the client yourself, use the standalone manifests from the tutorial.
The `package.json` and `tsconfig.json` here are monorepo-internal (`workspace:`/`catalog:` protocols; typecheck-only in CI). To build the client yourself outside the monorepo, copy `src/index.ts` into a standalone project that depends on the published packages.

View file

@ -42,5 +42,25 @@ export default [
}
]
}
},
{
// Guide companions are documentation snippets: filenames mirror their page slugs,
// every //#region must stay self-contained when rendered on its page, and snippet
// style follows the docs register rather than the example-app style rules.
// The import restrictions above still apply.
files: ['guides/**/*.ts'],
rules: {
'unicorn/filename-case': 'off',
'unicorn/switch-case-braces': 'off',
'unicorn/numeric-separators-style': 'off',
'unicorn/import-style': 'off',
'unicorn/no-await-expression-member': 'off',
'unicorn/prefer-response-static-json': 'off',
'@typescript-eslint/consistent-type-imports': 'off',
'simple-import-sort/imports': 'off',
'import/no-duplicates': 'off',
'unicorn/require-array-join-separator': 'off',
'unicorn/explicit-length-check': 'off'
}
}
];

View file

@ -1,3 +1,3 @@
# guides
Snippet collections synced into `docs/server.md` and `docs/client.md` via `pnpm sync:snippets`. Typecheck-only — these are not runnable programs.
Per-page snippet companions for the `docs/` guide pages: each `<dir>/<page>.examples.ts` is the type-checked source for the code fences in `docs/<dir>/<page>.md`, synced via `pnpm sync:snippets`. Companions that quote output are also run as real programs by `pnpm docs:examples`; the rest opt out with a `// docs: typecheck-only` first line.

View file

@ -0,0 +1,100 @@
/**
* Companion example for `docs/advanced/custom-methods.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 below the
* regions connects an in-memory client and produces the output the page quotes
* verbatim.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
* npx tsx guides/advanced/custom-methods.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
//#region setRequestHandler_custom
import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const SearchParams = z.object({ query: z.string(), limit: z.number().int().default(10) });
const SearchResult = z.object({ items: z.array(z.string()) });
const mcp = new McpServer({ name: 'acme-search', version: '1.0.0' });
mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async ({ query, limit }) => {
return { items: Array.from({ length: limit }, (_, index) => `${query}-${index}`) };
});
//#endregion setRequestHandler_custom
// "Declare an extension capability" — must happen before the server connects.
//#region registerCapabilities_extensions
mcp.server.registerCapabilities({
extensions: { 'com.example/feature-flags': { flags: ['dark-mode', 'beta-search'] } }
});
//#endregion registerCapabilities_extensions
// ---------------------------------------------------------------------------
// Harness (not shown on the page). An in-memory client drives the calls whose
// output advanced/custom-methods.md quotes verbatim. Any MCP client behaves
// the same. Imported dynamically so the page's lead region stays self-contained.
// ---------------------------------------------------------------------------
const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client');
const client = new Client({ name: 'custom-methods-docs-harness', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcp.connect(serverTransport);
await client.connect(clientTransport);
// "Call it from the client" — the result the page quotes.
//#region request_custom
const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult);
console.log(result);
//#endregion request_custom
// Proof for the page's ::: tip — params that fail `SearchParams` are rejected
// before the handler runs. Throws (non-zero exit) if the claim is false.
let rejection: Error | undefined;
try {
await client.request({ method: 'acme/search', params: { query: 42 } }, SearchResult);
} catch (error) {
rejection = error as Error;
}
if (!rejection) {
throw new Error('custom-methods.md tip claim failed: invalid params were accepted');
}
console.log(rejection.message);
// "Read the negotiated extensions on the client" — the map the page quotes.
//#region getServerCapabilities_extensions
const extensions = client.getServerCapabilities()?.extensions ?? {};
console.log(extensions);
//#endregion getServerCapabilities_extensions
// "Send a custom notification from the handler" — registering the method again
// replaces its handler with one that reports progress.
//#region setRequestHandler_notify
mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async ({ query, limit }, ctx) => {
await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'start', pct: 0 } });
const items = Array.from({ length: limit }, (_, index) => `${query}-${index}`);
await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'done', pct: 1 } });
return { items };
});
//#endregion setRequestHandler_notify
// "Receive it on the client" — the progress params the page quotes.
//#region setNotificationHandler_custom
const SearchProgressParams = z.object({ stage: z.string(), pct: z.number() });
client.setNotificationHandler('acme/searchProgress', { params: SearchProgressParams }, params => {
console.log(params);
});
await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 1 } }, SearchResult);
//#endregion setNotificationHandler_custom
// Let the notification microtasks drain before tearing the pair down.
await new Promise(resolve => setImmediate(resolve));
await client.close();
await mcp.close();

View file

@ -0,0 +1,210 @@
/**
* Companion example for `docs/advanced/custom-transports.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 below the
* regions connects a client and a server over the loopback transport and
* produces the output the page quotes verbatim.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
* npx tsx guides/advanced/custom-transports.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
//#region transport_loopback
import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/server';
export class LoopbackTransport implements Transport {
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
private peer?: LoopbackTransport;
/** Cross-wire two ends: whatever one end sends, the other receives. */
static link(a: LoopbackTransport, b: LoopbackTransport): void {
a.peer = b;
b.peer = a;
}
async start(): Promise<void> {
// Open your channel here. The loopback has nothing to open.
}
async send(message: JSONRPCMessage): Promise<void> {
const peer = this.peer;
if (!peer) throw new Error('Loopback peer is gone');
queueMicrotask(() => peer.onmessage?.(message));
}
async close(): Promise<void> {
this.peer = undefined;
this.onclose?.();
}
}
//#endregion transport_loopback
// "Connect it like a built-in transport" — produces the output the page quotes.
//#region connect_loopback
import { Client } from '@modelcontextprotocol/client';
import { McpServer } from '@modelcontextprotocol/server';
const server = new McpServer({ name: 'loopback-demo', version: '1.0.0' });
server.registerTool('ping', { description: 'Reply with pong' }, async () => ({
content: [{ type: 'text', text: 'pong' }]
}));
const client = new Client({ name: 'loopback-client', version: '1.0.0' });
const serverEnd = new LoopbackTransport();
const clientEnd = new LoopbackTransport();
LoopbackTransport.link(serverEnd, clientEnd);
await server.connect(serverEnd);
await client.connect(clientEnd);
const result = await client.callTool({ name: 'ping' });
console.log(result.content);
//#endregion connect_loopback
// "Frame messages over a byte stream" — typechecked, not run (it needs a real
// socket). The runnable proof on this page is the loopback transport above.
//#region transport_socket
import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/server';
import type { Socket } from 'node:net';
export class SocketTransport implements Transport {
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
private readonly readBuffer = new ReadBuffer();
constructor(private readonly socket: Socket) {}
async start(): Promise<void> {
this.socket.on('data', chunk => {
try {
this.readBuffer.append(chunk);
let message = this.readBuffer.readMessage();
while (message !== null) {
this.onmessage?.(message);
message = this.readBuffer.readMessage();
}
} catch (error) {
this.onerror?.(error as Error);
}
});
this.socket.on('error', error => this.onerror?.(error));
this.socket.on('close', () => this.onclose?.());
}
async send(message: JSONRPCMessage): Promise<void> {
this.socket.write(serializeMessage(message));
}
async close(): Promise<void> {
this.socket.end();
}
}
//#endregion transport_socket
// "Report a session ID and the negotiated version".
//#region transport_session
export class SessionLoopbackTransport extends LoopbackTransport {
sessionId?: string;
protocolVersion?: string;
setProtocolVersion(version: string): void {
this.protocolVersion = version;
}
}
//#endregion transport_session
// "Opt into per-request cancellation" — typechecked, not run.
import type { TransportSendOptions } from '@modelcontextprotocol/server';
import { parseJSONRPCMessage } from '@modelcontextprotocol/server';
export class HttpPostTransport implements Transport {
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
constructor(private readonly endpoint: URL) {}
//#region transport_perRequest
readonly hasPerRequestStream = true;
async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
const response = await fetch(this.endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json', ...options?.headers },
body: JSON.stringify(message),
signal: options?.requestSignal
});
this.onmessage?.(parseJSONRPCMessage(await response.json()));
}
//#endregion transport_perRequest
async start(): Promise<void> {}
async close(): Promise<void> {
this.onclose?.();
}
}
// ---------------------------------------------------------------------------
// Harness (not shown on the page). Drives the remaining claims the page makes
// and throws (non-zero exit) if any of them is false.
// ---------------------------------------------------------------------------
await client.close();
await server.close();
// "Report a session ID and the negotiated version" — the version the page quotes.
const sessionServer = new McpServer({ name: 'loopback-demo', version: '1.0.0' });
const sessionClient = new Client({ name: 'loopback-client', version: '1.0.0' });
const sessionServerEnd = new SessionLoopbackTransport();
const sessionClientEnd = new SessionLoopbackTransport();
LoopbackTransport.link(sessionServerEnd, sessionClientEnd);
await sessionServer.connect(sessionServerEnd);
await sessionClient.connect(sessionClientEnd);
console.log(sessionClientEnd.protocolVersion);
if (sessionServerEnd.protocolVersion !== sessionClientEnd.protocolVersion) {
throw new Error(
`custom-transports.md claim failed: server end negotiated ${sessionServerEnd.protocolVersion}, client end ${sessionClientEnd.protocolVersion}`
);
}
await sessionClient.close();
await sessionServer.close();
// "Test it against the in-memory pair" — the reference transport returns the
// same result the loopback produced.
const referenceServer = new McpServer({ name: 'loopback-demo', version: '1.0.0' });
referenceServer.registerTool('ping', { description: 'Reply with pong' }, async () => ({
content: [{ type: 'text', text: 'pong' }]
}));
const referenceClient = new Client({ name: 'loopback-client', version: '1.0.0' });
//#region inMemory_pair
import { InMemoryTransport } from '@modelcontextprotocol/client';
const [inMemoryClientEnd, inMemoryServerEnd] = InMemoryTransport.createLinkedPair();
//#endregion inMemory_pair
await referenceServer.connect(inMemoryServerEnd);
await referenceClient.connect(inMemoryClientEnd);
const referenceResult = await referenceClient.callTool({ name: 'ping' });
if (JSON.stringify(referenceResult.content) !== JSON.stringify(result.content)) {
throw new Error(`custom-transports.md claim failed: in-memory result ${JSON.stringify(referenceResult.content)}`);
}
await referenceClient.close();
await referenceServer.close();

View file

@ -0,0 +1,135 @@
/**
* Runnable, type-checked companion for `docs/advanced/gateway.md`.
*
* Each `//#region` block is synced byte-for-byte into that page's code fences
* (`pnpm sync:snippets --check` reports drift).
*
* The page's program runs for real: the harness below builds a
* `createMcpHandler` server and routes `globalThis.fetch` for
* `http://localhost:3000/mcp` into `handler.fetch`, so the HTTP regions
* execute in-process without binding a port. The output the page quotes
* verbatim is whatever this file prints. The server's `request_count` tool
* returns how many MCP requests reached the process (`createMcpHandler`
* builds one server instance per request), which is what proves that
* `connect({ prior })` sent nothing.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
* npx tsx guides/advanced/gateway.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
// ---------------------------------------------------------------------------
// Harness (not shown on the page). Every `new McpServer(...)` is one inbound
// MCP request, so the module-level counter is the number of requests the
// process has answered — `request_count` exposes it to the page's clients.
// ---------------------------------------------------------------------------
let requests = 0;
const handler = createMcpHandler(() => {
requests++;
const server = new McpServer({ name: 'gateway-target', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } });
server.registerTool('echo', { description: 'Echo the input back', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({
content: [{ type: 'text', text }]
}));
server.registerTool(
'request_count',
{ description: 'Number of MCP requests this server process has answered', outputSchema: z.object({ requests: z.number() }) },
async () => ({ content: [{ type: 'text', text: String(requests) }], structuredContent: { requests } })
);
return server;
});
const realFetch = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const request = new Request(input, init);
if (new URL(request.url).host === 'localhost:3000') return handler.fetch(request);
return realFetch(input, init);
}) as typeof fetch;
// ## Connect with a prior discover result
//#region connect_prior
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
const url = new URL('http://localhost:3000/mcp');
// Probe once …
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await bootstrap.connect(new StreamableHTTPClientTransport(url));
const persisted = JSON.stringify(bootstrap.getDiscoverResult());
// … then every other client connects with zero round trips.
const worker = new Client({ name: 'worker', version: '1.0.0' });
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
//#endregion connect_prior
// ## Probe once at bootstrap
//#region bootstrap_probe
console.log(bootstrap.getDiscoverResult());
//#endregion bootstrap_probe
// ## Persist the advertisement
//#region persist_advertisement
import type { DiscoverResult } from '@modelcontextprotocol/client';
const prior = JSON.parse(persisted) as DiscoverResult;
//#endregion persist_advertisement
// ## Fan out to workers
//#region fan_out
const fleet = await Promise.all(
['worker-a', 'worker-b', 'worker-c'].map(async name => {
const replica = new Client({ name, version: '1.0.0' });
await replica.connect(new StreamableHTTPClientTransport(url), { prior });
return replica;
})
);
const proof = await worker.callTool({ name: 'request_count' });
console.log(proof.structuredContent);
//#endregion fan_out
// ## Open a listen stream when a worker needs notifications
//#region listen_worker
const subscription = await worker.listen({ toolsListChanged: true });
console.log(subscription.honoredFilter);
//#endregion listen_worker
await subscription.close();
// ## Handle a stale or incompatible advertisement
//#region prior_stale
import { SdkError, SdkErrorCode } from '@modelcontextprotocol/client';
const stale: DiscoverResult = { ...prior, supportedVersions: ['2025-06-18'] };
const late = new Client({ name: 'worker-d', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
try {
await late.connect(new StreamableHTTPClientTransport(url), { prior: stale });
} catch (error) {
if (!(error instanceof SdkError) || error.code !== SdkErrorCode.EraNegotiationFailed) throw error;
console.log(error.code);
// Fall back to a fresh probe, then re-persist getDiscoverResult().
await late.connect(new StreamableHTTPClientTransport(url));
console.log('re-probed:', late.getNegotiatedProtocolVersion());
}
//#endregion prior_stale
// ---------------------------------------------------------------------------
// Harness teardown.
// ---------------------------------------------------------------------------
for (const client of [bootstrap, worker, late, ...fleet]) await client.close();
await handler.close();
globalThis.fetch = realFetch;

View file

@ -0,0 +1,179 @@
/**
* Companion example for `docs/advanced/low-level-server.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 between the
* regions connects in-memory clients and produces every output the page quotes
* verbatim, exiting non-zero on drift.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
* npx tsx guides/advanced/low-level-server.examples.ts # from examples/
*
* `lowLevel_serve` lives in a never-invoked wrapper: `serveStdio` would bind
* this process's real stdin/stdout, so that one region is typecheck-only.
*
* @module
*/
/* eslint-disable no-console, import/no-duplicates */
// Harness imports. The page's lead block (the first region) carries its own
// `Server` import so the rendered fence stands alone.
import { createMcpHandler, fromJsonSchema, McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
// ---------------------------------------------------------------------------
// "Build the server and list your tools by hand"
// ---------------------------------------------------------------------------
//#region lowLevel_listTools
import { Server } from '@modelcontextprotocol/server';
const catalog = [
{ name: 'Espresso cup', price: 12 },
{ name: 'Travel mug', price: 24 },
{ name: 'Mug rack', price: 36 }
];
const server = new Server({ name: 'catalog', version: '1.0.0' }, { capabilities: { tools: {} } });
server.setRequestHandler('tools/list', async () => ({
tools: [
{
name: 'search',
description: 'Search the product catalog',
inputSchema: {
type: 'object',
properties: { query: { type: 'string', description: 'Substring to match against product names' } },
required: ['query']
}
}
]
}));
//#endregion lowLevel_listTools
// ---------------------------------------------------------------------------
// "Handle tools/call yourself"
// ---------------------------------------------------------------------------
//#region lowLevel_callTool
server.setRequestHandler('tools/call', async request => {
if (request.params.name !== 'search') {
return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true };
}
const { query } = request.params.arguments as { query: string };
const hits = catalog.filter(product => product.name.toLowerCase().includes(query.toLowerCase()));
return { content: [{ type: 'text', text: hits.map(product => product.name).join('\n') }] };
});
//#endregion lowLevel_callTool
// ---------------------------------------------------------------------------
// Harness (not shown on the page). An in-memory client drives the calls whose
// output low-level-server.md quotes verbatim. Imported dynamically so the
// page's lead region stays self-contained.
// ---------------------------------------------------------------------------
const { Client, InMemoryTransport, ProtocolError } = await import('@modelcontextprotocol/client');
const client = new Client({ name: 'low-level-docs-harness', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
// The handler answers a valid call exactly like the McpServer version.
const result = await client.callTool({ name: 'search', arguments: { query: 'mug' } });
console.log(result.content);
// Nothing validated `query`, so a wrongly-typed argument reaches the handler
// and crashes it: the client sees a JSON-RPC error, not a tool result.
const crashed = await client.callTool({ name: 'search', arguments: { query: 42 } }).catch((error: unknown) => error);
if (!(crashed instanceof ProtocolError)) {
throw new Error(`low-level-server.md expected the unvalidated call to reject: ${JSON.stringify(crashed)}`);
}
console.log(`${crashed.name} ${crashed.code}: ${crashed.message}`);
// ---------------------------------------------------------------------------
// "Validate arguments yourself"
// ---------------------------------------------------------------------------
//#region lowLevel_validate
const SearchArguments = fromJsonSchema<{ query: string }>({
type: 'object',
properties: { query: { type: 'string' } },
required: ['query']
});
server.setRequestHandler('tools/call', async request => {
if (request.params.name !== 'search') {
return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true };
}
const parsed = await SearchArguments['~standard'].validate(request.params.arguments ?? {});
if (parsed.issues) {
return { content: [{ type: 'text', text: parsed.issues.map(issue => issue.message).join('; ') }], isError: true };
}
const hits = catalog.filter(product => product.name.toLowerCase().includes(parsed.value.query.toLowerCase()));
return { content: [{ type: 'text', text: hits.map(product => product.name).join('\n') }] };
});
//#endregion lowLevel_validate
// The same wrongly-typed call now comes back as an ordinary isError result.
const rejected = await client.callTool({ name: 'search', arguments: { query: 42 } });
console.log(rejected);
if (rejected.isError !== true) {
throw new Error(`low-level-server.md expected the validated call to return isError: ${JSON.stringify(rejected)}`);
}
await client.close();
await server.close();
// ---------------------------------------------------------------------------
// "Serve it with the same entry points" — typecheck-only. `serveStdio` would
// take over this process's stdin/stdout, so the harness never calls this.
// ---------------------------------------------------------------------------
function lowLevel_serve() {
//#region lowLevel_serve
serveStdio(() => server);
createMcpHandler(() => server);
//#endregion lowLevel_serve
}
void lowLevel_serve;
// ---------------------------------------------------------------------------
// "Reach the low level from McpServer"
// ---------------------------------------------------------------------------
//#region lowLevel_escapeHatch
const mcp = new McpServer({ name: 'catalog', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } });
mcp.registerTool(
'search',
{ description: 'Search the product catalog', inputSchema: z.object({ query: z.string() }) },
async ({ query }) => {
const names = catalog.filter(product => product.name.includes(query)).map(product => product.name);
return { content: [{ type: 'text', text: names.join('\n') }] };
}
);
const subscriptions = new Set<string>();
mcp.server.setRequestHandler('resources/subscribe', async request => {
subscriptions.add(request.params.uri);
return {};
});
//#endregion lowLevel_escapeHatch
// Harness: prove the page's claim for the section above — `registerTool` still
// owns `tools/list`, and the hand-registered handler answers
// `resources/subscribe` on the same connection.
const mcpClient = new Client({ name: 'low-level-docs-harness', version: '1.0.0' });
const [mcpClientTransport, mcpServerTransport] = InMemoryTransport.createLinkedPair();
await mcp.connect(mcpServerTransport);
await mcpClient.connect(mcpClientTransport);
const { tools } = await mcpClient.listTools();
await mcpClient.subscribeResource({ uri: 'demo://config' });
if (tools.length !== 1 || tools[0]?.name !== 'search' || !subscriptions.has('demo://config')) {
throw new Error(`low-level-server.md escape-hatch claim failed: ${JSON.stringify({ tools, subscriptions: [...subscriptions] })}`);
}
await mcpClient.close();
await mcp.close();

View file

@ -0,0 +1,129 @@
/**
* Companion example for `docs/advanced/schema-libraries.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 below the
* regions connects an in-memory client and produces the output the page quotes
* verbatim.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
* npx tsx guides/advanced/schema-libraries.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
//#region registerTool_arktype
import { McpServer } from '@modelcontextprotocol/server';
import { type } from 'arktype';
const server = new McpServer({ name: 'schema-zoo', version: '1.0.0' });
server.registerTool(
'greet',
{
description: 'Greet someone by name',
inputSchema: type({ name: 'string', 'times?': '1 <= number.integer <= 5' })
},
async ({ name, times }) => ({
content: [{ type: 'text', text: Array.from({ length: times ?? 1 }, () => `Hello, ${name}`).join('\n') }]
})
);
//#endregion registerTool_arktype
//#region registerTool_valibot
import { toStandardJsonSchema } from '@valibot/to-json-schema';
import * as v from 'valibot';
server.registerTool(
'shout',
{ description: 'Greet someone, loudly', inputSchema: toStandardJsonSchema(v.object({ name: v.string() })) },
async ({ name }) => ({ content: [{ type: 'text', text: `HELLO, ${name.toUpperCase()}` }] })
);
//#endregion registerTool_valibot
//#region registerTool_fromJsonSchema
import { fromJsonSchema } from '@modelcontextprotocol/server';
server.registerTool(
'farewell',
{
description: 'Say goodbye',
inputSchema: fromJsonSchema<{ name: string }>({
type: 'object',
properties: { name: { type: 'string' } },
required: ['name']
})
},
async ({ name }) => ({ content: [{ type: 'text', text: `Goodbye, ${name}` }] })
);
//#endregion registerTool_fromJsonSchema
//#region registerTool_outputSchema
server.registerTool(
'measure',
{
description: 'Measure the length of a name',
inputSchema: type({ name: 'string' }),
outputSchema: type({ name: 'string', length: 'number' })
},
async ({ name }) => {
const output = { name, length: name.length };
return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output };
}
);
//#endregion registerTool_outputSchema
//#region jsonSchemaValidator_ajv
import { addFormats, Ajv, AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv';
const ajv = new Ajv({ strict: true, allErrors: true });
addFormats(ajv);
const validator = new AjvJsonSchemaValidator(ajv);
const strict = new McpServer({ name: 'schema-zoo', version: '1.0.0' }, { jsonSchemaValidator: validator });
//#endregion jsonSchemaValidator_ajv
//#region jsonSchemaValidator_cfWorker
import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker';
const edge = new McpServer({ name: 'schema-zoo', version: '1.0.0' }, { jsonSchemaValidator: new CfWorkerJsonSchemaValidator() });
//#endregion jsonSchemaValidator_cfWorker
// ---------------------------------------------------------------------------
// Harness (not shown on the page). An in-memory client drives the calls whose
// output advanced/schema-libraries.md quotes verbatim. Any MCP client behaves
// the same. Imported dynamically so the page's lead region stays
// self-contained.
// ---------------------------------------------------------------------------
const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client');
const client = new Client({ name: 'schema-libraries-docs-harness', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
// "Register a tool with an ArkType schema" — the rejection the page quotes.
const rejected = await client.callTool({ name: 'greet', arguments: { name: 'Ada', times: 99 } });
console.log(rejected);
// "Register a tool with a Valibot schema" — proves the page's prose claim that
// the call validates and dispatches like any other tool.
const shouted = await client.callTool({ name: 'shout', arguments: { name: 'Ada' } });
if (JSON.stringify(shouted.content) !== JSON.stringify([{ type: 'text', text: 'HELLO, ADA' }])) {
throw new Error(`schema-libraries.md valibot claim failed: ${JSON.stringify(shouted)}`);
}
// "Start from JSON Schema you already have" — the advertised schema the page quotes.
const { tools } = await client.listTools();
const farewell = tools.find(tool => tool.name === 'farewell');
console.log(farewell?.inputSchema);
// "Validate structured output with any library" — the structured result the page quotes.
const measured = await client.callTool({ name: 'measure', arguments: { name: 'Ada' } });
console.log(measured);
await client.close();
await server.close();
await strict.close();
await edge.close();

View file

@ -0,0 +1,104 @@
/**
* Companion example for `docs/advanced/wire-schemas.md`.
*
* Every `ts` fence on that page is synced from a `//#region` in this file
* (`pnpm sync:snippets --check`). The file also runs: it is the gateway path
* the page walks through — raw JSON in, validated with the schemas from
* `@modelcontextprotocol/core`, no `Client` or `Server` anywhere — and it
* prints the exact output the page quotes verbatim.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
* npx tsx guides/advanced/wire-schemas.examples.ts # from examples/
*
* @module
*/
/* eslint-disable @typescript-eslint/no-unused-vars, no-console */
// "Validate a wire payload" — the happy path the page quotes.
//#region wireSchemas_validateResult
import { CallToolResultSchema } from '@modelcontextprotocol/core';
// The body an upstream server returned for a tools/call you forwarded.
const body: unknown = JSON.parse('{"content":[{"type":"text","text":"Travel mug"}]}');
const parsed = CallToolResultSchema.safeParse(body);
if (!parsed.success) {
throw new Error(`upstream returned an invalid tools/call result: ${parsed.error.message}`);
}
console.log(parsed.data.content);
//#endregion wireSchemas_validateResult
// "Validate a wire payload" — the rejection the page quotes.
//#region wireSchemas_validateResult_invalid
const malformed = CallToolResultSchema.safeParse({ content: 'Travel mug' });
console.log(malformed.error?.issues);
//#endregion wireSchemas_validateResult_invalid
// "Pick the schema for the message you hold" — the undecoded envelope.
//#region wireSchemas_envelope
import { JSONRPCMessageSchema } from '@modelcontextprotocol/core';
const frame = '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"mug"}}}';
const message = JSONRPCMessageSchema.parse(JSON.parse(frame));
//#endregion wireSchemas_envelope
// "Route raw JSON-RPC in a proxy" — branch on method, then the per-method schema.
//#region wireSchemas_route
import { CallToolRequestSchema } from '@modelcontextprotocol/core';
if ('method' in message) {
switch (message.method) {
case 'tools/call': {
const call = CallToolRequestSchema.parse(message);
console.log(`forward tools/call for ${call.params.name} upstream`);
break;
}
default:
console.log(`forward ${message.method} unchanged`);
}
}
//#endregion wireSchemas_route
// "Validate OAuth and discovery metadata" — the second export group.
//#region wireSchemas_oauthMetadata
import { OAuthMetadataSchema } from '@modelcontextprotocol/core';
// In production this body comes from GET <issuer>/.well-known/oauth-authorization-server.
const response = new Response(
JSON.stringify({
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
response_types_supported: ['code']
})
);
const metadata = OAuthMetadataSchema.parse(await response.json());
console.log(metadata.token_endpoint);
//#endregion wireSchemas_oauthMetadata
// "Get the TypeScript types, guards and errors from the SDK packages" — core is
// Zod values only; the names live in /client and /server.
//#region wireSchemas_types
import type { CallToolResult } from '@modelcontextprotocol/client';
import * as z from 'zod/v4';
// The SDK's spec type and the schema's own inferred output describe the same value.
const relayed: CallToolResult = parsed.data;
type CallToolResultFromCore = z.infer<typeof CallToolResultSchema>;
//#endregion wireSchemas_types
// ---------------------------------------------------------------------------
// Self-checks (not shown on the page). Throw — non-zero exit — if any claim
// the page makes stops being true.
// ---------------------------------------------------------------------------
if (malformed.success) {
throw new Error('wire-schemas.md claim failed: a non-array `content` must not parse');
}
if (!('method' in message) || message.method !== 'tools/call') {
throw new Error('wire-schemas.md claim failed: the envelope did not narrow to a tools/call request');
}
if (relayed.content[0]?.type !== 'text') {
throw new Error('wire-schemas.md claim failed: parsed result is not assignable to CallToolResult');
}

View file

@ -1,923 +0,0 @@
/**
* Type-checked examples for docs/client.md.
*
* Regions are synced into markdown code fences via `pnpm sync:snippets`.
* Each function wraps a single region. The function name matches the region name.
*
* @module
*/
//#region imports
import type {
AuthProvider,
CallToolResult,
InputRequiredResult,
OAuthClientInformationContext,
OAuthClientInformationMixed,
OAuthClientMetadata,
OAuthClientProvider,
OAuthDiscoveryState,
OAuthTokens
} from '@modelcontextprotocol/client';
import {
applyMiddlewares,
checkResourceAllowed,
Client,
ClientCredentialsProvider,
createMiddleware,
CrossAppAccessProvider,
discoverAndRequestJwtAuthGrant,
isInputRequiredResult,
IssuerMismatchError,
LOG_LEVEL_META_KEY,
PrivateKeyJwtProvider,
ProtocolError,
resourceUrlFromServerUrl,
SdkError,
SdkErrorCode,
SdkHttpError,
SSEClientTransport,
StreamableHTTPClientTransport,
TRACEPARENT_META_KEY,
TRACESTATE_META_KEY,
UnauthorizedError
} from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
//#endregion imports
// ---------------------------------------------------------------------------
// Connecting to a server
// ---------------------------------------------------------------------------
/** Example: Streamable HTTP transport. */
async function connect_streamableHttp() {
//#region connect_streamableHttp
const client = new Client({ name: 'my-client', version: '1.0.0' });
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'));
await client.connect(transport);
//#endregion connect_streamableHttp
}
/** Example: stdio transport for local process-spawned servers. */
async function connect_stdio() {
//#region connect_stdio
const client = new Client({ name: 'my-client', version: '1.0.0' });
const transport = new StdioClientTransport({
command: 'node',
args: ['server.js']
});
await client.connect(transport);
//#endregion connect_stdio
}
/** Example: Try Streamable HTTP, fall back to legacy SSE. */
async function connect_sseFallback(url: string) {
//#region connect_sseFallback
const baseUrl = new URL(url);
try {
// Try modern Streamable HTTP transport first
const client = new Client({ name: 'my-client', version: '1.0.0' });
const transport = new StreamableHTTPClientTransport(baseUrl);
await client.connect(transport);
return { client, transport };
} catch {
// Fall back to legacy SSE transport
const client = new Client({ name: 'my-client', version: '1.0.0' });
const transport = new SSEClientTransport(baseUrl);
await client.connect(transport);
return { client, transport };
}
//#endregion connect_sseFallback
}
/** Example: Opt into 2026-07-28 protocol version negotiation. */
async function Client_versionNegotiation(transport: StreamableHTTPClientTransport) {
//#region Client_versionNegotiation
// Auto-negotiate: probe with server/discover, fall back to the 2025 handshake
// against a 2025-only server.
const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(transport);
client.getProtocolEra(); // 'modern' or 'legacy'
client.getNegotiatedProtocolVersion(); // '2026-07-28' or '2025-11-25'
//#endregion Client_versionNegotiation
}
/** Example: zero-round-trip connect from a persisted DiscoverResult. */
async function Client_connect_prior(url: URL) {
//#region Client_connect_prior
// Probe once (here via the 'auto'-mode connect), persist the result …
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await bootstrap.connect(new StreamableHTTPClientTransport(url));
const persisted = JSON.stringify(bootstrap.getDiscoverResult());
// … then every worker connects with zero round trips.
const worker = new Client({ name: 'worker', version: '1.0.0' });
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
//#endregion Client_connect_prior
}
// ---------------------------------------------------------------------------
// Disconnecting
// ---------------------------------------------------------------------------
/** Example: Graceful disconnect for Streamable HTTP. */
async function disconnect_streamableHttp(client: Client, transport: StreamableHTTPClientTransport) {
//#region disconnect_streamableHttp
await transport.terminateSession(); // notify the server (recommended)
await client.close();
//#endregion disconnect_streamableHttp
}
// ---------------------------------------------------------------------------
// Server instructions
// ---------------------------------------------------------------------------
/** Example: Access server instructions after connecting. */
async function serverInstructions_basic(client: Client) {
//#region serverInstructions_basic
const instructions = client.getInstructions();
const systemPrompt = ['You are a helpful assistant.', instructions].filter(Boolean).join('\n\n');
console.log(systemPrompt);
//#endregion serverInstructions_basic
}
// ---------------------------------------------------------------------------
// Extension capabilities
// ---------------------------------------------------------------------------
/** Example: Read the negotiated extension capabilities after connecting. */
function extensionCapabilities_read(client: Client) {
//#region extensionCapabilities_read
const extensions = client.getServerCapabilities()?.extensions ?? {};
if ('com.example/feature-flags' in extensions) {
// Advertised on this connection; the entry's value is its settings object.
}
//#endregion extensionCapabilities_read
}
// ---------------------------------------------------------------------------
// Authentication
// ---------------------------------------------------------------------------
/** Example: Minimal AuthProvider for bearer auth with externally-managed tokens. */
async function auth_tokenProvider(getStoredToken: () => Promise<string>) {
//#region auth_tokenProvider
const authProvider: AuthProvider = { token: async () => getStoredToken() };
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider });
//#endregion auth_tokenProvider
return transport;
}
/** Example: Client credentials auth for service-to-service communication. */
async function auth_clientCredentials() {
//#region auth_clientCredentials
const authProvider = new ClientCredentialsProvider({
clientId: 'my-service',
clientSecret: 'my-secret'
});
const client = new Client({ name: 'my-client', version: '1.0.0' });
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider });
await client.connect(transport);
//#endregion auth_clientCredentials
}
/** Example: Private key JWT auth. */
async function auth_privateKeyJwt(pemEncodedKey: string) {
//#region auth_privateKeyJwt
const authProvider = new PrivateKeyJwtProvider({
clientId: 'my-service',
privateKey: pemEncodedKey,
algorithm: 'RS256'
});
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider });
//#endregion auth_privateKeyJwt
return transport;
}
/** Example: Cross-App Access (SEP-990 Enterprise Managed Authorization). */
async function auth_crossAppAccess(getIdToken: () => Promise<string>) {
//#region auth_crossAppAccess
const authProvider = new CrossAppAccessProvider({
assertion: async ctx => {
// ctx provides: authorizationServerUrl, resourceUrl, scope, fetchFn
const result = await discoverAndRequestJwtAuthGrant({
idpUrl: 'https://idp.example.com',
audience: ctx.authorizationServerUrl,
resource: ctx.resourceUrl,
idToken: await getIdToken(),
clientId: 'my-idp-client',
clientSecret: 'my-idp-secret',
scope: ctx.scope,
fetchFn: ctx.fetchFn
});
return result.jwtAuthGrant;
},
clientId: 'my-mcp-client',
clientSecret: 'my-mcp-secret'
});
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider });
//#endregion auth_crossAppAccess
return transport;
}
/**
* Example: Minimal `OAuthClientProvider` for the authorization-code flow.
* Client credentials are stored per authorization-server `issuer` (SEP-2352).
*/
function auth_oauthClientProvider(onRedirect: (url: URL) => void) {
//#region auth_oauthClientProvider
class MyOAuthProvider implements OAuthClientProvider {
// Key DCR-obtained credentials by issuer so a client_id registered with one
// authorization server is never returned for another (SEP-2352).
private creds = new Map<string, OAuthClientInformationMixed>();
private storedTokens?: OAuthTokens;
private verifier?: string;
private discovery?: OAuthDiscoveryState;
lastState?: string;
readonly redirectUrl = 'http://localhost:8090/callback';
readonly clientMetadata: OAuthClientMetadata = {
client_name: 'My MCP Client',
redirect_uris: ['http://localhost:8090/callback'],
// Loopback redirect → the SDK would default this to 'native'; set
// explicitly when the heuristic is wrong for your deployment (SEP-837).
application_type: 'native'
};
clientInformation(ctx?: OAuthClientInformationContext) {
return ctx ? this.creds.get(ctx.issuer) : undefined;
}
saveClientInformation(info: OAuthClientInformationMixed, ctx?: OAuthClientInformationContext) {
if (ctx) this.creds.set(ctx.issuer, info);
}
tokens() {
return this.storedTokens;
}
saveTokens(tokens: OAuthTokens) {
// In production, persist to OS keychain / secure storage — never plain files.
this.storedTokens = tokens;
}
// CSRF binding for the redirect — the SDK puts this on the authorize URL;
// your callback handler compares it before calling `finishAuth`.
state() {
this.lastState = crypto.randomUUID();
return this.lastState;
}
// Callback-leg AS-binding (SEP-2352): record what discovery resolved before
// the redirect so the SDK can verify the code is exchanged at the same AS.
saveDiscoveryState(state: OAuthDiscoveryState) {
this.discovery = state;
}
discoveryState() {
return this.discovery;
}
redirectToAuthorization(url: URL) {
onRedirect(url);
}
saveCodeVerifier(v: string) {
this.verifier = v;
}
codeVerifier() {
if (!this.verifier) throw new Error('no code verifier');
return this.verifier;
}
}
const provider = new MyOAuthProvider();
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), {
authProvider: provider
});
//#endregion auth_oauthClientProvider
//#region auth_validateResourceURL
class PinnedResourceProvider extends MyOAuthProvider {
async validateResourceURL(serverUrl: string | URL, resource?: string): Promise<URL | undefined> {
const expected = resourceUrlFromServerUrl(serverUrl); // strips the fragment (RFC 8707 §2)
if (resource && !checkResourceAllowed({ requestedResource: expected, configuredResource: resource })) {
throw new Error(`Refusing resource ${resource} for server ${expected.href}`);
}
return expected;
}
}
//#endregion auth_validateResourceURL
void PinnedResourceProvider;
return { provider, transport };
}
/** Example: Handling the OAuth callback — extract `iss` for RFC 9207 validation. */
async function auth_finishAuth(url: URL, provider: OAuthClientProvider & { lastState?: string }, waitForCallback: () => Promise<string>) {
//#region auth_finishAuth
const client = new Client({ name: 'my-client', version: '1.0.0' });
const transport = new StreamableHTTPClientTransport(url, { authProvider: provider });
try {
await client.connect(transport);
return client;
} catch (error) {
// With version negotiation, the connect-time 401 may surface wrapped as
// SdkError(EraNegotiationFailed) whose .data.cause is the UnauthorizedError.
const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause;
if (!(root instanceof UnauthorizedError)) throw error;
// The transport called redirectToAuthorization(); fall through to the browser callback.
}
const callbackUrl = await waitForCallback();
const params = new URL(callbackUrl).searchParams;
// The SDK does not validate `state` — compare it to the value your provider generated.
if (params.get('state') !== provider.lastState) throw new Error('state mismatch');
try {
// Preferred: hand over the whole query — the SDK extracts `code` and
// `iss`, validates `iss` (RFC 9207), and never surfaces callback-derived
// `error`/`error_description` text on mismatch.
await transport.finishAuth(params);
} catch (error) {
if (error instanceof IssuerMismatchError) {
// Mix-up attack: do NOT render params.get('error_description') to the user.
throw new Error('Authorization failed: issuer mismatch');
}
throw error;
}
// Reconnect on a FRESH transport — a started transport cannot be restarted;
// OAuth state (tokens, verifier, discovery) lives on the provider, not the transport.
await client.connect(new StreamableHTTPClientTransport(url, { authProvider: provider }));
return client;
//#endregion auth_finishAuth
}
// ---------------------------------------------------------------------------
// Using server features
// ---------------------------------------------------------------------------
/** Example: List and call tools. */
async function callTool_basic(client: Client) {
//#region callTool_basic
const { tools } = await client.listTools();
console.log(
'Available tools:',
tools.map(t => t.name)
);
const result = await client.callTool({
name: 'calculate-bmi',
arguments: { weightKg: 70, heightM: 1.75 }
});
console.log(result.content);
//#endregion callTool_basic
}
/** Example: Structured tool output. */
async function callTool_structuredOutput(client: Client) {
//#region callTool_structuredOutput
const result = await client.callTool({
name: 'calculate-bmi',
arguments: { weightKg: 70, heightM: 1.75 }
});
// Machine-readable output for the client application. SEP-2106: structuredContent is
// `unknown` (any JSON value). Check for presence with `!== undefined` and narrow before use.
if (result.structuredContent !== undefined) {
const sc: unknown = result.structuredContent; // e.g. { bmi: 22.86 }
if (typeof sc === 'object' && sc !== null && 'bmi' in sc) {
console.log(sc.bmi);
}
}
//#endregion callTool_structuredOutput
}
/** Example: Track progress of a long-running tool call. */
async function callTool_progress(client: Client) {
//#region callTool_progress
const result = await client.callTool(
{ name: 'long-operation', arguments: {} },
{
onprogress: ({ progress, total }: { progress: number; total?: number }) => {
console.log(`Progress: ${progress}/${total ?? '?'}`);
},
resetTimeoutOnProgress: true,
maxTotalTimeout: 600_000
}
);
console.log(result.content);
//#endregion callTool_progress
}
/** Example: List and read resources. */
async function readResource_basic(client: Client) {
//#region readResource_basic
const { resources } = await client.listResources();
console.log(
'Available resources:',
resources.map(r => r.name)
);
const { contents } = await client.readResource({ uri: 'config://app' });
for (const item of contents) {
console.log(item);
}
//#endregion readResource_basic
}
/** Example: Subscribe to resource changes. */
async function subscribeResource_basic(client: Client) {
//#region subscribeResource_basic
await client.subscribeResource({ uri: 'config://app' });
client.setNotificationHandler('notifications/resources/updated', async notification => {
if (notification.params.uri === 'config://app') {
const { contents } = await client.readResource({ uri: 'config://app' });
console.log('Config updated:', contents);
}
});
// Later: stop receiving updates
await client.unsubscribeResource({ uri: 'config://app' });
//#endregion subscribeResource_basic
}
/** Example: List and get prompts. */
async function getPrompt_basic(client: Client) {
//#region getPrompt_basic
const { prompts } = await client.listPrompts();
console.log(
'Available prompts:',
prompts.map(p => p.name)
);
const { messages } = await client.getPrompt({
name: 'review-code',
arguments: { code: 'console.log("hello")' }
});
console.log(messages);
//#endregion getPrompt_basic
}
/** Example: Request argument completions. */
async function complete_basic(client: Client) {
//#region complete_basic
const { completion } = await client.complete({
ref: {
type: 'ref/prompt',
name: 'review-code'
},
argument: {
name: 'language',
value: 'type'
}
});
console.log(completion.values); // e.g. ['typescript']
//#endregion complete_basic
}
// ---------------------------------------------------------------------------
// Response caching
// ---------------------------------------------------------------------------
/** Example: Per-call cache disposition via cacheMode. */
async function responseCache_basic(client: Client) {
//#region responseCache_basic
const tools = await client.listTools(); // network, then cached for the server's ttlMs
const again = await client.listTools(); // served from cache while still fresh
await client.listTools(undefined, { cacheMode: 'refresh' }); // always refetch and re-store
await client.readResource({ uri: 'config://app' }, { cacheMode: 'bypass' }); // no cache read or write
//#endregion responseCache_basic
void tools;
void again;
}
// ---------------------------------------------------------------------------
// Notifications
// ---------------------------------------------------------------------------
/** Example: Handle log messages and list-change notifications. */
function notificationHandler_basic(client: Client) {
//#region notificationHandler_basic
// Server log messages (sent by the server during request processing)
client.setNotificationHandler('notifications/message', notification => {
const { level, data } = notification.params;
console.log(`[${level}]`, data);
});
// Server's resource list changed — re-fetch the list
client.setNotificationHandler('notifications/resources/list_changed', async () => {
const { resources } = await client.listResources();
console.log('Resources changed:', resources.length);
});
//#endregion notificationHandler_basic
}
/** Example: Control server log level. */
async function setLoggingLevel_basic(client: Client) {
//#region setLoggingLevel_basic
await client.setLoggingLevel('warning');
//#endregion setLoggingLevel_basic
}
/** Example: Per-request log-level opt-in on a 2026-07-28 connection. */
async function logLevelMeta_modern(client: Client) {
//#region logLevelMeta_modern
const result = await client.callTool({
name: 'fetch-data',
arguments: { url: 'https://example.com' },
_meta: { [LOG_LEVEL_META_KEY]: 'debug' }
});
//#endregion logLevelMeta_modern
void result;
}
/** Example: Automatic list-change tracking via the listChanged option. */
async function listChanged_basic() {
//#region listChanged_basic
const client = new Client(
{ name: 'my-client', version: '1.0.0' },
{
listChanged: {
tools: {
onChanged: (error, tools) => {
if (error) {
console.error('Failed to refresh tools:', error);
return;
}
console.log('Tools updated:', tools);
}
},
prompts: {
onChanged: (error, prompts) => console.log('Prompts updated:', prompts)
}
}
}
);
//#endregion listChanged_basic
return client;
}
/** Example: Open a subscriptions/listen stream explicitly (2026-07-28). */
async function listen_basic(client: Client) {
//#region listen_basic
client.setNotificationHandler('notifications/tools/list_changed', async () => {
const { tools } = await client.listTools();
console.log('Tools changed:', tools.length);
});
client.setNotificationHandler('notifications/resources/updated', async notification => {
console.log('Resource updated:', notification.params.uri);
});
const subscription = await client.listen({
toolsListChanged: true,
resourceSubscriptions: ['config://app']
});
console.log('Server honored:', subscription.honoredFilter);
// Later: tear the stream down
await subscription.close();
//#endregion listen_basic
}
/** Example: Watch loop that re-listens on unexpected closes. */
async function listen_watchLoop(client: Client, watching: boolean) {
//#region listen_watchLoop
while (watching) {
const sub = await client.listen({ resourceSubscriptions: ['config://app'] });
const reason = await sub.closed;
if (reason !== 'remote') break; // 'local' or 'graceful': done
await new Promise(resolve => setTimeout(resolve, 1000)); // back off, then re-listen
}
//#endregion listen_watchLoop
}
// ---------------------------------------------------------------------------
// Handling server-initiated requests
// ---------------------------------------------------------------------------
/** Example: Declare client capabilities for sampling, elicitation, and roots. */
function capabilities_declaration() {
//#region capabilities_declaration
const client = new Client(
{ name: 'my-client', version: '1.0.0' },
{
capabilities: {
sampling: {},
elicitation: { form: {} },
roots: { listChanged: true }
}
}
);
//#endregion capabilities_declaration
return client;
}
/** Example: Handle a sampling request from the server. */
function sampling_handler(client: Client) {
//#region sampling_handler
client.setRequestHandler('sampling/createMessage', async request => {
const lastMessage = request.params.messages.at(-1);
console.log('Sampling request:', lastMessage);
// In production, send messages to your LLM here
return {
model: 'my-model',
role: 'assistant' as const,
content: {
type: 'text' as const,
text: 'Response from the model'
}
};
});
//#endregion sampling_handler
}
/** Example: Handle an elicitation request from the server. */
function elicitation_handler(client: Client) {
//#region elicitation_handler
client.setRequestHandler('elicitation/create', async request => {
console.log('Server asks:', request.params.message);
if (request.params.mode === 'form') {
// Present the schema-driven form to the user
console.log('Schema:', request.params.requestedSchema);
return { action: 'accept', content: { confirm: true } };
}
return { action: 'decline' };
});
//#endregion elicitation_handler
}
/** Example: Expose filesystem roots to the server. */
function roots_handler(client: Client) {
//#region roots_handler
client.setRequestHandler('roots/list', async () => {
return {
roots: [
{ uri: 'file:///home/user/projects/my-app', name: 'My App' },
{ uri: 'file:///home/user/data', name: 'Data' }
]
};
});
//#endregion roots_handler
}
/** Example: Manual multi-round-trip handling with autoFulfill disabled (2026-07-28). */
async function inputRequired_manual(transport: StreamableHTTPClientTransport) {
//#region inputRequired_manual
const client = new Client(
{ name: 'my-client', version: '1.0.0' },
{
capabilities: { elicitation: { form: {} } },
versionNegotiation: { mode: 'auto' },
inputRequired: { autoFulfill: false }
}
);
await client.connect(transport);
const value = (await client.request(
{ method: 'tools/call', params: { name: 'deploy', arguments: { env: 'prod' } } },
{ allowInputRequired: true }
)) as CallToolResult | InputRequiredResult;
if (isInputRequiredResult(value)) {
// Collect responses for value.inputRequests from your UI, then retry:
await client.request(
{
method: 'tools/call',
params: {
name: 'deploy',
arguments: { env: 'prod' },
inputResponses: { confirm: { action: 'accept', content: { confirm: true } } },
requestState: value.requestState // echo byte-exact
}
},
{ allowInputRequired: true }
);
}
//#endregion inputRequired_manual
}
// ---------------------------------------------------------------------------
// Error handling
// ---------------------------------------------------------------------------
/** Example: Tool errors vs protocol errors. */
async function errorHandling_toolErrors(client: Client) {
//#region errorHandling_toolErrors
try {
const result = await client.callTool({
name: 'fetch-data',
arguments: { url: 'https://example.com' }
});
// Tool-level error: the tool ran but reported a problem
if (result.isError) {
console.error('Tool error:', result.content);
return;
}
console.log('Success:', result.content);
} catch (error) {
// Protocol-level error: the request itself failed
if (error instanceof ProtocolError) {
console.error(`Protocol error ${error.code}: ${error.message}`);
} else if (error instanceof SdkError) {
console.error(`SDK error [${error.code}]: ${error.message}`);
} else {
throw error;
}
}
//#endregion errorHandling_toolErrors
}
/** Example: Connection lifecycle callbacks. */
function errorHandling_lifecycle(client: Client) {
//#region errorHandling_lifecycle
// Out-of-band errors (SSE disconnects, parse errors)
client.onerror = error => {
console.error('Transport error:', error.message);
};
// Connection closed (pending requests are rejected with CONNECTION_CLOSED)
client.onclose = () => {
console.log('Connection closed');
};
//#endregion errorHandling_lifecycle
}
/** Example: Custom timeouts. */
async function errorHandling_timeout(client: Client) {
//#region errorHandling_timeout
try {
const result = await client.callTool(
{ name: 'slow-operation', arguments: {} },
{ timeout: 120_000 } // 2 minutes instead of the default 60 seconds
);
console.log(result.content);
} catch (error) {
if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) {
console.error('Request timed out');
}
}
//#endregion errorHandling_timeout
}
/** Example: Typed HTTP transport errors. */
async function errorHandling_http(client: Client, transport: StreamableHTTPClientTransport) {
//#region errorHandling_http
try {
await client.connect(transport);
} catch (error) {
if (error instanceof SdkHttpError) {
console.error(`HTTP ${error.status} (${error.statusText ?? ''}) [${error.code}]`);
} else {
throw error;
}
}
//#endregion errorHandling_http
}
// ---------------------------------------------------------------------------
// Advanced patterns
// ---------------------------------------------------------------------------
/** Example: Client middleware that adds a custom header. */
async function middleware_basic() {
//#region middleware_basic
const authMiddleware = createMiddleware(async (next, input, init) => {
const headers = new Headers(init?.headers);
headers.set('X-Custom-Header', 'my-value');
return next(input, { ...init, headers });
});
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), {
fetch: applyMiddlewares(authMiddleware)(fetch)
});
//#endregion middleware_basic
return transport;
}
/** Example: Attach W3C Trace Context to a single request via `_meta`. */
async function traceContext_perRequest(client: Client) {
//#region traceContext_perRequest
// Values would normally come from your tracer's active span context.
const result = await client.callTool({
name: 'calculate-bmi',
arguments: { weightKg: 70, heightM: 1.75 },
_meta: {
[TRACEPARENT_META_KEY]: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01',
[TRACESTATE_META_KEY]: 'vendor1=opaqueValue1'
}
});
console.log(result.content);
//#endregion traceContext_perRequest
}
/** Example: Client middleware that injects trace context into every outgoing request. */
async function traceContext_middleware() {
//#region traceContext_middleware
const traceContextMiddleware = createMiddleware(async (next, input, init) => {
if (typeof init?.body !== 'string') {
return next(input, init);
}
const message = JSON.parse(init.body) as {
method?: string;
params?: { _meta?: Record<string, unknown>; [key: string]: unknown };
};
// Only requests and notifications carry params._meta; skip responses.
if (message.method === undefined) {
return next(input, init);
}
message.params = {
...message.params,
_meta: {
...message.params?._meta,
// Replace with values from your tracer's active span context.
[TRACEPARENT_META_KEY]: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'
}
};
return next(input, { ...init, body: JSON.stringify(message) });
});
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), {
fetch: applyMiddlewares(traceContextMiddleware)(fetch)
});
//#endregion traceContext_middleware
return transport;
}
/** Example: Track resumption tokens for SSE reconnection. */
async function resumptionToken_basic(client: Client) {
//#region resumptionToken_basic
let lastToken: string | undefined;
const result = await client.request(
{
method: 'tools/call',
params: { name: 'long-running-operation', arguments: {} }
},
{
resumptionToken: lastToken,
onresumptiontoken: (token: string) => {
lastToken = token;
// Persist token to survive restarts
}
}
);
console.log(result);
//#endregion resumptionToken_basic
}
// Suppress unused-function warnings (functions exist solely for type-checking)
void connect_streamableHttp;
void connect_stdio;
void connect_sseFallback;
void Client_versionNegotiation;
void disconnect_streamableHttp;
void serverInstructions_basic;
void extensionCapabilities_read;
void auth_tokenProvider;
void auth_clientCredentials;
void auth_privateKeyJwt;
void auth_crossAppAccess;
void callTool_basic;
void callTool_structuredOutput;
void callTool_progress;
void readResource_basic;
void subscribeResource_basic;
void getPrompt_basic;
void complete_basic;
void responseCache_basic;
void notificationHandler_basic;
void setLoggingLevel_basic;
void logLevelMeta_modern;
void listChanged_basic;
void listen_basic;
void listen_watchLoop;
void capabilities_declaration;
void sampling_handler;
void elicitation_handler;
void roots_handler;
void inputRequired_manual;
void errorHandling_toolErrors;
void errorHandling_lifecycle;
void errorHandling_timeout;
void errorHandling_http;
void middleware_basic;
void traceContext_perRequest;
void traceContext_middleware;
void resumptionToken_basic;

View file

@ -0,0 +1,145 @@
/**
* Runnable, type-checked companion for `docs/clients/caching.md`.
*
* Each `//#region` block is synced byte-for-byte into that page's code fences by
* `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift).
*
* The page's main program runs for real: a hint-sending `McpServer` is served
* by `createMcpHandler` and the client's transport `fetch` is routed into
* `handler.fetch`, so a real 2026-07-28 Streamable HTTP exchange runs
* in-process without binding a port. The harness counts the JSON-RPC requests
* that actually reach the server; the counts the page quotes verbatim are
* whatever this file prints. It throws (non-zero exit) if a cache-served call
* reaches the server.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
* npx tsx guides/clients/caching.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
import type { ResponseCacheStore } from '@modelcontextprotocol/client';
import { Client, InMemoryResponseCacheStore, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
// ---------------------------------------------------------------------------
// ## Have the server send the hint
// The factory body carries the page's server-side region. `createMcpHandler`
// (the harness) serves it; the tool and resource give the cache something to
// hold.
// ---------------------------------------------------------------------------
const handler = createMcpHandler(() => {
//#region cacheHints_server
const server = new McpServer(
{ name: 'catalog', version: '1.0.0' },
{
cacheHints: {
'tools/list': { ttlMs: 60_000, cacheScope: 'public' },
'resources/read': { ttlMs: 5_000, cacheScope: 'private' }
}
}
);
//#endregion cacheHints_server
server.registerTool('search', { description: 'Search the product catalog' }, async () => ({
content: [{ type: 'text', text: 'Espresso cup\nTravel mug\nMug rack' }]
}));
server.registerResource('app-config', 'config://app', { mimeType: 'application/json' }, async uri => ({
contents: [{ uri: uri.href, mimeType: 'application/json', text: '{"theme":"dark"}' }]
}));
return server;
});
// ---------------------------------------------------------------------------
// Harness (not shown on the page). The transport's `fetch` is routed into
// `handler.fetch` — [Test a server](docs/testing.md) wiring — and counts
// every JSON-RPC request that reaches the server.
// ---------------------------------------------------------------------------
const reached = new Map<string, number>();
const transport = new StreamableHTTPClientTransport(new URL('http://caching.example/mcp'), {
fetch: (url, init) => {
if (typeof init?.body === 'string') {
const message = JSON.parse(init.body) as { method?: string };
if (typeof message.method === 'string') reached.set(message.method, (reached.get(message.method) ?? 0) + 1);
}
return handler.fetch(new Request(url, init));
}
});
const client = new Client(
{ name: 'caching-docs-harness', version: '1.0.0' },
// Cache hints ride the 2026-07-28 revision — see docs/protocol-versions.md.
{ versionNegotiation: { mode: 'auto' } }
);
await client.connect(transport);
// ## Let the cache work — the calls whose request counts the page quotes.
//#region responseCache_use
const tools = await client.listTools(); // network, then cached for the server's ttlMs
const again = await client.listTools(); // served from cache while still fresh
await client.listTools(undefined, { cacheMode: 'refresh' }); // always refetch and re-store
await client.readResource({ uri: 'config://app' }, { cacheMode: 'bypass' }); // no cache read or write
//#endregion responseCache_use
console.log('tools/list requests that reached the server:', reached.get('tools/list'));
console.log('resources/read requests that reached the server:', reached.get('resources/read'));
// Self-verification: the page claims the second listTools() made no round trip
// (two tools/list requests total: the first call and the 'refresh') and that
// the cached result carries the same tools.
if (reached.get('tools/list') !== 2 || reached.get('resources/read') !== 1) {
throw new Error(`caching.md claim failed: tools/list=${reached.get('tools/list')}, resources/read=${reached.get('resources/read')}`);
}
if (again.tools.map(tool => tool.name).join() !== tools.tools.map(tool => tool.name).join()) {
throw new Error('caching.md claim failed: cached listTools() differs from the first result');
}
await client.close();
await handler.close();
// ---------------------------------------------------------------------------
// The remaining regions configure a Client; they typecheck but never connect
// (each would need its own server), so they live in wrapper functions that are
// never called.
// ---------------------------------------------------------------------------
// ## Bring your own store
function responseCacheStore_shared() {
//#region responseCacheStore_shared
const store = new InMemoryResponseCacheStore({ maxEntries: 2048 });
const client = new Client({ name: 'my-client', version: '1.0.0' }, { responseCacheStore: store });
//#endregion responseCacheStore_shared
return client;
}
// ## Partition the store per user
function cachePartition_perUser(sharedStore: ResponseCacheStore, userId: string) {
//#region cachePartition_perUser
const client = new Client({ name: 'gateway', version: '1.0.0' }, { responseCacheStore: sharedStore, cachePartition: userId });
//#endregion cachePartition_perUser
return client;
}
// ## Cache against servers that send no hints
function defaultCacheTtlMs_optIn() {
//#region defaultCacheTtlMs_optIn
const client = new Client({ name: 'my-client', version: '1.0.0' }, { defaultCacheTtlMs: 60_000 });
//#endregion defaultCacheTtlMs_optIn
return client;
}
void responseCacheStore_shared;
void cachePartition_perUser;
void defaultCacheTtlMs_optIn;

View file

@ -0,0 +1,205 @@
/**
* Companion example for `docs/clients/calling.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 around the
* regions registers the in-memory `orders` server the page's client talks to
* and produces the output the page quotes verbatim.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
* npx tsx guides/clients/calling.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
import { completable, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
// ---------------------------------------------------------------------------
// Harness (not shown on the page). The page documents the CLIENT verbs; this
// `orders` server only exists so every block has something real to call and
// every quoted output is real. Any MCP server behaves the same.
// ---------------------------------------------------------------------------
const orders = [
{ id: 'A-1041', customer: 'Ada', items: 3, total: 61.5, currency: 'EUR', status: 'shipped' },
{ id: 'A-1042', customer: 'Lin', items: 1, total: 18, currency: 'EUR', status: 'open' }
];
const server = new McpServer({ name: 'orders', version: '1.0.0' });
server.registerTool(
'lookup-order',
{
description: 'Look up one order by its id',
inputSchema: z.object({ id: z.string().describe('Order id, e.g. A-1041') })
},
async ({ id }) => {
const order = orders.find(candidate => candidate.id === id);
if (!order) throw new Error(`No order ${id}`);
return { content: [{ type: 'text', text: `${order.id}: ${order.items} items, ${order.status}` }] };
}
);
server.registerTool(
'order-total',
{
description: 'Total of one order',
inputSchema: z.object({ id: z.string() }),
outputSchema: z.object({ id: z.string(), total: z.number(), currency: z.string() })
},
async ({ id }) => {
const order = orders.find(candidate => candidate.id === id);
if (!order) throw new Error(`No order ${id}`);
const output = { id: order.id, total: order.total, currency: order.currency };
return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output };
}
);
server.registerTool(
'export-orders',
{
description: 'Export every order to the given format',
inputSchema: z.object({ format: z.string() })
},
async ({ format }, ctx) => {
const progressToken = ctx.mcpReq._meta?.progressToken;
for (const [index, order] of orders.entries()) {
if (progressToken !== undefined) {
await ctx.mcpReq.notify({
method: 'notifications/progress',
params: { progressToken, progress: index + 1, total: orders.length, message: `exported ${order.id}` }
});
}
}
return { content: [{ type: 'text', text: `${orders.length} orders exported as ${format}` }] };
}
);
server.registerResource(
'recent-orders',
'orders://recent',
{ description: 'Ids of the most recent orders', mimeType: 'application/json' },
async uri => ({
contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(orders.map(order => order.id)) }]
})
);
server.registerPrompt(
'summarize-order',
{
description: 'Write a status update for one order',
argsSchema: z.object({
id: z.string().describe('Order id'),
tone: completable(z.string().describe('Writing tone'), value =>
['formal', 'friendly', 'terse'].filter(tone => tone.startsWith(value))
)
})
},
({ id, tone }) => ({
messages: [
{
role: 'user' as const,
content: { type: 'text' as const, text: `Write a ${tone} status update for order ${id}.` }
}
]
})
);
const client = new Client({ name: 'orders-cli', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
// "List the tools and call one" — the names and the content the page quotes.
//#region listTools_callTool
const { tools } = await client.listTools();
console.log(tools.map(tool => tool.name));
const result = await client.callTool({ name: 'lookup-order', arguments: { id: 'A-1041' } });
console.log(result.content);
//#endregion listTools_callTool
// ---------------------------------------------------------------------------
// "Let the SDK walk the pages". `McpServer` answers `tools/list` in one page,
// so the harness swaps in a handler that serves the SAME three definitions two
// per page — the aggregate walk and the per-page call below are both real.
// ---------------------------------------------------------------------------
server.server.setRequestHandler('tools/list', async request =>
request.params?.cursor === undefined ? { tools: tools.slice(0, 2), nextCursor: 'page-2' } : { tools: tools.slice(2) }
);
// Proof for the page's aggregate claim: against the now-paginating server,
// the no-cursor call still returns every tool, with `nextCursor` cleared.
// Throws (non-zero exit) if the claim is false.
const walked = await client.listTools();
if (walked.tools.length !== tools.length || walked.nextCursor !== undefined) {
throw new Error(`calling.md claim failed: aggregated walk returned ${JSON.stringify(walked)}`);
}
// The cursor an earlier page of this server handed back in `nextCursor`.
const heldCursor = 'page-2';
// "Let the SDK walk the pages" — one raw page, the output the page quotes.
//#region listTools_onePage
const page = await client.listTools({ cursor: heldCursor });
console.log(
page.tools.map(tool => tool.name),
page.nextCursor
);
//#endregion listTools_onePage
// "Read structured output" — the narrowed `structuredContent` the page quotes.
//#region callTool_structured
const details = await client.callTool({ name: 'order-total', arguments: { id: 'A-1041' } });
const total: unknown = details.structuredContent;
if (typeof total === 'object' && total !== null && 'currency' in total) {
console.log(total);
}
//#endregion callTool_structured
// "Read a resource" — the uri list and the contents the page quotes.
//#region readResource_basic
const { resources } = await client.listResources();
console.log(resources.map(resource => resource.uri));
const { contents } = await client.readResource({ uri: 'orders://recent' });
console.log(contents[0]);
//#endregion readResource_basic
// "Get a prompt" — the prompt names and the filled-in messages the page quotes.
//#region getPrompt_basic
const { prompts } = await client.listPrompts();
console.log(prompts.map(prompt => prompt.name));
const prompt = await client.getPrompt({ name: 'summarize-order', arguments: { id: 'A-1041', tone: 'terse' } });
console.log(prompt.messages);
//#endregion getPrompt_basic
// "Autocomplete an argument" — the suggestions the page quotes.
//#region complete_tone
const { completion } = await client.complete({
ref: { type: 'ref/prompt', name: 'summarize-order' },
argument: { name: 'tone', value: 'f' }
});
console.log(completion.values);
//#endregion complete_tone
// "Track progress on a long call" — the progress updates and the final result.
//#region callTool_progress
const exported = await client.callTool(
{ name: 'export-orders', arguments: { format: 'csv' } },
{
onprogress: update => console.log(update),
resetTimeoutOnProgress: true,
maxTotalTimeout: 600_000
}
);
console.log(exported.content);
//#endregion callTool_progress
await client.close();
await server.close();

View file

@ -0,0 +1,116 @@
/**
* Runnable, type-checked companion for `docs/clients/connect.md`.
*
* Each `//#region` block is synced byte-for-byte into that page's code fences by
* `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift).
*
* The page's main program — connect over Streamable HTTP, read what the server
* told you, close — runs for real: the harness below builds a `createMcpHandler`
* server and routes `globalThis.fetch` for `http://localhost:3000/mcp` into
* `handler.fetch`, so the HTTP regions execute in-process without binding a
* port. The output the page quotes verbatim is whatever this file prints.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
* npx tsx guides/clients/connect.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
import { SSEClientTransport } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
// ---------------------------------------------------------------------------
// Harness (not shown on the page). A `createMcpHandler` server answers the
// page's `http://localhost:3000/mcp` URL in-process: `globalThis.fetch` for
// that host is routed into `handler.fetch`, so the page's HTTP regions run
// verbatim against a real Streamable HTTP server without binding a port.
// ---------------------------------------------------------------------------
const handler = createMcpHandler(() => {
const server = new McpServer(
{ name: 'travel', version: '2.1.0' },
{ instructions: 'Call list-trips before book-trip. Dates are ISO 8601.' }
);
server.registerTool(
'list-trips',
{ description: 'List the trips on file', inputSchema: z.object({ year: z.number().int() }) },
async ({ year }) => ({ content: [{ type: 'text', text: `No trips in ${year}` }] })
);
return server;
});
const realFetch = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const request = new Request(input, init);
if (new URL(request.url).host === 'localhost:3000') return handler.fetch(request);
return realFetch(input, init);
}) as typeof fetch;
// ## Create a client and connect over HTTP
//#region connect_streamableHttp
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
const client = new Client({ name: 'my-client', version: '1.0.0' });
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'));
await client.connect(transport);
//#endregion connect_streamableHttp
// ## Read what the server told you at connect time
//#region connect_introspect
console.log(client.getServerVersion());
console.log(client.getServerCapabilities());
console.log(client.getInstructions());
//#endregion connect_introspect
// ## Disconnect cleanly
//#region connect_close
await transport.terminateSession();
await client.close();
//#endregion connect_close
await handler.close();
globalThis.fetch = realFetch;
// ---------------------------------------------------------------------------
// The remaining transports cannot run inside this self-terminating harness —
// stdio spawns a process, the SSE fallback needs a legacy server — so their
// regions live in wrapper functions that typecheck but are never called.
// ---------------------------------------------------------------------------
// ## Connect to a local process over stdio
async function connect_stdio() {
//#region connect_stdio
const client = new Client({ name: 'my-client', version: '1.0.0' });
const transport = new StdioClientTransport({ command: 'node', args: ['server.js'] });
await client.connect(transport);
//#endregion connect_stdio
}
// ## Fall back to SSE for legacy servers
async function connect_sseFallback(url: string) {
//#region connect_sseFallback
try {
const client = new Client({ name: 'my-client', version: '1.0.0' });
await client.connect(new StreamableHTTPClientTransport(new URL(url)));
return client;
} catch {
const client = new Client({ name: 'my-client', version: '1.0.0' });
await client.connect(new SSEClientTransport(new URL(url)));
return client;
}
//#endregion connect_sseFallback
}
void connect_stdio;
void connect_sseFallback;

View file

@ -0,0 +1,89 @@
// docs: typecheck-only
/**
* Type-checked companion for `docs/clients/machine-auth.md`.
*
* Each `//#region` block is synced byte-for-byte into that page's `ts` fences by
* `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). Every flow
* on the page needs a live authorization server, so there is nothing meaningful
* to run — the regions only typecheck.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
*
* @module
*/
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { AuthProvider } from '@modelcontextprotocol/client';
import { CrossAppAccessProvider, discoverAndRequestJwtAuthGrant, PrivateKeyJwtProvider } from '@modelcontextprotocol/client';
// "Authenticate with client credentials" — the page's lead block. The import line
// is part of the region so the first fence on the page names where the providers
// come from.
//#region clientCredentials_connect
import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
const authProvider = new ClientCredentialsProvider({
clientId: 'reporting-job',
clientSecret: 'reporting-job-secret'
});
const client = new Client({ name: 'reporting-job', version: '1.0.0' });
const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider });
await client.connect(transport);
//#endregion clientCredentials_connect
// "Bring your own bearer token" — the minimal AuthProvider shape: token() only.
function bearerToken_provider(getStoredToken: () => Promise<string>) {
//#region bearerToken_provider
const authProvider: AuthProvider = { token: async () => getStoredToken() };
const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider });
//#endregion bearerToken_provider
return transport;
}
// "Sign with a private key instead of a secret" — private_key_jwt token-endpoint auth.
function privateKeyJwt_provider(pemEncodedKey: string) {
//#region privateKeyJwt_provider
const authProvider = new PrivateKeyJwtProvider({
clientId: 'reporting-job',
privateKey: pemEncodedKey,
algorithm: 'RS256'
});
const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider });
//#endregion privateKeyJwt_provider
return transport;
}
// "Act for an enterprise user with cross-app access" — SEP-990 / Enterprise
// Managed Authorization. The assertion callback turns the IdP ID Token into a
// JWT Authorization Grant; the provider exchanges that for the access token.
function crossAppAccess_provider(getIdToken: () => Promise<string>) {
//#region crossAppAccess_provider
const authProvider = new CrossAppAccessProvider({
assertion: async ctx => {
const grant = await discoverAndRequestJwtAuthGrant({
idpUrl: 'https://idp.example.com',
audience: ctx.authorizationServerUrl,
resource: ctx.resourceUrl,
idToken: await getIdToken(),
clientId: 'idp-exchange-client',
clientSecret: 'idp-exchange-secret',
scope: ctx.scope,
fetchFn: ctx.fetchFn
});
return grant.jwtAuthGrant;
},
clientId: 'reporting-job',
clientSecret: 'reporting-job-secret'
});
const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider });
//#endregion crossAppAccess_provider
return transport;
}
void bearerToken_provider;
void privateKeyJwt_provider;
void crossAppAccess_provider;

View file

@ -0,0 +1,136 @@
/**
* Companion example for `docs/clients/middleware.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 at the
* bottom routes every middleware stack into an in-process `createMcpHandler`
* — no port, no socket — and produces the output the page quotes verbatim.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
* npx tsx guides/clients/middleware.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
import type { OAuthClientProvider } from '@modelcontextprotocol/client';
import { Client, withLogging, withOAuth } from '@modelcontextprotocol/client';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
// ---------------------------------------------------------------------------
// "Write a middleware"
// ---------------------------------------------------------------------------
//#region middleware_create
import { applyMiddlewares, createMiddleware, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
const tagRequests = createMiddleware(async (next, input, init) => {
const headers = new Headers(init?.headers);
headers.set('X-Request-Source', 'reports-cli');
return next(input, { ...init, headers });
});
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), {
fetch: applyMiddlewares(tagRequests)(fetch)
});
//#endregion middleware_create
void transport;
// ---------------------------------------------------------------------------
// "Compose several middlewares" — the stub base fetch makes the order
// observable without any network.
// ---------------------------------------------------------------------------
//#region middleware_order
const stamp = (name: string) =>
createMiddleware(async (next, input, init) => {
console.log(`-> ${name}`);
const response = await next(input, init);
console.log(`<- ${name}`);
return response;
});
const base = async () => new Response('ok');
await applyMiddlewares(stamp('retry'), stamp('auth'), stamp('trace'))(base)('http://localhost:3000/mcp');
//#endregion middleware_order
// ---------------------------------------------------------------------------
// "Use the built-in logging middleware"
// ---------------------------------------------------------------------------
//#region middleware_logging
const loggedFetch = applyMiddlewares(tagRequests, withLogging())(fetch);
//#endregion middleware_logging
void loggedFetch;
// ---------------------------------------------------------------------------
// "Combine middleware with an auth provider" — never invoked: a working
// `OAuthClientProvider` is the reader's, not this program's (docs/clients/oauth.md).
// ---------------------------------------------------------------------------
/** Example: OAuth expressed as one layer of a middleware stack. */
function authenticatedTransport(provider: OAuthClientProvider) {
//#region middleware_withOAuth
const serverUrl = new URL('http://localhost:3000/mcp');
const authed = new StreamableHTTPClientTransport(serverUrl, {
fetch: applyMiddlewares(withOAuth(provider, serverUrl), withLogging({ statusLevel: 400 }))(fetch)
});
//#endregion middleware_withOAuth
return authed;
}
void authenticatedTransport;
// ---------------------------------------------------------------------------
// "Inspect the response"
// ---------------------------------------------------------------------------
//#region middleware_inspect
const observeStatus = createMiddleware(async (next, input, init) => {
const response = await next(input, init);
if (typeof init?.body === 'string') {
const { method } = JSON.parse(init.body) as { method?: string };
console.log(`${method ?? 'response'} -> HTTP ${response.status}`);
}
return response;
});
//#endregion middleware_inspect
// ---------------------------------------------------------------------------
// Harness (not shown on the page). An in-process `createMcpHandler` stands in
// for the network: `serverFetch` has the same shape as global `fetch`, so the
// exact middleware values defined in the regions above compose onto it
// unchanged. Each block connects a real Client over Streamable HTTP and calls
// one tool; the console output is what the page quotes verbatim.
// ---------------------------------------------------------------------------
const handler = createMcpHandler(() => {
const server = new McpServer({ name: 'reports', version: '1.0.0' });
server.registerTool('ping', { description: 'Reply with pong', inputSchema: z.object({ tag: z.string() }) }, async ({ tag }) => ({
content: [{ type: 'text', text: `pong ${tag}` }]
}));
return server;
});
type AnyFetch = (url: string | URL, init?: RequestInit) => Promise<Response>;
const serverFetch: AnyFetch = (url, init) => handler.fetch(new Request(url, init));
async function drive(fetchImpl: AnyFetch): Promise<void> {
const client = new Client({ name: 'middleware-docs-harness', version: '1.0.0' });
await client.connect(new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { fetch: fetchImpl }));
await client.callTool({ name: 'ping', arguments: { tag: 'docs' } });
await client.close();
}
// "Use the built-in logging middleware" — the lines the page quotes. The default
// logger derives each duration from `performance.now()`; pin it while this stack
// runs so the quoted output is reproducible byte for byte.
console.log('--- withLogging');
const realNow = performance.now.bind(performance);
performance.now = () => 0;
await drive(applyMiddlewares(tagRequests, withLogging())(serverFetch));
performance.now = realNow;
// "Inspect the response" — the method -> status lines the page quotes.
console.log('--- inspect');
await drive(applyMiddlewares(observeStatus)(serverFetch));
await handler.close();

View file

@ -0,0 +1,181 @@
// docs: typecheck-only
/**
* Type-checked companion for `docs/clients/oauth.md`.
*
* Each `//#region` block is synced byte-for-byte into that page's `ts` fences by
* `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). The page is
* the user-facing authorization-code flow — finishing it needs a browser and an
* authorization server, so nothing here runs in CI; the file only typechecks.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
*
* @module
*/
/* eslint-disable @typescript-eslint/no-unused-vars */
import type {
OAuthClientInformationContext,
OAuthClientInformationMixed,
OAuthClientMetadata,
OAuthClientProvider,
OAuthDiscoveryState,
OAuthTokens
} from '@modelcontextprotocol/client';
import {
checkResourceAllowed,
Client,
IssuerMismatchError,
resourceUrlFromServerUrl,
StreamableHTTPClientTransport,
UnauthorizedError
} from '@modelcontextprotocol/client';
// Stand-ins for the host application: how it opens a browser, and how its
// callback endpoint hands the redirect URL back to this code.
declare function onRedirect(url: URL): void;
declare function waitForCallback(): Promise<string>;
// ---------------------------------------------------------------------------
// "Implement OAuthClientProvider" — the page shows this class AFTER the
// transport that uses it; it lives first here so every region below can see it.
// ---------------------------------------------------------------------------
//#region MyOAuthProvider_class
class MyOAuthProvider implements OAuthClientProvider {
// Key DCR-obtained credentials by issuer so a client_id registered with one
// authorization server is never returned for another (SEP-2352).
private creds = new Map<string, OAuthClientInformationMixed>();
private storedTokens?: OAuthTokens;
private verifier?: string;
private discovery?: OAuthDiscoveryState;
lastState?: string;
readonly redirectUrl = 'http://localhost:8090/callback';
readonly clientMetadata: OAuthClientMetadata = {
client_name: 'My MCP Client',
redirect_uris: ['http://localhost:8090/callback'],
// Loopback redirect → the SDK would default this to 'native'; set
// explicitly when the heuristic is wrong for your deployment (SEP-837).
application_type: 'native'
};
clientInformation(ctx?: OAuthClientInformationContext) {
return ctx ? this.creds.get(ctx.issuer) : undefined;
}
saveClientInformation(info: OAuthClientInformationMixed, ctx?: OAuthClientInformationContext) {
if (ctx) this.creds.set(ctx.issuer, info);
}
tokens() {
return this.storedTokens;
}
saveTokens(tokens: OAuthTokens) {
// In production, persist to OS keychain / secure storage — never plain files.
this.storedTokens = tokens;
}
// CSRF binding for the redirect — the SDK puts this on the authorize URL;
// your callback handler compares it before calling `finishAuth`.
state() {
this.lastState = crypto.randomUUID();
return this.lastState;
}
// Callback-leg AS-binding (SEP-2352): record what discovery resolved before
// the redirect so the SDK can verify the code is exchanged at the same AS.
saveDiscoveryState(state: OAuthDiscoveryState) {
this.discovery = state;
}
discoveryState() {
return this.discovery;
}
redirectToAuthorization(url: URL) {
onRedirect(url);
}
saveCodeVerifier(v: string) {
this.verifier = v;
}
codeVerifier() {
if (!this.verifier) throw new Error('no code verifier');
return this.verifier;
}
}
//#endregion MyOAuthProvider_class
// ---------------------------------------------------------------------------
// "Hand the transport an OAuth provider"
// ---------------------------------------------------------------------------
/** Example: connect with an `authProvider`; an auth-gated server throws `UnauthorizedError`. */
async function authProvider_connect() {
//#region authProvider_connect
const provider = new MyOAuthProvider();
const client = new Client({ name: 'my-app', version: '1.0.0' });
const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), {
authProvider: provider
});
try {
await client.connect(transport);
} catch (error) {
if (!(error instanceof UnauthorizedError)) throw error;
// The transport already called provider.redirectToAuthorization(url):
// the end user is in the browser, at the authorization server.
}
//#endregion authProvider_connect
return { client, provider, transport };
}
// ---------------------------------------------------------------------------
// "Finish the flow from the callback"
// ---------------------------------------------------------------------------
/** Example: state check, code exchange, reconnect on a fresh transport. */
async function finishAuth_callback() {
const { client, provider, transport } = await authProvider_connect();
const url = new URL('https://api.example.com/mcp');
//#region finishAuth_callback
const callbackUrl = await waitForCallback(); // however your app receives the redirect
const params = new URL(callbackUrl).searchParams;
// The SDK does not validate `state` — compare it to the value your provider generated.
if (params.get('state') !== provider.lastState) throw new Error('state mismatch');
await transport.finishAuth(params);
// Reconnect on a FRESH transport — a started transport cannot be restarted.
// OAuth state (tokens, verifier, discovery) lives on the provider, not the transport.
await client.connect(new StreamableHTTPClientTransport(url, { authProvider: provider }));
//#endregion finishAuth_callback
}
// ---------------------------------------------------------------------------
// "Handle issuer mismatch"
// ---------------------------------------------------------------------------
/** Example: the RFC 9207 mix-up defense around `finishAuth`. */
async function finishAuth_issuerMismatch(transport: StreamableHTTPClientTransport, params: URLSearchParams) {
//#region finishAuth_issuerMismatch
try {
await transport.finishAuth(params);
} catch (error) {
if (error instanceof IssuerMismatchError) {
// Mix-up attack: never render params.get('error_description') to the user.
throw new Error('Authorization failed: issuer mismatch');
}
throw error;
}
//#endregion finishAuth_issuerMismatch
}
// ---------------------------------------------------------------------------
// "Pin the resource indicator"
// ---------------------------------------------------------------------------
//#region validateResourceURL_pin
class PinnedResourceProvider extends MyOAuthProvider {
async validateResourceURL(serverUrl: string | URL, resource?: string): Promise<URL | undefined> {
const expected = resourceUrlFromServerUrl(serverUrl); // strips the fragment (RFC 8707 §2)
if (resource && !checkResourceAllowed({ requestedResource: expected, configuredResource: resource })) {
throw new Error(`Refusing resource ${resource} for server ${expected.href}`);
}
return expected;
}
}
//#endregion validateResourceURL_pin

View file

@ -0,0 +1,67 @@
/**
* Companion example for `docs/clients/roots.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 below the
* regions connects an in-memory server that requests `roots/list` and produces
* the output the page quotes verbatim.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
* npx tsx guides/clients/roots.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
//#region roots_capability
import { Client } from '@modelcontextprotocol/client';
const client = new Client({ name: 'workspace-client', version: '1.0.0' }, { capabilities: { roots: { listChanged: true } } });
//#endregion roots_capability
//#region roots_listHandler
const roots = [
{ uri: 'file:///home/user/projects/my-app', name: 'My App' },
{ uri: 'file:///home/user/data', name: 'Data' }
];
client.setRequestHandler('roots/list', async () => {
return { roots };
});
//#endregion roots_listHandler
// ---------------------------------------------------------------------------
// Harness (not shown on the page). An in-memory low-level Server plays the
// counterpart: it requests `roots/list`, and requests it again when the client
// sends `notifications/roots/list_changed`. Any MCP server behaves the same.
// Imported dynamically so the page's lead region stays self-contained.
// ---------------------------------------------------------------------------
const { Server } = await import('@modelcontextprotocol/server');
const { InMemoryTransport } = await import('@modelcontextprotocol/client');
const server = new Server({ name: 'roots-docs-harness', version: '1.0.0' });
const relisted = new Promise<void>(resolve => {
server.setNotificationHandler('notifications/roots/list_changed', async () => {
console.log((await server.listRoots()).roots);
resolve();
});
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
// "Answer roots/list" — the list the page quotes.
console.log((await server.listRoots()).roots);
// "Tell the server when the roots change" — the notification triggers the
// harness's re-list above, whose output the page quotes.
//#region roots_listChanged
roots.push({ uri: 'file:///home/user/projects/another-app', name: 'Another app' });
await client.sendRootsListChanged();
//#endregion roots_listChanged
await relisted;
await client.close();
await server.close();

View file

@ -0,0 +1,176 @@
/**
* Companion example for `docs/clients/server-requests.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 below the
* regions connects this client over an in-memory transport pair to a server
* whose tools elicit input and request sampling, and produces the output the
* page quotes verbatim.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
* npx tsx guides/clients/server-requests.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
//#region Client_capabilities
import { Client } from '@modelcontextprotocol/client';
const client = new Client(
{ name: 'my-client', version: '1.0.0' },
{
capabilities: {
sampling: {},
elicitation: { form: {}, url: {} }
}
}
);
//#endregion Client_capabilities
// "Handle an elicitation request" — one handler, both modes.
//#region setRequestHandler_elicitation
client.setRequestHandler('elicitation/create', async request => {
if (request.params.mode === 'url') {
// Open request.params.url in the user's browser; answer when they finish.
return { action: 'accept' };
}
// Render request.params.requestedSchema as a form; return what the user entered.
return { action: 'accept', content: { city: 'Lisbon' } };
});
//#endregion setRequestHandler_elicitation
// "Handle a sampling request" — a canned model stands in for a real provider.
//#region setRequestHandler_sampling
client.setRequestHandler('sampling/createMessage', async request => {
const lastMessage = request.params.messages.at(-1);
console.log('Sampling request:', lastMessage?.content);
// In production, run the messages through your model here.
return {
model: 'host-model',
role: 'assistant',
content: { type: 'text', text: 'One travel mug to Lisbon.' }
};
});
//#endregion setRequestHandler_sampling
// "Cap or disable automatic fulfilment" — the same constructor with the
// `inputRequired` option added. Wrapped so the file keeps a single live client.
function Client_inputRequired() {
//#region Client_inputRequired
const client = new Client(
{ name: 'my-client', version: '1.0.0' },
{
capabilities: { sampling: {}, elicitation: { form: {}, url: {} } },
inputRequired: { maxRounds: 3 }
}
);
//#endregion Client_inputRequired
return client;
}
// ---------------------------------------------------------------------------
// Harness (not shown on the page). A server whose tools elicit input and
// request sampling drives the handlers above over an in-memory transport pair.
// Imported dynamically so the page's lead region stays self-contained.
// ---------------------------------------------------------------------------
const { getSupportedElicitationModes, InMemoryTransport } = await import('@modelcontextprotocol/client');
const { McpServer } = await import('@modelcontextprotocol/server');
const z = await import('zod/v4');
const server = new McpServer({ name: 'orders', version: '1.0.0' });
// Form-mode elicitation: docs/servers/elicitation.md owns this side.
server.registerTool(
'place-order',
{
description: 'Place an order after collecting a shipping city',
inputSchema: z.object({ item: z.string() })
},
async ({ item }, ctx) => {
const answer = await ctx.mcpReq.elicitInput({
mode: 'form',
message: `Where should we ship the ${item}?`,
requestedSchema: {
type: 'object',
properties: { city: { type: 'string', title: 'City' } },
required: ['city']
}
});
if (answer.action !== 'accept') {
return { content: [{ type: 'text', text: `Order ${answer.action}.` }] };
}
return { content: [{ type: 'text', text: `Order placed: ${item} ships to ${answer.content?.city}.` }] };
}
);
// URL-mode elicitation: exercises the handler's `url` branch.
server.registerTool(
'link-card',
{
description: 'Link a payment card through a hosted flow',
inputSchema: z.object({ provider: z.string() })
},
async ({ provider }, ctx) => {
const answer = await ctx.mcpReq.elicitInput({
mode: 'url',
message: `Sign in to ${provider} to link your card`,
url: `https://pay.example.com/link/${encodeURIComponent(provider)}`,
elicitationId: crypto.randomUUID()
});
return { content: [{ type: 'text', text: `Card link: ${answer.action}.` }] };
}
);
// Sampling: docs/servers/sampling.md owns this side.
server.registerTool(
'summarize-order',
{
description: 'Summarize the latest order with the host model',
inputSchema: z.object({ order: z.string() })
},
async ({ order }, ctx) => {
const response = await ctx.mcpReq.requestSampling({
messages: [{ role: 'user', content: { type: 'text', text: `Summarize this order: ${order}` } }],
maxTokens: 200
});
const block = Array.isArray(response.content) ? response.content[0] : response.content;
const summary = block?.type === 'text' ? block.text : '(non-text)';
return { content: [{ type: 'text', text: `${response.model}: ${summary}` }] };
}
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
// "Handle an elicitation request" — the form round trip the page quotes.
const placed = await client.callTool({ name: 'place-order', arguments: { item: 'Travel mug' } });
console.log(placed.content);
// "Handle a sampling request" — the handler logs the prompt it received, then
// the tool result carries the handler's completion. Both lines are quoted.
const summarized = await client.callTool({ name: 'summarize-order', arguments: { order: '1 Travel mug to Lisbon' } });
console.log(summarized.content);
// Proof for the elicitation section's `url` branch — not quoted on the page,
// but the run fails (non-zero exit) if the branch stops working.
const linked = await client.callTool({ name: 'link-card', arguments: { provider: 'examplepay' } });
const linkedText = linked.content?.[0];
if (linkedText?.type !== 'text' || linkedText.text !== 'Card link: accept.') {
throw new Error(`server-requests.md url-branch claim failed: ${JSON.stringify(linked.content)}`);
}
// Proof for the page's ::: tip — an empty `elicitation: {}` capability means
// form mode only; `url` has to be declared explicitly.
const modes = getSupportedElicitationModes({});
if (modes.supportsFormMode !== true || modes.supportsUrlMode !== false) {
throw new Error(`server-requests.md tip claim failed: ${JSON.stringify(modes)}`);
}
// Keep the round-cap constructor type-checked (its region is page-only).
void Client_inputRequired;
await client.close();
await server.close();

View file

@ -0,0 +1,199 @@
/**
* Companion example for `docs/clients/subscriptions.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 drives a
* `createMcpHandler` entry in process — no port, no socket — over a
* 2026-07-28 connection, publishes the changes, and produces the output the
* page quotes verbatim. It exits non-zero if a quoted line never appears.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
* npx tsx guides/clients/subscriptions.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
// ---------------------------------------------------------------------------
// Harness server (not shown on the page). A per-request factory whose tool
// set and `config://app` resource the harness mutates between publishes.
// `subscriptions/listen` is served by the entry, so the page's client talks
// to `handler.fetch` directly — the URL is never dialed.
// ---------------------------------------------------------------------------
let settings = { theme: 'light' };
let archiveEnabled = false;
function buildNotesServer(): McpServer {
const server = new McpServer(
{ name: 'notes', version: '1.0.0' },
{ capabilities: { tools: { listChanged: true }, resources: { subscribe: true } } }
);
server.registerTool(
'search-notes',
{ description: 'Search notes', inputSchema: z.object({ query: z.string() }) },
async ({ query }) => ({
content: [{ type: 'text', text: `no notes match ${query}` }]
})
);
if (archiveEnabled) {
server.registerTool('archive-note', { description: 'Archive a note' }, async () => ({
content: [{ type: 'text', text: 'archived' }]
}));
}
server.registerResource('config', 'config://app', { mimeType: 'application/json' }, async uri => ({
contents: [{ uri: uri.href, text: JSON.stringify(settings) }]
}));
return server;
}
// Each leg gets its own handler instance (and therefore its own event bus),
// so a publish meant for one leg cannot reach the other.
const handler = createMcpHandler(buildNotesServer);
const autoHandler = createMcpHandler(buildNotesServer);
/** Connect `client` to `target` over the 2026-07-28 revision, in process. */
async function connect(client: Client, target: typeof handler): Promise<void> {
client.setVersionNegotiation({ mode: 'auto' });
await client.connect(
new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), {
fetch: (url, init) => target.fetch(new Request(url, init))
})
);
}
// Every `console.log` line is also recorded so the harness can wait for the
// exact output the page quotes (and fail loudly if it never arrives).
const logged: string[] = [];
const realLog = console.log.bind(console);
console.log = (...args: unknown[]) => {
logged.push(args.map(String).join(' '));
realLog(...args);
};
/** Wait until a logged line starts with `prefix`, or throw after 5 s. */
async function loggedLine(prefix: string): Promise<void> {
const deadline = Date.now() + 5000;
while (!logged.some(line => line.startsWith(prefix))) {
if (Date.now() > deadline) throw new Error(`timed out waiting for output line "${prefix}"`);
await new Promise(resolve => setTimeout(resolve, 25));
}
}
const client = new Client({ name: 'notes-watcher', version: '1.0.0' });
await connect(client, handler);
// ---------------------------------------------------------------------------
// "Open a subscription stream" — the honored-filter line the page quotes.
// ---------------------------------------------------------------------------
//#region listen_open
client.setNotificationHandler('notifications/tools/list_changed', async () => {
const { tools } = await client.listTools();
console.log('Tools changed:', tools.length);
});
const subscription = await client.listen({
toolsListChanged: true,
resourceSubscriptions: ['config://app']
});
console.log('Server honored:', subscription.honoredFilter);
//#endregion listen_open
// ---------------------------------------------------------------------------
// "Handle the notifications" — the second handler, then one publish of each
// kind. Both notification lines the page quotes come from these handlers.
// ---------------------------------------------------------------------------
//#region listen_updated
client.setNotificationHandler('notifications/resources/updated', async notification => {
const { contents } = await client.readResource({ uri: notification.params.uri });
console.log('Updated', notification.params.uri, contents);
});
//#endregion listen_updated
archiveEnabled = true;
handler.notify.toolsChanged();
await loggedLine('Tools changed:');
settings = { theme: 'dark' };
handler.notify.resourceUpdated('config://app');
await loggedLine('Updated config://app');
// ---------------------------------------------------------------------------
// "Close the stream and react to closure" — the close reason the page quotes.
// ---------------------------------------------------------------------------
//#region listen_close
await subscription.close();
console.log('Closed:', await subscription.closed);
//#endregion listen_close
await client.close();
await handler.close();
/** Example: re-listen only on an unexpected disconnect. Never invoked — the page's watch-loop block. */
async function watchConfig(watching: boolean): Promise<void> {
//#region listen_watchLoop
while (watching) {
const sub = await client.listen({ resourceSubscriptions: ['config://app'] });
const reason = await sub.closed;
if (reason !== 'remote') break; // 'local' or 'graceful': done
await new Promise(resolve => setTimeout(resolve, 1000)); // back off, then re-listen
}
//#endregion listen_watchLoop
}
void watchConfig;
// ---------------------------------------------------------------------------
// "Let the SDK open the stream for you" — a second client whose stream the
// SDK opens from the `listChanged` option. The harness publishes one more
// tool change to produce the `onChanged` line the page quotes.
// ---------------------------------------------------------------------------
//#region listChanged_auto
const watcher = new Client(
{ name: 'notes-watcher', version: '1.0.0' },
{
listChanged: {
tools: {
onChanged: (error, tools) => {
if (error) {
console.error('Refresh failed:', error);
return;
}
console.log('Tools refreshed:', tools?.length);
}
}
}
}
);
//#endregion listChanged_auto
await connect(watcher, autoHandler);
if (watcher.autoOpenedSubscription === undefined) {
throw new Error('listChanged should auto-open a subscription stream on a 2026-07-28 connection');
}
console.log('Auto-opened:', watcher.autoOpenedSubscription.honoredFilter);
archiveEnabled = false;
autoHandler.notify.toolsChanged();
await loggedLine('Tools refreshed:');
await watcher.autoOpenedSubscription.close();
await watcher.close();
await autoHandler.close();
/** Example: the 2025-era per-resource path. Never invoked — `resources/subscribe` is not part of 2026-07-28. */
async function legacySubscribe(client: Client): Promise<void> {
//#region subscribeResource_legacy
await client.subscribeResource({ uri: 'config://app' });
// The same notifications/resources/updated handler fires.
await client.unsubscribeResource({ uri: 'config://app' });
//#endregion subscribeResource_legacy
}
void legacySubscribe;

Some files were not shown because too many files have changed in this diff Show more