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
9 changed files with 11884 additions and 4971 deletions

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;