docs: rebuild the v2 documentation around a task-based page tree (#2397)
This commit is contained in:
parent
708d5455d9
commit
b153505428
6 changed files with 11884 additions and 4971 deletions
100
examples/guides/advanced/custom-methods.examples.ts
Normal file
100
examples/guides/advanced/custom-methods.examples.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Companion example for `docs/advanced/custom-methods.md`.
|
||||
*
|
||||
* Every `ts` fence on that page is synced from a `//#region` in this file
|
||||
* (`pnpm sync:snippets --check`). The file also runs: the harness below the
|
||||
* regions connects an in-memory client and produces the output the page quotes
|
||||
* verbatim.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/advanced/custom-methods.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
//#region setRequestHandler_custom
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
const SearchParams = z.object({ query: z.string(), limit: z.number().int().default(10) });
|
||||
const SearchResult = z.object({ items: z.array(z.string()) });
|
||||
|
||||
const mcp = new McpServer({ name: 'acme-search', version: '1.0.0' });
|
||||
|
||||
mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async ({ query, limit }) => {
|
||||
return { items: Array.from({ length: limit }, (_, index) => `${query}-${index}`) };
|
||||
});
|
||||
//#endregion setRequestHandler_custom
|
||||
|
||||
// "Declare an extension capability" — must happen before the server connects.
|
||||
//#region registerCapabilities_extensions
|
||||
mcp.server.registerCapabilities({
|
||||
extensions: { 'com.example/feature-flags': { flags: ['dark-mode', 'beta-search'] } }
|
||||
});
|
||||
//#endregion registerCapabilities_extensions
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page). An in-memory client drives the calls whose
|
||||
// output advanced/custom-methods.md quotes verbatim. Any MCP client behaves
|
||||
// the same. Imported dynamically so the page's lead region stays self-contained.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client');
|
||||
|
||||
const client = new Client({ name: 'custom-methods-docs-harness', version: '1.0.0' });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await mcp.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
// "Call it from the client" — the result the page quotes.
|
||||
//#region request_custom
|
||||
const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult);
|
||||
console.log(result);
|
||||
//#endregion request_custom
|
||||
|
||||
// Proof for the page's ::: tip — params that fail `SearchParams` are rejected
|
||||
// before the handler runs. Throws (non-zero exit) if the claim is false.
|
||||
let rejection: Error | undefined;
|
||||
try {
|
||||
await client.request({ method: 'acme/search', params: { query: 42 } }, SearchResult);
|
||||
} catch (error) {
|
||||
rejection = error as Error;
|
||||
}
|
||||
if (!rejection) {
|
||||
throw new Error('custom-methods.md tip claim failed: invalid params were accepted');
|
||||
}
|
||||
console.log(rejection.message);
|
||||
|
||||
// "Read the negotiated extensions on the client" — the map the page quotes.
|
||||
//#region getServerCapabilities_extensions
|
||||
const extensions = client.getServerCapabilities()?.extensions ?? {};
|
||||
console.log(extensions);
|
||||
//#endregion getServerCapabilities_extensions
|
||||
|
||||
// "Send a custom notification from the handler" — registering the method again
|
||||
// replaces its handler with one that reports progress.
|
||||
//#region setRequestHandler_notify
|
||||
mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async ({ query, limit }, ctx) => {
|
||||
await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'start', pct: 0 } });
|
||||
const items = Array.from({ length: limit }, (_, index) => `${query}-${index}`);
|
||||
await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'done', pct: 1 } });
|
||||
return { items };
|
||||
});
|
||||
//#endregion setRequestHandler_notify
|
||||
|
||||
// "Receive it on the client" — the progress params the page quotes.
|
||||
//#region setNotificationHandler_custom
|
||||
const SearchProgressParams = z.object({ stage: z.string(), pct: z.number() });
|
||||
|
||||
client.setNotificationHandler('acme/searchProgress', { params: SearchProgressParams }, params => {
|
||||
console.log(params);
|
||||
});
|
||||
|
||||
await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 1 } }, SearchResult);
|
||||
//#endregion setNotificationHandler_custom
|
||||
|
||||
// Let the notification microtasks drain before tearing the pair down.
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
await client.close();
|
||||
await mcp.close();
|
||||
210
examples/guides/advanced/custom-transports.examples.ts
Normal file
210
examples/guides/advanced/custom-transports.examples.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* Companion example for `docs/advanced/custom-transports.md`.
|
||||
*
|
||||
* Every `ts` fence on that page is synced from a `//#region` in this file
|
||||
* (`pnpm sync:snippets --check`). The file also runs: the harness below the
|
||||
* regions connects a client and a server over the loopback transport and
|
||||
* produces the output the page quotes verbatim.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/advanced/custom-transports.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
//#region transport_loopback
|
||||
import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/server';
|
||||
|
||||
export class LoopbackTransport implements Transport {
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
|
||||
private peer?: LoopbackTransport;
|
||||
|
||||
/** Cross-wire two ends: whatever one end sends, the other receives. */
|
||||
static link(a: LoopbackTransport, b: LoopbackTransport): void {
|
||||
a.peer = b;
|
||||
b.peer = a;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Open your channel here. The loopback has nothing to open.
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
const peer = this.peer;
|
||||
if (!peer) throw new Error('Loopback peer is gone');
|
||||
queueMicrotask(() => peer.onmessage?.(message));
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.peer = undefined;
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
//#endregion transport_loopback
|
||||
|
||||
// "Connect it like a built-in transport" — produces the output the page quotes.
|
||||
//#region connect_loopback
|
||||
import { Client } from '@modelcontextprotocol/client';
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
|
||||
const server = new McpServer({ name: 'loopback-demo', version: '1.0.0' });
|
||||
server.registerTool('ping', { description: 'Reply with pong' }, async () => ({
|
||||
content: [{ type: 'text', text: 'pong' }]
|
||||
}));
|
||||
|
||||
const client = new Client({ name: 'loopback-client', version: '1.0.0' });
|
||||
|
||||
const serverEnd = new LoopbackTransport();
|
||||
const clientEnd = new LoopbackTransport();
|
||||
LoopbackTransport.link(serverEnd, clientEnd);
|
||||
|
||||
await server.connect(serverEnd);
|
||||
await client.connect(clientEnd);
|
||||
|
||||
const result = await client.callTool({ name: 'ping' });
|
||||
console.log(result.content);
|
||||
//#endregion connect_loopback
|
||||
|
||||
// "Frame messages over a byte stream" — typechecked, not run (it needs a real
|
||||
// socket). The runnable proof on this page is the loopback transport above.
|
||||
//#region transport_socket
|
||||
import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/server';
|
||||
import type { Socket } from 'node:net';
|
||||
|
||||
export class SocketTransport implements Transport {
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
|
||||
private readonly readBuffer = new ReadBuffer();
|
||||
|
||||
constructor(private readonly socket: Socket) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.socket.on('data', chunk => {
|
||||
try {
|
||||
this.readBuffer.append(chunk);
|
||||
let message = this.readBuffer.readMessage();
|
||||
while (message !== null) {
|
||||
this.onmessage?.(message);
|
||||
message = this.readBuffer.readMessage();
|
||||
}
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error);
|
||||
}
|
||||
});
|
||||
this.socket.on('error', error => this.onerror?.(error));
|
||||
this.socket.on('close', () => this.onclose?.());
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
this.socket.write(serializeMessage(message));
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.socket.end();
|
||||
}
|
||||
}
|
||||
//#endregion transport_socket
|
||||
|
||||
// "Report a session ID and the negotiated version".
|
||||
//#region transport_session
|
||||
export class SessionLoopbackTransport extends LoopbackTransport {
|
||||
sessionId?: string;
|
||||
|
||||
protocolVersion?: string;
|
||||
|
||||
setProtocolVersion(version: string): void {
|
||||
this.protocolVersion = version;
|
||||
}
|
||||
}
|
||||
//#endregion transport_session
|
||||
|
||||
// "Opt into per-request cancellation" — typechecked, not run.
|
||||
import type { TransportSendOptions } from '@modelcontextprotocol/server';
|
||||
import { parseJSONRPCMessage } from '@modelcontextprotocol/server';
|
||||
|
||||
export class HttpPostTransport implements Transport {
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
|
||||
constructor(private readonly endpoint: URL) {}
|
||||
|
||||
//#region transport_perRequest
|
||||
readonly hasPerRequestStream = true;
|
||||
|
||||
async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
|
||||
const response = await fetch(this.endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(message),
|
||||
signal: options?.requestSignal
|
||||
});
|
||||
this.onmessage?.(parseJSONRPCMessage(await response.json()));
|
||||
}
|
||||
//#endregion transport_perRequest
|
||||
|
||||
async start(): Promise<void> {}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page). Drives the remaining claims the page makes
|
||||
// and throws (non-zero exit) if any of them is false.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
await client.close();
|
||||
await server.close();
|
||||
|
||||
// "Report a session ID and the negotiated version" — the version the page quotes.
|
||||
const sessionServer = new McpServer({ name: 'loopback-demo', version: '1.0.0' });
|
||||
const sessionClient = new Client({ name: 'loopback-client', version: '1.0.0' });
|
||||
|
||||
const sessionServerEnd = new SessionLoopbackTransport();
|
||||
const sessionClientEnd = new SessionLoopbackTransport();
|
||||
LoopbackTransport.link(sessionServerEnd, sessionClientEnd);
|
||||
|
||||
await sessionServer.connect(sessionServerEnd);
|
||||
await sessionClient.connect(sessionClientEnd);
|
||||
|
||||
console.log(sessionClientEnd.protocolVersion);
|
||||
if (sessionServerEnd.protocolVersion !== sessionClientEnd.protocolVersion) {
|
||||
throw new Error(
|
||||
`custom-transports.md claim failed: server end negotiated ${sessionServerEnd.protocolVersion}, client end ${sessionClientEnd.protocolVersion}`
|
||||
);
|
||||
}
|
||||
|
||||
await sessionClient.close();
|
||||
await sessionServer.close();
|
||||
|
||||
// "Test it against the in-memory pair" — the reference transport returns the
|
||||
// same result the loopback produced.
|
||||
const referenceServer = new McpServer({ name: 'loopback-demo', version: '1.0.0' });
|
||||
referenceServer.registerTool('ping', { description: 'Reply with pong' }, async () => ({
|
||||
content: [{ type: 'text', text: 'pong' }]
|
||||
}));
|
||||
const referenceClient = new Client({ name: 'loopback-client', version: '1.0.0' });
|
||||
|
||||
//#region inMemory_pair
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
const [inMemoryClientEnd, inMemoryServerEnd] = InMemoryTransport.createLinkedPair();
|
||||
//#endregion inMemory_pair
|
||||
|
||||
await referenceServer.connect(inMemoryServerEnd);
|
||||
await referenceClient.connect(inMemoryClientEnd);
|
||||
|
||||
const referenceResult = await referenceClient.callTool({ name: 'ping' });
|
||||
if (JSON.stringify(referenceResult.content) !== JSON.stringify(result.content)) {
|
||||
throw new Error(`custom-transports.md claim failed: in-memory result ${JSON.stringify(referenceResult.content)}`);
|
||||
}
|
||||
|
||||
await referenceClient.close();
|
||||
await referenceServer.close();
|
||||
135
examples/guides/advanced/gateway.examples.ts
Normal file
135
examples/guides/advanced/gateway.examples.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* Runnable, type-checked companion for `docs/advanced/gateway.md`.
|
||||
*
|
||||
* Each `//#region` block is synced byte-for-byte into that page's code fences
|
||||
* (`pnpm sync:snippets --check` reports drift).
|
||||
*
|
||||
* The page's program runs for real: the harness below builds a
|
||||
* `createMcpHandler` server and routes `globalThis.fetch` for
|
||||
* `http://localhost:3000/mcp` into `handler.fetch`, so the HTTP regions
|
||||
* execute in-process without binding a port. The output the page quotes
|
||||
* verbatim is whatever this file prints. The server's `request_count` tool
|
||||
* returns how many MCP requests reached the process (`createMcpHandler`
|
||||
* builds one server instance per request), which is what proves that
|
||||
* `connect({ prior })` sent nothing.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/advanced/gateway.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page). Every `new McpServer(...)` is one inbound
|
||||
// MCP request, so the module-level counter is the number of requests the
|
||||
// process has answered — `request_count` exposes it to the page's clients.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let requests = 0;
|
||||
|
||||
const handler = createMcpHandler(() => {
|
||||
requests++;
|
||||
const server = new McpServer({ name: 'gateway-target', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } });
|
||||
server.registerTool('echo', { description: 'Echo the input back', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({
|
||||
content: [{ type: 'text', text }]
|
||||
}));
|
||||
server.registerTool(
|
||||
'request_count',
|
||||
{ description: 'Number of MCP requests this server process has answered', outputSchema: z.object({ requests: z.number() }) },
|
||||
async () => ({ content: [{ type: 'text', text: String(requests) }], structuredContent: { requests } })
|
||||
);
|
||||
return server;
|
||||
});
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const request = new Request(input, init);
|
||||
if (new URL(request.url).host === 'localhost:3000') return handler.fetch(request);
|
||||
return realFetch(input, init);
|
||||
}) as typeof fetch;
|
||||
|
||||
// ## Connect with a prior discover result
|
||||
|
||||
//#region connect_prior
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
const url = new URL('http://localhost:3000/mcp');
|
||||
|
||||
// Probe once …
|
||||
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
await bootstrap.connect(new StreamableHTTPClientTransport(url));
|
||||
const persisted = JSON.stringify(bootstrap.getDiscoverResult());
|
||||
|
||||
// … then every other client connects with zero round trips.
|
||||
const worker = new Client({ name: 'worker', version: '1.0.0' });
|
||||
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
|
||||
//#endregion connect_prior
|
||||
|
||||
// ## Probe once at bootstrap
|
||||
|
||||
//#region bootstrap_probe
|
||||
console.log(bootstrap.getDiscoverResult());
|
||||
//#endregion bootstrap_probe
|
||||
|
||||
// ## Persist the advertisement
|
||||
|
||||
//#region persist_advertisement
|
||||
import type { DiscoverResult } from '@modelcontextprotocol/client';
|
||||
|
||||
const prior = JSON.parse(persisted) as DiscoverResult;
|
||||
//#endregion persist_advertisement
|
||||
|
||||
// ## Fan out to workers
|
||||
|
||||
//#region fan_out
|
||||
const fleet = await Promise.all(
|
||||
['worker-a', 'worker-b', 'worker-c'].map(async name => {
|
||||
const replica = new Client({ name, version: '1.0.0' });
|
||||
await replica.connect(new StreamableHTTPClientTransport(url), { prior });
|
||||
return replica;
|
||||
})
|
||||
);
|
||||
|
||||
const proof = await worker.callTool({ name: 'request_count' });
|
||||
console.log(proof.structuredContent);
|
||||
//#endregion fan_out
|
||||
|
||||
// ## Open a listen stream when a worker needs notifications
|
||||
|
||||
//#region listen_worker
|
||||
const subscription = await worker.listen({ toolsListChanged: true });
|
||||
console.log(subscription.honoredFilter);
|
||||
//#endregion listen_worker
|
||||
|
||||
await subscription.close();
|
||||
|
||||
// ## Handle a stale or incompatible advertisement
|
||||
|
||||
//#region prior_stale
|
||||
import { SdkError, SdkErrorCode } from '@modelcontextprotocol/client';
|
||||
|
||||
const stale: DiscoverResult = { ...prior, supportedVersions: ['2025-06-18'] };
|
||||
|
||||
const late = new Client({ name: 'worker-d', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
try {
|
||||
await late.connect(new StreamableHTTPClientTransport(url), { prior: stale });
|
||||
} catch (error) {
|
||||
if (!(error instanceof SdkError) || error.code !== SdkErrorCode.EraNegotiationFailed) throw error;
|
||||
console.log(error.code);
|
||||
|
||||
// Fall back to a fresh probe, then re-persist getDiscoverResult().
|
||||
await late.connect(new StreamableHTTPClientTransport(url));
|
||||
console.log('re-probed:', late.getNegotiatedProtocolVersion());
|
||||
}
|
||||
//#endregion prior_stale
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness teardown.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
for (const client of [bootstrap, worker, late, ...fleet]) await client.close();
|
||||
await handler.close();
|
||||
globalThis.fetch = realFetch;
|
||||
179
examples/guides/advanced/low-level-server.examples.ts
Normal file
179
examples/guides/advanced/low-level-server.examples.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* Companion example for `docs/advanced/low-level-server.md`.
|
||||
*
|
||||
* Every `ts` fence on that page is synced from a `//#region` in this file
|
||||
* (`pnpm sync:snippets --check`). The file also runs: the harness between the
|
||||
* regions connects in-memory clients and produces every output the page quotes
|
||||
* verbatim, exiting non-zero on drift.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/advanced/low-level-server.examples.ts # from examples/
|
||||
*
|
||||
* `lowLevel_serve` lives in a never-invoked wrapper: `serveStdio` would bind
|
||||
* this process's real stdin/stdout, so that one region is typecheck-only.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console, import/no-duplicates */
|
||||
// Harness imports. The page's lead block (the first region) carries its own
|
||||
// `Server` import so the rendered fence stands alone.
|
||||
import { createMcpHandler, fromJsonSchema, McpServer } from '@modelcontextprotocol/server';
|
||||
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Build the server and list your tools by hand"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
//#region lowLevel_listTools
|
||||
import { Server } from '@modelcontextprotocol/server';
|
||||
|
||||
const catalog = [
|
||||
{ name: 'Espresso cup', price: 12 },
|
||||
{ name: 'Travel mug', price: 24 },
|
||||
{ name: 'Mug rack', price: 36 }
|
||||
];
|
||||
|
||||
const server = new Server({ name: 'catalog', version: '1.0.0' }, { capabilities: { tools: {} } });
|
||||
|
||||
server.setRequestHandler('tools/list', async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: 'search',
|
||||
description: 'Search the product catalog',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { query: { type: 'string', description: 'Substring to match against product names' } },
|
||||
required: ['query']
|
||||
}
|
||||
}
|
||||
]
|
||||
}));
|
||||
//#endregion lowLevel_listTools
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Handle tools/call yourself"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
//#region lowLevel_callTool
|
||||
server.setRequestHandler('tools/call', async request => {
|
||||
if (request.params.name !== 'search') {
|
||||
return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true };
|
||||
}
|
||||
const { query } = request.params.arguments as { query: string };
|
||||
const hits = catalog.filter(product => product.name.toLowerCase().includes(query.toLowerCase()));
|
||||
return { content: [{ type: 'text', text: hits.map(product => product.name).join('\n') }] };
|
||||
});
|
||||
//#endregion lowLevel_callTool
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page). An in-memory client drives the calls whose
|
||||
// output low-level-server.md quotes verbatim. Imported dynamically so the
|
||||
// page's lead region stays self-contained.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { Client, InMemoryTransport, ProtocolError } = await import('@modelcontextprotocol/client');
|
||||
|
||||
const client = new Client({ name: 'low-level-docs-harness', version: '1.0.0' });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
// The handler answers a valid call exactly like the McpServer version.
|
||||
const result = await client.callTool({ name: 'search', arguments: { query: 'mug' } });
|
||||
console.log(result.content);
|
||||
|
||||
// Nothing validated `query`, so a wrongly-typed argument reaches the handler
|
||||
// and crashes it: the client sees a JSON-RPC error, not a tool result.
|
||||
const crashed = await client.callTool({ name: 'search', arguments: { query: 42 } }).catch((error: unknown) => error);
|
||||
if (!(crashed instanceof ProtocolError)) {
|
||||
throw new Error(`low-level-server.md expected the unvalidated call to reject: ${JSON.stringify(crashed)}`);
|
||||
}
|
||||
console.log(`${crashed.name} ${crashed.code}: ${crashed.message}`);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Validate arguments yourself"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
//#region lowLevel_validate
|
||||
const SearchArguments = fromJsonSchema<{ query: string }>({
|
||||
type: 'object',
|
||||
properties: { query: { type: 'string' } },
|
||||
required: ['query']
|
||||
});
|
||||
|
||||
server.setRequestHandler('tools/call', async request => {
|
||||
if (request.params.name !== 'search') {
|
||||
return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true };
|
||||
}
|
||||
const parsed = await SearchArguments['~standard'].validate(request.params.arguments ?? {});
|
||||
if (parsed.issues) {
|
||||
return { content: [{ type: 'text', text: parsed.issues.map(issue => issue.message).join('; ') }], isError: true };
|
||||
}
|
||||
const hits = catalog.filter(product => product.name.toLowerCase().includes(parsed.value.query.toLowerCase()));
|
||||
return { content: [{ type: 'text', text: hits.map(product => product.name).join('\n') }] };
|
||||
});
|
||||
//#endregion lowLevel_validate
|
||||
|
||||
// The same wrongly-typed call now comes back as an ordinary isError result.
|
||||
const rejected = await client.callTool({ name: 'search', arguments: { query: 42 } });
|
||||
console.log(rejected);
|
||||
if (rejected.isError !== true) {
|
||||
throw new Error(`low-level-server.md expected the validated call to return isError: ${JSON.stringify(rejected)}`);
|
||||
}
|
||||
|
||||
await client.close();
|
||||
await server.close();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Serve it with the same entry points" — typecheck-only. `serveStdio` would
|
||||
// take over this process's stdin/stdout, so the harness never calls this.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function lowLevel_serve() {
|
||||
//#region lowLevel_serve
|
||||
serveStdio(() => server);
|
||||
createMcpHandler(() => server);
|
||||
//#endregion lowLevel_serve
|
||||
}
|
||||
void lowLevel_serve;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Reach the low level from McpServer"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
//#region lowLevel_escapeHatch
|
||||
const mcp = new McpServer({ name: 'catalog', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } });
|
||||
|
||||
mcp.registerTool(
|
||||
'search',
|
||||
{ description: 'Search the product catalog', inputSchema: z.object({ query: z.string() }) },
|
||||
async ({ query }) => {
|
||||
const names = catalog.filter(product => product.name.includes(query)).map(product => product.name);
|
||||
return { content: [{ type: 'text', text: names.join('\n') }] };
|
||||
}
|
||||
);
|
||||
|
||||
const subscriptions = new Set<string>();
|
||||
mcp.server.setRequestHandler('resources/subscribe', async request => {
|
||||
subscriptions.add(request.params.uri);
|
||||
return {};
|
||||
});
|
||||
//#endregion lowLevel_escapeHatch
|
||||
|
||||
// Harness: prove the page's claim for the section above — `registerTool` still
|
||||
// owns `tools/list`, and the hand-registered handler answers
|
||||
// `resources/subscribe` on the same connection.
|
||||
const mcpClient = new Client({ name: 'low-level-docs-harness', version: '1.0.0' });
|
||||
const [mcpClientTransport, mcpServerTransport] = InMemoryTransport.createLinkedPair();
|
||||
await mcp.connect(mcpServerTransport);
|
||||
await mcpClient.connect(mcpClientTransport);
|
||||
|
||||
const { tools } = await mcpClient.listTools();
|
||||
await mcpClient.subscribeResource({ uri: 'demo://config' });
|
||||
if (tools.length !== 1 || tools[0]?.name !== 'search' || !subscriptions.has('demo://config')) {
|
||||
throw new Error(`low-level-server.md escape-hatch claim failed: ${JSON.stringify({ tools, subscriptions: [...subscriptions] })}`);
|
||||
}
|
||||
|
||||
await mcpClient.close();
|
||||
await mcp.close();
|
||||
129
examples/guides/advanced/schema-libraries.examples.ts
Normal file
129
examples/guides/advanced/schema-libraries.examples.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* Companion example for `docs/advanced/schema-libraries.md`.
|
||||
*
|
||||
* Every `ts` fence on that page is synced from a `//#region` in this file
|
||||
* (`pnpm sync:snippets --check`). The file also runs: the harness below the
|
||||
* regions connects an in-memory client and produces the output the page quotes
|
||||
* verbatim.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/advanced/schema-libraries.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
//#region registerTool_arktype
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import { type } from 'arktype';
|
||||
|
||||
const server = new McpServer({ name: 'schema-zoo', version: '1.0.0' });
|
||||
|
||||
server.registerTool(
|
||||
'greet',
|
||||
{
|
||||
description: 'Greet someone by name',
|
||||
inputSchema: type({ name: 'string', 'times?': '1 <= number.integer <= 5' })
|
||||
},
|
||||
async ({ name, times }) => ({
|
||||
content: [{ type: 'text', text: Array.from({ length: times ?? 1 }, () => `Hello, ${name}`).join('\n') }]
|
||||
})
|
||||
);
|
||||
//#endregion registerTool_arktype
|
||||
|
||||
//#region registerTool_valibot
|
||||
import { toStandardJsonSchema } from '@valibot/to-json-schema';
|
||||
import * as v from 'valibot';
|
||||
|
||||
server.registerTool(
|
||||
'shout',
|
||||
{ description: 'Greet someone, loudly', inputSchema: toStandardJsonSchema(v.object({ name: v.string() })) },
|
||||
async ({ name }) => ({ content: [{ type: 'text', text: `HELLO, ${name.toUpperCase()}` }] })
|
||||
);
|
||||
//#endregion registerTool_valibot
|
||||
|
||||
//#region registerTool_fromJsonSchema
|
||||
import { fromJsonSchema } from '@modelcontextprotocol/server';
|
||||
|
||||
server.registerTool(
|
||||
'farewell',
|
||||
{
|
||||
description: 'Say goodbye',
|
||||
inputSchema: fromJsonSchema<{ name: string }>({
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string' } },
|
||||
required: ['name']
|
||||
})
|
||||
},
|
||||
async ({ name }) => ({ content: [{ type: 'text', text: `Goodbye, ${name}` }] })
|
||||
);
|
||||
//#endregion registerTool_fromJsonSchema
|
||||
|
||||
//#region registerTool_outputSchema
|
||||
server.registerTool(
|
||||
'measure',
|
||||
{
|
||||
description: 'Measure the length of a name',
|
||||
inputSchema: type({ name: 'string' }),
|
||||
outputSchema: type({ name: 'string', length: 'number' })
|
||||
},
|
||||
async ({ name }) => {
|
||||
const output = { name, length: name.length };
|
||||
return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output };
|
||||
}
|
||||
);
|
||||
//#endregion registerTool_outputSchema
|
||||
|
||||
//#region jsonSchemaValidator_ajv
|
||||
import { addFormats, Ajv, AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv';
|
||||
|
||||
const ajv = new Ajv({ strict: true, allErrors: true });
|
||||
addFormats(ajv);
|
||||
const validator = new AjvJsonSchemaValidator(ajv);
|
||||
|
||||
const strict = new McpServer({ name: 'schema-zoo', version: '1.0.0' }, { jsonSchemaValidator: validator });
|
||||
//#endregion jsonSchemaValidator_ajv
|
||||
|
||||
//#region jsonSchemaValidator_cfWorker
|
||||
import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker';
|
||||
|
||||
const edge = new McpServer({ name: 'schema-zoo', version: '1.0.0' }, { jsonSchemaValidator: new CfWorkerJsonSchemaValidator() });
|
||||
//#endregion jsonSchemaValidator_cfWorker
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page). An in-memory client drives the calls whose
|
||||
// output advanced/schema-libraries.md quotes verbatim. Any MCP client behaves
|
||||
// the same. Imported dynamically so the page's lead region stays
|
||||
// self-contained.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client');
|
||||
|
||||
const client = new Client({ name: 'schema-libraries-docs-harness', version: '1.0.0' });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
// "Register a tool with an ArkType schema" — the rejection the page quotes.
|
||||
const rejected = await client.callTool({ name: 'greet', arguments: { name: 'Ada', times: 99 } });
|
||||
console.log(rejected);
|
||||
|
||||
// "Register a tool with a Valibot schema" — proves the page's prose claim that
|
||||
// the call validates and dispatches like any other tool.
|
||||
const shouted = await client.callTool({ name: 'shout', arguments: { name: 'Ada' } });
|
||||
if (JSON.stringify(shouted.content) !== JSON.stringify([{ type: 'text', text: 'HELLO, ADA' }])) {
|
||||
throw new Error(`schema-libraries.md valibot claim failed: ${JSON.stringify(shouted)}`);
|
||||
}
|
||||
|
||||
// "Start from JSON Schema you already have" — the advertised schema the page quotes.
|
||||
const { tools } = await client.listTools();
|
||||
const farewell = tools.find(tool => tool.name === 'farewell');
|
||||
console.log(farewell?.inputSchema);
|
||||
|
||||
// "Validate structured output with any library" — the structured result the page quotes.
|
||||
const measured = await client.callTool({ name: 'measure', arguments: { name: 'Ada' } });
|
||||
console.log(measured);
|
||||
|
||||
await client.close();
|
||||
await server.close();
|
||||
await strict.close();
|
||||
await edge.close();
|
||||
104
examples/guides/advanced/wire-schemas.examples.ts
Normal file
104
examples/guides/advanced/wire-schemas.examples.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Companion example for `docs/advanced/wire-schemas.md`.
|
||||
*
|
||||
* Every `ts` fence on that page is synced from a `//#region` in this file
|
||||
* (`pnpm sync:snippets --check`). The file also runs: it is the gateway path
|
||||
* the page walks through — raw JSON in, validated with the schemas from
|
||||
* `@modelcontextprotocol/core`, no `Client` or `Server` anywhere — and it
|
||||
* prints the exact output the page quotes verbatim.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/advanced/wire-schemas.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars, no-console */
|
||||
|
||||
// "Validate a wire payload" — the happy path the page quotes.
|
||||
//#region wireSchemas_validateResult
|
||||
import { CallToolResultSchema } from '@modelcontextprotocol/core';
|
||||
|
||||
// The body an upstream server returned for a tools/call you forwarded.
|
||||
const body: unknown = JSON.parse('{"content":[{"type":"text","text":"Travel mug"}]}');
|
||||
|
||||
const parsed = CallToolResultSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`upstream returned an invalid tools/call result: ${parsed.error.message}`);
|
||||
}
|
||||
console.log(parsed.data.content);
|
||||
//#endregion wireSchemas_validateResult
|
||||
|
||||
// "Validate a wire payload" — the rejection the page quotes.
|
||||
//#region wireSchemas_validateResult_invalid
|
||||
const malformed = CallToolResultSchema.safeParse({ content: 'Travel mug' });
|
||||
console.log(malformed.error?.issues);
|
||||
//#endregion wireSchemas_validateResult_invalid
|
||||
|
||||
// "Pick the schema for the message you hold" — the undecoded envelope.
|
||||
//#region wireSchemas_envelope
|
||||
import { JSONRPCMessageSchema } from '@modelcontextprotocol/core';
|
||||
|
||||
const frame = '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"mug"}}}';
|
||||
const message = JSONRPCMessageSchema.parse(JSON.parse(frame));
|
||||
//#endregion wireSchemas_envelope
|
||||
|
||||
// "Route raw JSON-RPC in a proxy" — branch on method, then the per-method schema.
|
||||
//#region wireSchemas_route
|
||||
import { CallToolRequestSchema } from '@modelcontextprotocol/core';
|
||||
|
||||
if ('method' in message) {
|
||||
switch (message.method) {
|
||||
case 'tools/call': {
|
||||
const call = CallToolRequestSchema.parse(message);
|
||||
console.log(`forward tools/call for ${call.params.name} upstream`);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.log(`forward ${message.method} unchanged`);
|
||||
}
|
||||
}
|
||||
//#endregion wireSchemas_route
|
||||
|
||||
// "Validate OAuth and discovery metadata" — the second export group.
|
||||
//#region wireSchemas_oauthMetadata
|
||||
import { OAuthMetadataSchema } from '@modelcontextprotocol/core';
|
||||
|
||||
// In production this body comes from GET <issuer>/.well-known/oauth-authorization-server.
|
||||
const response = new Response(
|
||||
JSON.stringify({
|
||||
issuer: 'https://auth.example.com',
|
||||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||||
token_endpoint: 'https://auth.example.com/token',
|
||||
response_types_supported: ['code']
|
||||
})
|
||||
);
|
||||
|
||||
const metadata = OAuthMetadataSchema.parse(await response.json());
|
||||
console.log(metadata.token_endpoint);
|
||||
//#endregion wireSchemas_oauthMetadata
|
||||
|
||||
// "Get the TypeScript types, guards and errors from the SDK packages" — core is
|
||||
// Zod values only; the names live in /client and /server.
|
||||
//#region wireSchemas_types
|
||||
import type { CallToolResult } from '@modelcontextprotocol/client';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
// The SDK's spec type and the schema's own inferred output describe the same value.
|
||||
const relayed: CallToolResult = parsed.data;
|
||||
type CallToolResultFromCore = z.infer<typeof CallToolResultSchema>;
|
||||
//#endregion wireSchemas_types
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Self-checks (not shown on the page). Throw — non-zero exit — if any claim
|
||||
// the page makes stops being true.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (malformed.success) {
|
||||
throw new Error('wire-schemas.md claim failed: a non-array `content` must not parse');
|
||||
}
|
||||
if (!('method' in message) || message.method !== 'tools/call') {
|
||||
throw new Error('wire-schemas.md claim failed: the envelope did not narrow to a tools/call request');
|
||||
}
|
||||
if (relayed.content[0]?.type !== 'text') {
|
||||
throw new Error('wire-schemas.md claim failed: parsed result is not assignable to CallToolResult');
|
||||
}
|
||||
Loading…
Reference in a new issue