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

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

View file

@ -0,0 +1,183 @@
/**
* Companion example for `docs/servers/completion.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/servers/completion.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
//#region completable_language
import { completable, McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const languages = ['typescript', 'javascript', 'python', 'rust', 'go'];
const server = new McpServer({ name: 'review', version: '1.0.0' });
server.registerPrompt(
'review-code',
{
description: 'Review code for best practices',
argsSchema: z.object({
language: completable(z.string().describe('Programming language'), value =>
languages.filter(language => language.startsWith(value))
)
})
},
({ language }) => ({
messages: [
{
role: 'user' as const,
content: { type: 'text' as const, text: `Review this ${language} code for best practices.` }
}
]
})
);
//#endregion completable_language
// "Return suggestions from the complete callback" — `repo` completes from an
// async lookup; `branch` points at `completeBranch`, defined in the next region.
//#region registerPrompt_async
const branchesByRepo: Record<string, string[]> = {
'typescript-sdk': ['main', 'release/1.x', 'release/2.x'],
'python-sdk': ['main', 'release/1.x'],
inspector: ['main']
};
async function listRepos(): Promise<string[]> {
return Object.keys(branchesByRepo);
}
server.registerPrompt(
'review-pr',
{
description: 'Review the open pull requests on one branch',
argsSchema: z.object({
repo: completable(z.string().describe('Repository name'), async value => {
const repos = await listRepos();
return repos.filter(repo => repo.startsWith(value));
}),
branch: completable(z.string().describe('Target branch'), completeBranch)
})
},
({ repo, branch }) => ({
messages: [
{
role: 'user' as const,
content: { type: 'text' as const, text: `Review the open pull requests on ${repo}@${branch}.` }
}
]
})
);
//#endregion registerPrompt_async
// "Use the other arguments for context" — `branch` suggestions depend on the
// `repo` the client has already filled in. Function declarations hoist, so the
// registration above can reference this by name.
//#region completeCallback_context
async function completeBranch(value: string, context?: { arguments?: Record<string, string> }): Promise<string[]> {
const repo = context?.arguments?.repo;
if (!repo) return [];
return (branchesByRepo[repo] ?? []).filter(branch => branch.startsWith(value));
}
//#endregion completeCallback_context
// "Complete a resource template variable" — the template's `complete` map.
//#region resourceTemplate_complete
server.registerResource(
'readme',
new ResourceTemplate('repo://{repo}/readme', {
list: undefined,
complete: {
repo: async value => {
const repos = await listRepos();
return repos.filter(repo => repo.startsWith(value));
}
}
}),
{ description: 'The README of one repository' },
async (uri, { repo }) => ({
contents: [{ uri: uri.href, text: `Repository: ${repo}` }]
})
);
//#endregion resourceTemplate_complete
// ---------------------------------------------------------------------------
// Harness (not shown on the page). An in-memory client drives the requests
// whose output servers/completion.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: 'completion-docs-harness', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
// Proof for "Wrap an argument with `completable`": the first completable
// registration advertised the `completions` capability without any declaration.
if (!client.getServerCapabilities()?.completions) {
throw new Error('completion.md claim failed: completions capability was not advertised');
}
// Proof for "Wrap an argument with `completable`": `language` typed as `ty`.
const language = await client.complete({
ref: { type: 'ref/prompt', name: 'review-code' },
argument: { name: 'language', value: 'ty' }
});
if (language.completion.values.join(',') !== 'typescript') {
throw new Error(`completion.md claim failed: language 'ty' -> ${JSON.stringify(language.completion.values)}`);
}
// "Return suggestions from the complete callback" — the async `repo` result the page quotes.
const repo = await client.complete({
ref: { type: 'ref/prompt', name: 'review-pr' },
argument: { name: 'repo', value: 'ty' }
});
console.log(repo.completion);
// "Use the other arguments for context" — the context-narrowed `branch` result the page quotes.
const branch = await client.complete({
ref: { type: 'ref/prompt', name: 'review-pr' },
argument: { name: 'branch', value: 'rel' },
context: { arguments: { repo: 'typescript-sdk' } }
});
console.log(branch.completion);
// "Use the other arguments for context" — without context the callback returns nothing.
const branchNoContext = await client.complete({
ref: { type: 'ref/prompt', name: 'review-pr' },
argument: { name: 'branch', value: 'rel' }
});
if (branchNoContext.completion.values.length !== 0) {
throw new Error(`completion.md claim failed: branch without context -> ${JSON.stringify(branchNoContext.completion.values)}`);
}
// Proof for "Complete a resource template variable": the request targets the
// template by its URI pattern.
const templateRepo = await client.complete({
ref: { type: 'ref/resource', uri: 'repo://{repo}/readme' },
argument: { name: 'repo', value: 'py' }
});
if (templateRepo.completion.values.join(',') !== 'python-sdk') {
throw new Error(`completion.md claim failed: template repo 'py' -> ${JSON.stringify(templateRepo.completion.values)}`);
}
// "Try it from a client" — the call and output the page quotes.
//#region complete_client
const result = await client.complete({
ref: { type: 'ref/prompt', name: 'review-code' },
argument: { name: 'language', value: '' }
});
console.log(result.completion);
//#endregion complete_client
await client.close();
await server.close();

View file

@ -0,0 +1,173 @@
/**
* Companion example for `docs/servers/elicitation.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 drives every elicitation round over an in-memory transport pair and
* prints the output the page quotes verbatim.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
* npx tsx guides/servers/elicitation.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
//#region registerTool_elicitForm
import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const server = new McpServer({ name: 'feedback', version: '1.0.0' });
server.registerTool(
'collect-feedback',
{
description: 'Ask the user how something went',
inputSchema: z.object({ topic: z.string() })
},
async ({ topic }, ctx) => {
const result = await ctx.mcpReq.elicitInput({
mode: 'form',
message: `How was ${topic}?`,
requestedSchema: {
type: 'object',
properties: {
rating: { type: 'number', title: 'Rating (1-5)', minimum: 1, maximum: 5 },
comment: { type: 'string', title: 'Comment' }
},
required: ['rating']
}
});
if (result.action !== 'accept') {
return { content: [{ type: 'text', text: `Feedback ${result.action}.` }] };
}
return { content: [{ type: 'text', text: `Recorded: ${JSON.stringify(result.content)}` }] };
}
);
//#endregion registerTool_elicitForm
// "Handle every action" — a confirmation form whose handler answers all three actions.
//#region registerTool_elicitActions
server.registerTool(
'delete-dataset',
{
description: 'Delete a dataset after the user confirms',
inputSchema: z.object({ name: z.string() })
},
async ({ name }, ctx) => {
const result = await ctx.mcpReq.elicitInput({
mode: 'form',
message: `Delete ${name}? This cannot be undone.`,
requestedSchema: {
type: 'object',
properties: { confirm: { type: 'boolean', title: 'Yes, delete it' } },
required: ['confirm']
}
});
switch (result.action) {
case 'accept':
if (result.content?.confirm !== true) {
return { content: [{ type: 'text', text: 'Box left unchecked - nothing deleted.' }] };
}
return { content: [{ type: 'text', text: `Deleted ${name}.` }] };
case 'decline':
return { content: [{ type: 'text', text: 'Declined - nothing deleted.' }] };
case 'cancel':
return { content: [{ type: 'text', text: 'Dismissed - ask again later.' }] };
}
}
);
//#endregion registerTool_elicitActions
// "Send the end user to a URL" — url mode hands the browser flow to the client.
//#region registerTool_elicitUrl
server.registerTool(
'link-account',
{
description: 'Link a billing account through a hosted sign-in flow',
inputSchema: z.object({ provider: z.string() })
},
async ({ provider }, ctx) => {
const result = await ctx.mcpReq.elicitInput({
mode: 'url',
message: `Sign in to ${provider} to link your account`,
url: `https://billing.example.com/connect/${encodeURIComponent(provider)}`,
elicitationId: crypto.randomUUID()
});
if (result.action !== 'accept') {
return { content: [{ type: 'text', text: `Sign-in ${result.action}.` }] };
}
return { content: [{ type: 'text', text: `Linked ${provider}.` }] };
}
);
//#endregion registerTool_elicitUrl
// ---------------------------------------------------------------------------
// Harness (not shown on the page beyond the two regions below). An in-memory
// client plays the end user; a real host renders UI instead. Imported
// dynamically so the page's lead region stays self-contained.
// ---------------------------------------------------------------------------
const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client');
// The client-side handler the page shows once (the full client story lives in
// docs/clients/server-requests.md).
//#region Client_elicitationHandler
const client = new Client({ name: 'feedback-host', version: '1.0.0' }, { capabilities: { elicitation: { form: {}, url: {} } } });
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 typed.
return { action: 'accept', content: { rating: 5, comment: 'Smooth setup' } };
});
//#endregion Client_elicitationHandler
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
// "Ask for input with a form" — the accept round trip the page quotes.
//#region callTool_collectFeedback
const result = await client.callTool({ name: 'collect-feedback', arguments: { topic: 'the new editor' } });
console.log(result.content);
//#endregion callTool_collectFeedback
// "Send the end user to a URL" — the handler's url branch accepts.
const linked = await client.callTool({ name: 'link-account', arguments: { provider: 'github' } });
console.log(linked.content);
// "Handle every action" — the end user clicks Decline; the harness simulates
// that by swapping in a handler that declines every request.
client.setRequestHandler('elicitation/create', async () => ({ action: 'decline' }));
const declined = await client.callTool({ name: 'delete-dataset', arguments: { name: 'staging-snapshots' } });
console.log(declined.content);
// "Require the elicitation capability" — the same form tool served to a client
// that never declared the elicitation capability. elicitInput throws before
// anything reaches the wire and the message becomes the tool result.
const plainServer = new McpServer({ name: 'feedback', version: '1.0.0' });
plainServer.registerTool(
'collect-feedback',
{ description: 'Ask the user how something went', inputSchema: z.object({ topic: z.string() }) },
async ({ topic }, ctx) => {
const result = await ctx.mcpReq.elicitInput({
mode: 'form',
message: `How was ${topic}?`,
requestedSchema: { type: 'object', properties: { rating: { type: 'number' } }, required: ['rating'] }
});
return { content: [{ type: 'text', text: result.action }] };
}
);
const plainClient = new Client({ name: 'no-elicitation-host', version: '1.0.0' });
const [plainClientTransport, plainServerTransport] = InMemoryTransport.createLinkedPair();
await plainServer.connect(plainServerTransport);
await plainClient.connect(plainClientTransport);
const failed = await plainClient.callTool({ name: 'collect-feedback', arguments: { topic: 'anything' } });
console.log(failed);
await plainClient.close();
await plainServer.close();
await client.close();
await server.close();

View file

@ -0,0 +1,132 @@
/**
* Companion example for `docs/servers/errors.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/servers/errors.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
//#region registerTool_isError
import { McpServer, ProtocolError, ProtocolErrorCode, ResourceNotFoundError, ResourceTemplate } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const notes = new Map([['welcome', 'Read tools.md first.']]);
const server = new McpServer({ name: 'notes', version: '1.0.0' });
server.registerTool(
'read-note',
{
description: 'Read a note by its id',
inputSchema: z.object({ id: z.string() })
},
async ({ id }) => {
const note = notes.get(id);
if (!note) {
return {
content: [{ type: 'text', text: `No note with id "${id}". Known ids: ${[...notes.keys()].join(', ')}` }],
isError: true
};
}
return { content: [{ type: 'text', text: note }] };
}
);
//#endregion registerTool_isError
//#region registerTool_throw
server.registerTool(
'delete-note',
{
description: 'Delete a note by its id',
inputSchema: z.object({ id: z.string() })
},
async ({ id }) => {
if (!notes.delete(id)) {
throw new Error(`Cannot delete "${id}": no such note`);
}
return { content: [{ type: 'text', text: `Deleted "${id}"` }] };
}
);
//#endregion registerTool_throw
//#region registerResource_protocolError
server.registerResource(
'note',
new ResourceTemplate('note://{id}', { list: undefined }),
{ description: 'A note by its id' },
async (uri, { id }) => {
const noteId = String(id);
if (!/^[a-z]+$/.test(noteId)) {
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Note ids are lowercase letters, got "${noteId}"`);
}
const note = notes.get(noteId);
if (!note) throw new ResourceNotFoundError(uri.href);
return { contents: [{ uri: uri.href, text: note }] };
}
);
//#endregion registerResource_protocolError
// ---------------------------------------------------------------------------
// Harness (not shown on the page). An in-memory client drives the calls whose
// output servers/errors.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: 'errors-docs-harness', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
// "Return a tool error with isError" — the result the page quotes.
//#region callTool_isError
const missing = await client.callTool({ name: 'read-note', arguments: { id: 'drafts' } });
console.log(missing);
//#endregion callTool_isError
// "Let a thrown exception become a tool error" — the converted result the page quotes.
//#region callTool_throw
const thrown = await client.callTool({ name: 'delete-note', arguments: { id: 'drafts' } });
console.log(thrown);
//#endregion callTool_throw
// "Throw a protocol error" — the JSON-RPC error the page quotes.
//#region readResource_protocolError
try {
await client.readResource({ uri: 'note://42' });
} catch (error) {
const { code, message } = error as ProtocolError;
console.log({ code, message });
}
//#endregion readResource_protocolError
// "Use the typed error subclasses" — the structured data the page quotes.
//#region readResource_notFound
try {
await client.readResource({ uri: 'note://archived' });
} catch (error) {
const { code, message, data } = error as ResourceNotFoundError;
console.log({ code, message, data });
}
//#endregion readResource_notFound
// Proof for the page's claim that a thrown ProtocolError inside a TOOL handler
// is still converted to an `isError: true` result, never a JSON-RPC error.
// Throws (non-zero exit) if the claim is false.
server.registerTool('always-protocol-error', { description: 'Throws a ProtocolError' }, async () => {
throw new ProtocolError(ProtocolErrorCode.InternalError, 'unreachable as a protocol error');
});
const converted = await client.callTool({ name: 'always-protocol-error', arguments: {} });
if (converted.isError !== true) {
throw new Error(`errors.md claim failed: a ProtocolError thrown from a tool handler was not converted: ${JSON.stringify(converted)}`);
}
await client.close();
await server.close();

View file

@ -0,0 +1,243 @@
/**
* Companion example for `docs/servers/input-required.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 with an elicitation handler and
* produces the output the page quotes verbatim.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
* npx tsx guides/servers/input-required.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
import type { CallToolResult, ElicitResult, InputRequiredResult, ProtocolError } from '@modelcontextprotocol/server';
import {
acceptedContent,
createRequestStateCodec,
inputRequired,
inputResponse,
McpServer,
ProtocolErrorCode
} from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
// "Protect `requestState` with the codec" — the page shows this block AFTER the
// handlers that use `stateCodec`, but module evaluation needs it first.
//#region requestState_codec
const stateCodec = createRequestStateCodec<{ step: string }>({
key: crypto.getRandomValues(new Uint8Array(32)), // >= 32 bytes; share it across instances in a fleet
ttlSeconds: 600
});
const server = new McpServer({ name: 'releases', version: '1.0.0' }, { requestState: { verify: stateCodec.verify } });
//#endregion requestState_codec
// "Return `input_required` instead of pushing a request"
//#region registerTool_inputRequired
server.registerTool(
'deploy',
{
description: 'Deploy after the operator confirms',
inputSchema: z.object({ env: z.string() })
},
async ({ env }, ctx): Promise<CallToolResult | InputRequiredResult> => {
const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm');
if (confirmed?.confirm !== true) {
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: `Deploy to ${env}?`,
requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] }
})
}
});
}
return { content: [{ type: 'text', text: `Deployed to ${env}` }] };
}
);
//#endregion registerTool_inputRequired
// "Read the responses on re-entry" — the schema-aware overload + the declined branch.
//#region acceptedContent_schema
server.registerTool(
'tag-release',
{
description: 'Tag a release after the operator confirms',
inputSchema: z.object({ tag: z.string() })
},
async ({ tag }, ctx): Promise<CallToolResult | InputRequiredResult> => {
const view = inputResponse(ctx.mcpReq.inputResponses, 'confirm');
if (view.kind === 'elicit' && view.action !== 'accept') {
return { content: [{ type: 'text', text: 'Tagging cancelled by the operator' }], isError: true };
}
const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', z.object({ confirm: z.boolean() }));
if (confirmed?.confirm !== true) {
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: `Tag ${tag}?`,
requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] }
})
}
});
}
return { content: [{ type: 'text', text: `Tagged ${tag}` }] };
}
);
//#endregion acceptedContent_schema
// "Write the handler write-once" — two missing inputs requested in one round.
//#region registerTool_writeOnce
server.registerTool(
'provision',
{ description: 'Provision a database', inputSchema: z.object({}) },
async (_args, ctx): Promise<CallToolResult | InputRequiredResult> => {
const name = acceptedContent(ctx.mcpReq.inputResponses, 'name', z.object({ name: z.string() }));
const region = acceptedContent(ctx.mcpReq.inputResponses, 'region', z.object({ region: z.string() }));
if (name === undefined || region === undefined) {
return inputRequired({
inputRequests: {
...(name === undefined && {
name: inputRequired.elicit({
message: 'Database name?',
requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
})
}),
...(region === undefined && {
region: inputRequired.elicit({
message: 'Which region?',
requestedSchema: { type: 'object', properties: { region: { type: 'string' } }, required: ['region'] }
})
})
}
});
}
return { content: [{ type: 'text', text: `Provisioned ${name.name} in ${region.region}` }] };
}
);
//#endregion registerTool_writeOnce
// "Carry state across rounds with `requestState`" — two sequential rounds.
//#region requestState_mint
server.registerTool(
'wipe-cache',
{ description: 'Confirm, then pick a scope, then wipe', inputSchema: z.object({}) },
async (_args, ctx): Promise<CallToolResult | InputRequiredResult> => {
const state = ctx.mcpReq.requestState<{ step: string }>();
if (state?.step !== 'confirmed') {
const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm');
if (confirmed?.confirm !== true) {
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: 'Really wipe the cache?',
requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] }
})
}
});
}
// Mint only what the response above already proved: the operator confirmed.
return inputRequired({
inputRequests: {
scope: inputRequired.elicit({
message: 'Which scope?',
requestedSchema: { type: 'object', properties: { scope: { type: 'string' } }, required: ['scope'] }
})
},
requestState: await stateCodec.mint({ step: 'confirmed' })
});
}
const scope = acceptedContent<{ scope: string }>(ctx.mcpReq.inputResponses, 'scope');
return { content: [{ type: 'text', text: `Wiped ${scope?.scope ?? 'all'}` }] };
}
);
//#endregion requestState_mint
/** "Pick the embedded request kind" — all four builders in one map (typecheck-only). */
export function inputRequired_kinds(): InputRequiredResult {
//#region inputRequired_kinds
const next = inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: 'Continue?',
requestedSchema: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'] }
}),
signin: inputRequired.elicitUrl({ message: 'Sign in to continue', url: 'https://example.com/auth' }),
summary: inputRequired.createMessage({
messages: [{ role: 'user', content: { type: 'text', text: 'Summarize the diff' } }],
maxTokens: 200
}),
roots: inputRequired.listRoots()
}
});
//#endregion inputRequired_kinds
return next;
}
// ---------------------------------------------------------------------------
// Harness (not shown on the page). An in-memory client with an elicitation
// handler drives the calls whose output servers/input-required.md quotes
// verbatim. Any MCP client behaves the same; the SDK's legacy shim fulfils
// these `input_required` returns over the 2025-era linked pair.
// ---------------------------------------------------------------------------
const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client');
const client = new Client({ name: 'input-required-docs-harness', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } });
const answers: Record<string, Record<string, string | boolean>> = {
'Deploy to prod?': { confirm: true },
'Database name?': { name: 'analytics' },
'Which region?': { region: 'eu-west-1' },
'Really wipe the cache?': { confirm: true },
'Which scope?': { scope: 'sessions' }
};
const acceptHandler = async (request: { params: { message: string } }): Promise<ElicitResult> => {
console.log('[client] elicitation/create →', request.params.message);
const content = answers[request.params.message];
return content === undefined ? { action: 'decline' } : { action: 'accept', content };
};
client.setRequestHandler('elicitation/create', acceptHandler);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
// "Return `input_required` instead of pushing a request" — the round trip the page quotes.
console.log(await client.callTool({ name: 'deploy', arguments: { env: 'prod' } }));
// "Read the responses on re-entry" — the declined branch the page quotes.
client.setRequestHandler('elicitation/create', async (request): Promise<ElicitResult> => {
console.log('[client] elicitation/create →', request.params.message);
return { action: 'decline' };
});
console.log(await client.callTool({ name: 'tag-release', arguments: { tag: 'v2.1.0' } }));
client.setRequestHandler('elicitation/create', acceptHandler);
// "Write the handler write-once" — both missing inputs requested in one round.
console.log(await client.callTool({ name: 'provision', arguments: {} }));
// "Carry state across rounds with `requestState`" — two sequential rounds.
console.log(await client.callTool({ name: 'wipe-cache', arguments: {} }));
// "Protect `requestState` with the codec" — tampered state answers -32602.
// Matched by `code`, not `instanceof` (see docs/servers/errors.md): `instanceof`
// fails across separately bundled copies of the SDK.
try {
await client.request({
method: 'tools/call',
params: { name: 'wipe-cache', arguments: {}, requestState: 'tampered' }
});
} catch (error) {
const { code, message } = error as ProtocolError;
if (code !== ProtocolErrorCode.InvalidParams) throw error;
console.log(`${code} ${message}`);
}
await client.close();
await server.close();

View file

@ -0,0 +1,168 @@
/**
* Companion example for `docs/servers/logging-progress-cancellation.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
* server regions connects an in-memory client and produces the output the
* page quotes verbatim.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
* npx tsx guides/servers/logging-progress-cancellation.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
// "Log to the client" — the logging capability is declared at construction.
//#region logging_capability
const server = new McpServer({ name: 'file-processor', version: '1.0.0' }, { capabilities: { logging: {} } });
//#endregion logging_capability
// "Report progress from a handler" — the page's lead block.
//#region registerTool_progress
server.registerTool(
'process-files',
{
description: 'Process files with progress updates',
inputSchema: z.object({ files: z.array(z.string()) })
},
async ({ files }, ctx) => {
const progressToken = ctx.mcpReq._meta?.progressToken;
for (let i = 0; i < files.length; i++) {
// ... process files[i] ...
if (progressToken !== undefined) {
await ctx.mcpReq.notify({
method: 'notifications/progress',
params: { progressToken, progress: i + 1, total: files.length, message: `Processed ${files[i]}` }
});
}
}
return { content: [{ type: 'text', text: `Processed ${files.length} files` }] };
}
);
//#endregion registerTool_progress
// "Log to the client" — `ctx.mcpReq.log(level, data)` inside a handler.
//#region registerTool_logging
server.registerTool(
'validate-records',
{
description: 'Validate records before import',
inputSchema: z.object({ records: z.array(z.string()) })
},
async ({ records }, ctx) => {
await ctx.mcpReq.log('info', `Validating ${records.length} records`);
const invalid = records.filter(record => !record.endsWith('.csv'));
if (invalid.length > 0) {
await ctx.mcpReq.log('warning', { invalid });
}
return { content: [{ type: 'text', text: `${records.length - invalid.length} of ${records.length} records are valid` }] };
}
);
//#endregion registerTool_logging
// "Stop work when the request is cancelled" — check `ctx.mcpReq.signal`.
//#region registerTool_abort
server.registerTool(
'scan-archive',
{
description: 'Scan every page of the archive',
inputSchema: z.object({ pages: z.number().int() })
},
async ({ pages }, ctx) => {
let scanned = 0;
for (let page = 0; page < pages; page++) {
if (ctx.mcpReq.signal.aborted) {
console.error(`Stopped after ${scanned} of ${pages} pages: ${ctx.mcpReq.signal.reason}`);
break;
}
await new Promise(resolve => setTimeout(resolve, 100)); // ... scan one page ...
scanned++;
}
return { content: [{ type: 'text', text: `Scanned ${scanned} pages` }] };
}
);
//#endregion registerTool_abort
// "Pass the signal to your own I/O" — registered for the page, never called by
// the harness (it would hit the network).
//#region registerTool_forwardSignal
const SOURCE_URLS = {
readme: 'https://example.com/sources/readme.md',
changelog: 'https://example.com/sources/changelog.md'
};
server.registerTool(
'fetch-source',
{
description: 'Download one of the known source files',
inputSchema: z.object({ source: z.enum(['readme', 'changelog']) })
},
async ({ source }, ctx) => {
const response = await fetch(SOURCE_URLS[source], { signal: ctx.mcpReq.signal });
return { content: [{ type: 'text', text: await response.text() }] };
}
);
//#endregion registerTool_forwardSignal
// ---------------------------------------------------------------------------
// Harness (not shown on the page). An in-memory client drives the calls whose
// output the page quotes verbatim. Any MCP client behaves the same.
// Imported dynamically so the page's server regions stay self-contained.
// ---------------------------------------------------------------------------
const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client');
const client = new Client({ name: 'lpc-docs-harness', version: '1.0.0' });
// "Log to the client" — the client surfaces `notifications/message`.
//#region setNotificationHandler_message
client.setNotificationHandler('notifications/message', notification => {
console.log(notification.params.level, notification.params.data);
});
//#endregion setNotificationHandler_message
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
// "Report progress from a handler" — `onprogress` opts the call in.
//#region callTool_onprogress
const result = await client.callTool(
{ name: 'process-files', arguments: { files: ['a.csv', 'b.csv', 'c.csv'] } },
{ onprogress: update => console.log(update) }
);
console.log(result.content);
//#endregion callTool_onprogress
// "Skip progress when the client did not ask" — same call, no `onprogress`.
//#region callTool_noProgress
const quiet = await client.callTool({ name: 'process-files', arguments: { files: ['d.csv', 'e.csv'] } });
console.log(quiet.content);
//#endregion callTool_noProgress
// "Log to the client" — both `log` calls land before the result.
const validated = await client.callTool({ name: 'validate-records', arguments: { records: ['a.csv', 'b.txt'] } });
console.log(validated.content);
// "Stop work when the request is cancelled".
//#region callTool_abort
const controller = new AbortController();
const scan = client.callTool({ name: 'scan-archive', arguments: { pages: 40 } }, { signal: controller.signal });
// the end user clicks Stop while the scan runs
setTimeout(() => controller.abort('the end user clicked Stop'), 5);
await scan.catch(error => console.log(String(error)));
//#endregion callTool_abort
// Give the cancelled handler time to observe the abort and stop.
await new Promise(resolve => setTimeout(resolve, 250));
await client.close();
await server.close();

View file

@ -0,0 +1,119 @@
/**
* Companion example for `docs/servers/notifications.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
* lead region connects an in-memory client whose notification log the page
* quotes verbatim.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
* npx tsx guides/servers/notifications.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
//#region notifications_server
import { McpServer } from '@modelcontextprotocol/server';
const jobs = ['nightly-backup'];
const server = new McpServer({ name: 'jobs', version: '1.0.0' });
server.registerTool('list-jobs', { description: 'List the configured jobs' }, async () => ({
content: [{ type: 'text', text: jobs.join('\n') }]
}));
//#endregion notifications_server
// Symbols the later sections use, imported here so the page's lead block shows
// only what it teaches. A real server imports everything in one statement.
import { createMcpHandler, InMemoryServerEventBus, Server } from '@modelcontextprotocol/server';
// ---------------------------------------------------------------------------
// Harness (not shown on the page). An in-memory client connected to the server
// above logs every list-changed notification it receives — the output the
// page quotes verbatim. Any MCP client behaves the same.
// ---------------------------------------------------------------------------
const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client');
const client = new Client({ name: 'notifications-docs-harness', version: '1.0.0' });
for (const method of [
'notifications/tools/list_changed',
'notifications/prompts/list_changed',
'notifications/resources/list_changed'
] as const) {
client.setNotificationHandler(method, async () => console.log(method));
}
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
// "Send a list-changed notification" — the explicit push the page quotes.
//#region sendToolListChanged_basic
server.sendToolListChanged();
//#endregion sendToolListChanged_basic
// A round-trip so the notification above flushes before the next region runs;
// the page quotes its single output line on its own.
await client.listTools();
// "Let registration changes notify for you" — three more sends, none explicit.
//#region registeredTool_update
const report = server.registerTool('run-report', { description: 'Run the weekly report' }, async () => ({
content: [{ type: 'text', text: 'report queued' }]
}));
report.update({ description: 'Run the weekly report and email it' });
report.disable();
//#endregion registeredTool_update
await client.listTools();
await client.close();
await server.close();
// ---------------------------------------------------------------------------
// "Advertise the listChanged capability" — the low-level Server needs it
// declared up front. Constructed only; nothing on the page quotes output here.
// ---------------------------------------------------------------------------
//#region Server_listChanged
const lowLevel = new Server({ name: 'jobs', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } });
//#endregion Server_listChanged
void lowLevel;
// ---------------------------------------------------------------------------
// "Publish a resource update through the handler". The factory the page refers
// to: a fresh McpServer per request, advertising `resources.subscribe` so the
// entry honors per-resource subscriptions.
// ---------------------------------------------------------------------------
function buildJobsServer(): McpServer {
const app = new McpServer({ name: 'jobs', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } });
app.registerResource('config', 'config://app', { mimeType: 'application/json' }, async uri => ({
contents: [{ uri: uri.href, text: JSON.stringify({ jobs }) }]
}));
return app;
}
// Runs as written: with no subscription stream open the publish is a no-op,
// and `createMcpHandler` binds no port.
//#region handler_notifyResourceUpdated
const handler = createMcpHandler(() => buildJobsServer());
// After config://app changes:
handler.notify.resourceUpdated('config://app');
//#endregion handler_notifyResourceUpdated
await handler.close();
// "Pick an event bus for multi-process deployments" — typecheck-only wrapper.
function createMcpHandler_bus() {
//#region createMcpHandler_bus
const bus = new InMemoryServerEventBus();
const shared = createMcpHandler(() => buildJobsServer(), { bus });
//#endregion createMcpHandler_bus
return shared;
}
void createMcpHandler_bus;

View file

@ -0,0 +1,163 @@
/**
* Companion example for `docs/servers/prompts.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/servers/prompts.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
//#region registerPrompt_review
import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const server = new McpServer({ name: 'review', version: '1.0.0' });
server.registerPrompt(
'review-code',
{
title: 'Code Review',
description: 'Review code for best practices and potential issues',
argsSchema: z.object({
code: z.string().describe('The code to review')
})
},
({ code }) => ({
messages: [
{
role: 'user' as const,
content: { type: 'text' as const, text: `Review this code:\n\n${code}` }
}
]
})
);
//#endregion registerPrompt_review
//#region registerPrompt_messages
server.registerPrompt(
'explain-error',
{
description: 'Explain a compiler error and suggest the smallest fix',
argsSchema: z.object({ error: z.string() })
},
({ error }) => ({
messages: [
{
role: 'user' as const,
content: { type: 'text' as const, text: `Explain this compiler error:\n\n${error}` }
},
{
role: 'assistant' as const,
content: { type: 'text' as const, text: 'The one-line cause:' }
}
]
})
);
//#endregion registerPrompt_messages
//#region registerPrompt_embedResource
const styleGuide = '- Prefer const over let.\n- No single-letter identifiers.';
server.registerResource('style-guide', 'doc://style-guide', { mimeType: 'text/markdown' }, async uri => ({
contents: [{ uri: uri.href, mimeType: 'text/markdown', text: styleGuide }]
}));
server.registerPrompt(
'review-against-style',
{
description: 'Review code against the team style guide',
argsSchema: z.object({ code: z.string() })
},
({ code }) => ({
messages: [
{
role: 'user' as const,
content: {
type: 'resource' as const,
resource: { uri: 'doc://style-guide', mimeType: 'text/markdown', text: styleGuide }
}
},
{
role: 'user' as const,
content: { type: 'text' as const, text: `Review this code against the style guide:\n\n${code}` }
}
]
})
);
//#endregion registerPrompt_embedResource
//#region registerPrompt_completable
import { completable } from '@modelcontextprotocol/server';
server.registerPrompt(
'translate',
{
description: 'Translate a snippet into another language',
argsSchema: z.object({
language: completable(z.string(), value =>
['typescript', 'python', 'rust', 'go'].filter(language => language.startsWith(value))
),
code: z.string()
})
},
({ language, code }) => ({
messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `Translate to ${language}:\n\n${code}` } }]
})
);
//#endregion registerPrompt_completable
// ---------------------------------------------------------------------------
// Harness (not shown on the page). An in-memory client drives the calls whose
// output servers/prompts.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: 'prompts-docs-harness', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
// "Register a prompt" — the prose claims `prompts/list` advertises `review-code`
// with one required `code` argument carrying the `.describe()` string. Throws
// (non-zero exit) if the claim is false.
const { prompts } = await client.listPrompts();
const reviewPrompt = prompts.find(prompt => prompt.name === 'review-code');
const codeArgument = reviewPrompt?.arguments?.find(argument => argument.name === 'code');
if (codeArgument?.required !== true || codeArgument.description !== 'The code to review') {
throw new Error(`prompts.md claim failed: review-code argument is ${JSON.stringify(reviewPrompt?.arguments)}`);
}
// "Register a prompt" — the filled-in messages the page quotes.
//#region getPrompt_review
const result = await client.getPrompt({ name: 'review-code', arguments: { code: 'let x = 1' } });
console.log(result.messages);
//#endregion getPrompt_review
// "Validate the arguments with the schema" — the rejection the page quotes.
//#region getPrompt_invalid
import type { ProtocolError } from '@modelcontextprotocol/client';
try {
await client.getPrompt({ name: 'review-code', arguments: {} });
} catch (error) {
const { code, message } = error as ProtocolError;
console.log(code, message);
}
//#endregion getPrompt_invalid
// "Embed a resource in a message" — the embedded-resource message the page quotes.
const review = await client.getPrompt({
name: 'review-against-style',
arguments: { code: 'let n = 1' }
});
console.log(review.messages[0]);
await client.close();
await server.close();

View file

@ -0,0 +1,157 @@
/**
* Companion example for `docs/servers/resources.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/servers/resources.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
//#region registerResource_static
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
const server = new McpServer({ name: 'workspace', version: '1.0.0' });
server.registerResource(
'config',
'config://app',
{
title: 'Application Config',
description: 'Application configuration data',
mimeType: 'text/plain'
},
async uri => ({
contents: [{ uri: uri.href, text: 'log_level=info\nregion=eu-west-1' }]
})
);
//#endregion registerResource_static
//#region registerResource_report
// A 1x1 PNG; a production server reads these bytes from disk or object storage.
const chartPng = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=';
server.registerResource(
'report',
'report://latest',
{
title: 'Latest usage report',
description: 'Weekly usage summary with a rendered chart',
mimeType: 'text/markdown'
},
async uri => ({
contents: [
{ uri: uri.href, mimeType: 'text/markdown', text: 'Active installs grew 12% week over week.' },
{ uri: uri.href, mimeType: 'image/png', blob: chartPng }
]
})
);
//#endregion registerResource_report
//#region registerResource_template
server.registerResource(
'user-profile',
new ResourceTemplate('users://{userId}/profile', { list: undefined }),
{
title: 'User Profile',
description: 'Profile data for one user',
mimeType: 'application/json'
},
async (uri, { userId }) => ({
contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ userId, plan: 'pro' }) }]
})
);
//#endregion registerResource_template
//#region registerResource_list
server.registerResource(
'team-roster',
new ResourceTemplate('teams://{teamId}/roster', {
list: async () => ({
resources: [
{ uri: 'teams://core/roster', name: 'Core team roster' },
{ uri: 'teams://growth/roster', name: 'Growth team roster' }
]
})
}),
{
description: 'Members of one team',
mimeType: 'text/plain'
},
async (uri, { teamId }) => ({
contents: [{ uri: uri.href, text: `Members of team ${teamId}` }]
})
);
//#endregion registerResource_list
//#region registerResource_file
import { readFile, realpath } from 'node:fs/promises';
import path from 'node:path';
const DOCS_ROOT = path.resolve('./docs');
server.registerResource(
'doc',
new ResourceTemplate('docs://{file}', { list: undefined }),
{
description: 'A markdown page from the docs directory',
mimeType: 'text/markdown'
},
async (uri, { file }) => {
const requested = await realpath(path.join(DOCS_ROOT, String(file)));
if (!requested.startsWith(DOCS_ROOT + path.sep)) {
throw new Error(`${uri.href} resolves outside the docs root`);
}
return { contents: [{ uri: uri.href, text: await readFile(requested, 'utf8') }] };
}
);
//#endregion registerResource_file
// ---------------------------------------------------------------------------
// Harness (not shown on the page). An in-memory client drives the calls whose
// output servers/resources.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: 'resources-docs-harness', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
// "Return the contents from the read callback" — the two-item result the page quotes.
//#region readResource_report
const { contents } = await client.readResource({ uri: 'report://latest' });
console.log(contents);
//#endregion readResource_report
// "Add a resource template" — the parameterized read the page quotes.
//#region readResource_template
const profile = await client.readResource({ uri: 'users://7/profile' });
console.log(profile.contents);
//#endregion readResource_template
// "List the template's instances" — the merged list the page quotes. Must contain
// the static URIs and the team:// instances, and no users:// URI.
//#region listResources
const { resources } = await client.listResources();
console.log(resources.map(resource => resource.uri));
//#endregion listResources
const uris = resources.map(resource => resource.uri);
if (uris.some(uri => uri.startsWith('users://')) || !uris.includes('teams://core/roster')) {
throw new Error(`resources.md list claim failed: ${JSON.stringify(uris)}`);
}
// "Tell clients when a resource changes" — explicit list_changed.
//#region sendResourceListChanged
server.sendResourceListChanged();
//#endregion sendResourceListChanged
await client.close();
await server.close();

View file

@ -0,0 +1,89 @@
/**
* Companion example for `docs/servers/sampling.md`.
*
* The `ts` fence on that page is synced from the `//#region` in this file
* (`pnpm sync:snippets --check`). The file also runs: the harness below the
* region connects in-memory clients whose sampling handlers stand in for a
* host LLM, and produces the output the page quotes verbatim.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
* npx tsx guides/servers/sampling.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
// The page's one code block. Wrapped in a function so the harness can stand up
// two independent server instances (one per client) without duplicating it.
function registerTool_sampling(server: McpServer) {
//#region registerTool_sampling
server.registerTool(
'summarize',
{
description: 'Summarize text using the client LLM',
inputSchema: z.object({ text: z.string() })
},
async ({ text }, ctx) => {
const response = await ctx.mcpReq.requestSampling({
messages: [{ role: 'user', content: { type: 'text', text: `Summarize in one sentence:\n\n${text}` } }],
maxTokens: 500
});
return { content: [{ type: 'text', text: `Model (${response.model}): ${JSON.stringify(response.content)}` }] };
}
);
//#endregion registerTool_sampling
}
// ---------------------------------------------------------------------------
// Harness (not shown on the page). An in-memory client declares the sampling
// capability and answers `sampling/createMessage` the way a host would — by
// running the prompt through its model. Here the "model" is canned so the run
// is deterministic; any MCP client behaves the same over stdio or HTTP.
// ---------------------------------------------------------------------------
const server = new McpServer({ name: 'summarizer', version: '1.0.0' });
registerTool_sampling(server);
const client = new Client({ name: 'sampling-docs-harness', version: '1.0.0' }, { capabilities: { sampling: {} } });
client.setRequestHandler('sampling/createMessage', async () => {
return {
model: 'host-model',
role: 'assistant' as const,
content: { type: 'text' as const, text: 'Sampling lets a tool ask the client for a completion.' }
};
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
// "Read the model's reply" — the result the page quotes verbatim.
const result = await client.callTool({
name: 'summarize',
arguments: { text: 'Sampling is a server-to-client request for an LLM completion...' }
});
console.log(result.content);
await client.close();
await server.close();
// "Require the sampling capability" — a strict server and a client that never
// declared `sampling`. The SDK rejects the request before it reaches the wire;
// the page quotes the resulting tool error verbatim.
const bareServer = new McpServer({ name: 'summarizer', version: '1.0.0' }, { enforceStrictCapabilities: true });
registerTool_sampling(bareServer);
const bare = new Client({ name: 'no-sampling-harness', version: '1.0.0' });
const [bareClientTransport, bareServerTransport] = InMemoryTransport.createLinkedPair();
await bareServer.connect(bareServerTransport);
await bare.connect(bareClientTransport);
const rejected = await bare.callTool({ name: 'summarize', arguments: { text: 'anything' } });
console.log(rejected);
await bare.close();
await bareServer.close();

View file

@ -0,0 +1,119 @@
/**
* Companion example for `docs/servers/tools.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/servers/tools.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
//#region registerTool_search
import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const catalog = [
{ name: 'Espresso cup', price: 12 },
{ name: 'Travel mug', price: 24 },
{ name: 'Mug rack', price: 36 }
];
const server = new McpServer({ name: 'catalog', version: '1.0.0' });
server.registerTool(
'search',
{
description: 'Search the product catalog',
inputSchema: z.object({
query: z.string().describe('Substring to match against product names'),
limit: z.number().int().max(50).optional()
})
},
async ({ query, limit }) => {
const hits = catalog.filter(product => product.name.toLowerCase().includes(query.toLowerCase()));
const names = hits.slice(0, limit ?? 10).map(product => product.name);
return { content: [{ type: 'text', text: names.join('\n') }] };
}
);
//#endregion registerTool_search
//#region registerTool_structured
server.registerTool(
'product-details',
{
description: 'Look up one product by its exact name',
inputSchema: z.object({ name: z.string() }),
outputSchema: z.object({ name: z.string(), price: z.number() })
},
async ({ name }) => {
const product = catalog.find(candidate => candidate.name === name);
if (!product) throw new Error(`No product named ${name}`);
const output = { name: product.name, price: product.price };
return {
content: [{ type: 'text', text: JSON.stringify(output) }],
structuredContent: output
};
}
);
//#endregion registerTool_structured
//#region registerTool_annotations
server.registerTool(
'clear-catalog',
{
title: 'Clear the catalog',
description: 'Remove every product from the catalog',
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }
},
async () => {
catalog.length = 0;
return { content: [{ type: 'text', text: 'Catalog cleared' }] };
}
);
//#endregion registerTool_annotations
// ---------------------------------------------------------------------------
// Harness (not shown on the page). An in-memory client drives the calls whose
// output servers/tools.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: 'tools-docs-harness', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
// "Call it" — the happy path the page quotes.
//#region callTool_search
const result = await client.callTool({ name: 'search', arguments: { query: 'mug' } });
console.log(result.content);
//#endregion callTool_search
// "Send arguments the schema rejects" — the rejection the page quotes.
//#region callTool_invalid
const rejected = await client.callTool({ name: 'search', arguments: { query: 'mug', limit: 999 } });
console.log(rejected);
//#endregion callTool_invalid
// "Return structured output" — the structured result the page quotes.
const details = await client.callTool({ name: 'product-details', arguments: { name: 'Travel mug' } });
console.log(details);
// Proof for the page's ::: tip — `.describe()` lands in the JSON Schema that
// `tools/list` advertises for the `query` argument. Throws (non-zero exit) if
// the claim is false.
const { tools } = await client.listTools();
const searchTool = tools.find(tool => tool.name === 'search');
const properties = searchTool?.inputSchema.properties as Record<string, { description?: string }> | undefined;
if (properties?.['query']?.description !== 'Substring to match against product names') {
throw new Error(`tools.md tip claim failed: query.description is ${JSON.stringify(properties?.['query'])}`);
}
await client.close();
await server.close();