docs: rebuild the v2 documentation around a task-based page tree (#2397)
This commit is contained in:
parent
708d5455d9
commit
b153505428
9 changed files with 11884 additions and 4971 deletions
145
examples/guides/clients/caching.examples.ts
Normal file
145
examples/guides/clients/caching.examples.ts
Normal 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;
|
||||
205
examples/guides/clients/calling.examples.ts
Normal file
205
examples/guides/clients/calling.examples.ts
Normal 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();
|
||||
116
examples/guides/clients/connect.examples.ts
Normal file
116
examples/guides/clients/connect.examples.ts
Normal 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;
|
||||
89
examples/guides/clients/machine-auth.examples.ts
Normal file
89
examples/guides/clients/machine-auth.examples.ts
Normal 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;
|
||||
136
examples/guides/clients/middleware.examples.ts
Normal file
136
examples/guides/clients/middleware.examples.ts
Normal 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();
|
||||
181
examples/guides/clients/oauth.examples.ts
Normal file
181
examples/guides/clients/oauth.examples.ts
Normal 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
|
||||
67
examples/guides/clients/roots.examples.ts
Normal file
67
examples/guides/clients/roots.examples.ts
Normal 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();
|
||||
176
examples/guides/clients/server-requests.examples.ts
Normal file
176
examples/guides/clients/server-requests.examples.ts
Normal 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();
|
||||
199
examples/guides/clients/subscriptions.examples.ts
Normal file
199
examples/guides/clients/subscriptions.examples.ts
Normal 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;
|
||||
Loading…
Reference in a new issue