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

View file

@ -0,0 +1,131 @@
---
shape: how-to
---
# Custom methods
A **custom method** is a JSON-RPC method outside the MCP specification. Prefix it with a vendor namespace — `acme/search`, never a bare `search` — so it can never collide with a spec method.
## Handle a vendor-prefixed method on the server
`setRequestHandler` lives on the low-level [`Server`](./low-level-server.md), reached from an `McpServer` as `mcp.server`. A non-spec method needs schemas: pass `{ params, result }` as the second argument and the handler receives the parsed `params` object directly.
```ts source="../../examples/guides/advanced/custom-methods.examples.ts#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}`) };
});
```
The SDK validates incoming `params` against `SearchParams` before the handler runs; `result` types the handler's return value. A spec method never takes the schema bundle — `setRequestHandler('tools/call', handler)` resolves its schemas from the method name.
::: tip
Send `acme/search` with `query: 42` and the request fails before your handler runs — the caller gets back an `Invalid params` JSON-RPC error:
```
Invalid params for acme/search: query: Invalid input: expected string, received number
```
:::
## Call it from the client
Every call on this page comes from an in-memory `Client` connected to the server above — [Test a server](../testing.md) shows that wiring. For a non-spec method, `client.request` takes the request and a result schema; the SDK validates the response against it before the promise resolves and infers the return type from it.
```ts source="../../examples/guides/advanced/custom-methods.examples.ts#request_custom"
const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult);
console.log(result);
```
The handler's return value comes back validated and typed:
```
{ items: [ 'mcp-0', 'mcp-1', 'mcp-2' ] }
```
::: info
For spec methods, `client.request({ method: 'tools/list' })` takes no schema — the SDK resolves it from the method name, exactly as `setRequestHandler` does on the server.
:::
## Send a custom notification from the handler
A custom **notification** is the one-way mirror of a custom request: vendor-prefixed, no result. Registering a method again replaces its handler — replace `acme/search` with one that reports progress through `ctx.mcpReq.notify`.
```ts source="../../examples/guides/advanced/custom-methods.examples.ts#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 };
});
```
`ctx.mcpReq.notify` sends each notification to the peer whose request is being handled, on the same connection.
## Receive it on the client
`setNotificationHandler` follows the same rule as `setRequestHandler`: a non-spec notification method takes a `{ params }` schema, and the handler receives the parsed params.
```ts source="../../examples/guides/advanced/custom-methods.examples.ts#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);
```
That one call logs both stages:
```
{ stage: 'start', pct: 0 }
{ stage: 'done', pct: 1 }
```
## Declare an extension capability
An **extension capability** advertises a vendor feature during capability negotiation: `capabilities.extensions` maps a prefix-qualified extension identifier to that extension's settings object. Declare entries with `registerCapabilities` before connecting.
```ts source="../../examples/guides/advanced/custom-methods.examples.ts#registerCapabilities_extensions"
mcp.server.registerCapabilities({
extensions: { 'com.example/feature-flags': { flags: ['dark-mode', 'beta-search'] } }
});
```
Every client that connects sees the entry. The settings value is free-form JSON; `{}` means supported with no settings.
## Read the negotiated extensions on the client
After connecting, the advertised map is on `client.getServerCapabilities()`.
```ts source="../../examples/guides/advanced/custom-methods.examples.ts#getServerCapabilities_extensions"
const extensions = client.getServerCapabilities()?.extensions ?? {};
console.log(extensions);
```
The map arrives exactly as the server declared it:
```
{
'com.example/feature-flags': { flags: [ 'dark-mode', 'beta-search' ] }
}
```
Legacy connections advertise it in the `initialize` result and 2026-07-28 connections in `server/discover` — see [Protocol versions](../protocol-versions.md).
## Recap
- `setRequestHandler(method, { params, result }, handler)` handles a non-spec method; spec methods never take the schema bundle.
- The SDK validates incoming `params` before the handler runs and rejects what fails with an `Invalid params` error; `result` types the handler's return value.
- `client.request(request, ResultSchema)` is the calling side; the SDK validates the response against the schema.
- Custom notifications mirror custom requests: `ctx.mcpReq.notify` on one side, `setNotificationHandler` with `{ params }` on the other.
- `capabilities.extensions` advertises a vendor feature before connecting; the client reads the negotiated map after.
- Method names and extension identifiers are prefix-qualified (`acme/search`, `com.example/feature-flags`) — never bare words.

View file

@ -0,0 +1,203 @@
---
shape: how-to
---
# Custom transports
A **transport** moves `JSONRPCMessage` values in both directions over a channel the SDK knows nothing about. Implement the `Transport` interface and `connect()` accepts it like a built-in one.
## Implement the `Transport` interface
Three methods — `start`, `send`, `close` — and three callbacks the SDK installs: `onmessage`, `onerror`, `onclose`. This loopback delivers each message straight to a linked peer in the same process.
```ts source="../../examples/guides/advanced/custom-transports.examples.ts#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?.();
}
}
```
The SDK never looks inside your channel: it calls `send` for every outbound message and expects every inbound one on `onmessage`. Both `@modelcontextprotocol/server` and `@modelcontextprotocol/client` export `Transport`, `TransportSendOptions`, and `JSONRPCMessage`, so one implementation serves either side.
## Honor the callback contract
The interface carries three rules that no type checker enforces.
- Never call `start()` on a transport you hand to a `Client` or `Server`: `connect()` installs the three callbacks and then calls `start()` itself. A transport that starts reading before the callbacks exist drops messages.
- `close()` must end by firing your own `onclose` — the protocol layer tears down its side of the connection from that callback, however the channel ended.
- `onerror` reports out-of-band conditions (a malformed frame, a dropped socket) and is not necessarily fatal. For a failure the sender must see, throw from `send` instead.
## Connect it like a built-in transport
`Client.connect()` and `McpServer.connect()` take any `Transport`. Link two loopback ends, hand one to each side, and call a tool.
```ts source="../../examples/guides/advanced/custom-transports.examples.ts#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);
```
The whole MCP handshake and the tool call run over the loopback; the handler's `content` comes back unchanged:
```
[ { type: 'text', text: 'pong' } ]
```
## Frame messages over a byte stream
The loopback hands its peer a parsed object. A socket hands you bytes — frame them with the same helpers the stdio transports use: `ReadBuffer` buffers chunks and yields one parsed message per newline-delimited line, `serializeMessage` writes one, and `deserializeMessage` parses a single line you already hold.
```ts source="../../examples/guides/advanced/custom-transports.examples.ts#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();
}
}
```
`readMessage()` returns `null` until a complete line has arrived and skips non-JSON lines, so partial chunks, coalesced writes, and stray debug output all come out as whole `JSONRPCMessage` values or not at all.
::: tip
`ReadBuffer` throws once its buffer exceeds 10 MB (`STDIO_DEFAULT_MAX_BUFFER_SIZE`). Pass `new ReadBuffer({ maxBufferSize })` to raise the cap.
:::
## Report a session ID and the negotiated version
Three optional members let the protocol layer talk back to your transport; the SDK uses each one only when it is present. Set `sessionId` when your channel has one, and declare `setProtocolVersion` to receive the protocol version the two sides negotiated during `initialize`.
```ts source="../../examples/guides/advanced/custom-transports.examples.ts#transport_session"
export class SessionLoopbackTransport extends LoopbackTransport {
sessionId?: string;
protocolVersion?: string;
setProtocolVersion(version: string): void {
this.protocolVersion = version;
}
}
```
Connect a client and a server over a linked pair of these and both ends end up holding the same version; logging the client end's `protocolVersion` after `connect()` prints:
```
2025-11-25
```
Which version you see depends on the connection's protocol era — see [Protocol versions](../protocol-versions.md).
::: info
`setSupportedProtocolVersions` is the third optional member: `connect()` passes the local side's accepted versions into it, which is how the HTTP server transports know what to allow in the `MCP-Protocol-Version` header.
:::
## Opt into per-request cancellation
Declare `hasPerRequestStream` only on a transport that opens one underlying request per outbound JSON-RPC request, and forward `requestSignal` to that request.
```ts source="../../examples/guides/advanced/custom-transports.examples.ts#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()));
}
```
On a 2026-07-28 connection the protocol layer cancels an in-flight request by aborting that request's `requestSignal` instead of sending `notifications/cancelled` — see [Protocol versions](../protocol-versions.md). Single-channel transports — stdio, the loopback above — leave the flag undefined and ignore `requestSignal`; cancellation stays a notification for them.
## Test it against the in-memory pair
`InMemoryTransport` is the reference implementation: the smallest `Transport` the SDK ships, and a known-good baseline for the client and server you drive your own transport with.
```ts source="../../examples/guides/advanced/custom-transports.examples.ts#inMemory_pair"
import { InMemoryTransport } from '@modelcontextprotocol/client';
const [inMemoryClientEnd, inMemoryServerEnd] = InMemoryTransport.createLinkedPair();
```
Run the same client and server over both pairs: the `ping` call above returns the same `content` over `InMemoryTransport` as over the loopback, and anything that differs is a bug in your transport. [Test a server](../testing.md) builds its whole harness on this pair.
## Recap
- A transport is `start`, `send`, `close` plus `onmessage`, `onerror`, `onclose` — nothing else is required.
- `connect()` installs the callbacks, then calls `start()` for you; `close()` must fire `onclose`.
- `ReadBuffer`, `serializeMessage`, and `deserializeMessage` give you the newline-delimited framing the stdio transports use.
- `sessionId`, `setProtocolVersion`, `setSupportedProtocolVersions`, and `hasPerRequestStream` are optional members the SDK uses only when they are present.
- `InMemoryTransport.createLinkedPair()` is the reference implementation and the baseline to test your transport against.

160
docs/advanced/gateway.md Normal file
View file

@ -0,0 +1,160 @@
---
shape: how-to
---
# Gateways and worker fleets
A **gateway** — a proxy, a worker pool, any process that fronts one MCP server with many short-lived clients — probes the server once and reuses the answer for every connection after it.
## Connect with a prior discover result
`connect()` takes an optional `prior`: a persisted `DiscoverResult` from an earlier probe. With it, `connect()` adopts the server's advertisement directly and sends nothing on the wire.
```ts source="../../examples/guides/advanced/gateway.examples.ts#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) });
```
`worker` is connected: `callTool` works immediately, and the server has not heard from it yet.
`connect({ prior })` is 2026-07-28+ only — see [Protocol versions](../protocol-versions.md).
## Probe once at bootstrap
An `'auto'`-mode (or pinned) connect sends `server/discover` and records the answer; `getDiscoverResult()` reads it back.
```ts source="../../examples/guides/advanced/gateway.examples.ts#bootstrap_probe"
console.log(bootstrap.getDiscoverResult());
```
The recorded value is the server's whole advertisement — supported versions, capabilities, identity, instructions:
```
{
ttlMs: 0,
cacheScope: 'private',
supportedVersions: [ '2026-07-28' ],
capabilities: { tools: { listChanged: true } },
serverInfo: { name: 'gateway-target', version: '1.0.0' },
resultType: 'complete'
}
```
::: tip
An already-connected client can re-probe at any time: `await client.discover()` sends `server/discover` and updates `getDiscoverResult()`. A default-mode connect never probes, so its `getDiscoverResult()` is `undefined` — [Protocol versions](../protocol-versions.md#pin-an-era) lists the negotiation modes.
:::
## Persist the advertisement
The value is plain JSON. Write the string to Redis, a config map, or a process-local cache; parse it back wherever a client needs it.
```ts source="../../examples/guides/advanced/gateway.examples.ts#persist_advertisement"
import type { DiscoverResult } from '@modelcontextprotocol/client';
const prior = JSON.parse(persisted) as DiscoverResult;
```
Nothing about `prior` is tied to the process that probed: any client that can reach the same URL can adopt it.
## Fan out to workers
Build every replica from the same blob; the `request_count` call after them is the proof.
```ts source="../../examples/guides/advanced/gateway.examples.ts#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);
```
`request_count` is a tool on this page's example server that returns how many MCP requests reached the process. Five clients are connected by now — `bootstrap`, `worker`, three replicas — and the server has answered two requests:
```
{ requests: 2 }
```
The bootstrap probe was the first request and the `request_count` call itself the second. The four `connect({ prior })` calls sent nothing.
## Reuse only within one authorization context
The advertisement is what the server returned to the credential that probed.
::: warning
Never share a persisted `DiscoverResult` across principals — key the blob on the authorization context that obtained it (a credential hash works). The server still authorizes every request, so a wider `prior` grants nothing, but it misleads client-side capability gating.
:::
## Open a listen stream when a worker needs notifications
`connect({ prior })` never auto-opens a `subscriptions/listen` stream — prior-connected clients are request-only until you open one yourself.
```ts source="../../examples/guides/advanced/gateway.examples.ts#listen_worker"
const subscription = await worker.listen({ toolsListChanged: true });
console.log(subscription.honoredFilter);
```
The server acknowledges the filter it agreed to honor:
```
{ toolsListChanged: true }
```
From here the stream behaves like any other subscription — [Subscriptions](../clients/subscriptions.md) covers the notification handlers and the close semantics.
::: info
A `listChanged` option configured on a prior-connected client registers its handlers but stays silent: no stream opens until you call `listen()`.
:::
## Handle a stale or incompatible advertisement
A `prior` that shares no 2026-07-28+ revision with the client rejects with `SdkError(EraNegotiationFailed)` before anything reaches the transport.
```ts source="../../examples/guides/advanced/gateway.examples.ts#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());
}
```
The rejection happens before the transport starts, so the same `Client` connects again on the fallback path:
```
ERA_NEGOTIATION_FAILED
re-probed: 2026-07-28
```
Replace the persisted blob with the fresh `getDiscoverResult()` and the rest of the fleet recovers on its next read.
## Recap
- `connect(transport, { prior })` adopts a persisted `DiscoverResult` with zero round trips.
- The advertisement comes from one `'auto'`-mode or pinned probe — or an explicit `client.discover()` — and `getDiscoverResult()` reads it back.
- The value is plain JSON: stringify it into a shared cache, parse it in any process that fronts the same server.
- Reuse a `DiscoverResult` only across clients that present the same authorization context.
- Prior-connected clients are request-only; call `listen()` on the one that needs notifications.
- An incompatible `prior` rejects with `SdkError(EraNegotiationFailed)`; fall back to a fresh probe and re-persist.

View file

@ -0,0 +1,161 @@
---
shape: explanation
---
# Low-level Server
`Server` is the **protocol layer** under `McpServer`: it routes each JSON-RPC request to the handler you register for that method string, and nothing more. Rebuild the `search` tool from [Tools](../servers/tools.md) on it to see what `registerTool` adds.
## Build the server and list your tools by hand
Declare the `tools` capability in the constructor and answer `tools/list` yourself. `inputSchema` is the raw JSON Schema the client and the model see.
```ts source="../../examples/guides/advanced/low-level-server.examples.ts#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']
}
}
]
}));
```
A client's `tools/list` returns exactly the array you wrote — the SDK derived none of it.
::: tip
Drop `capabilities: { tools: {} }` and `setRequestHandler('tools/list', …)` throws. `Server` never infers a capability from a handler, the way `registerTool` registers the `tools` capability for you.
:::
## Handle `tools/call` yourself
`tools/call` is one handler for every tool. Dispatch on `request.params.name` and read `request.params.arguments` yourself.
```ts source="../../examples/guides/advanced/low-level-server.examples.ts#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') }] };
});
```
An in-memory `Client` connected to this server — [Test a server](../testing.md) shows that wiring — calls `search` with `{ query: 'mug' }` and the handler's `content` comes back unchanged:
```
[ { type: 'text', text: 'Travel mug\nMug rack' } ]
```
Now call it with `{ query: 42 }`. The protocol layer checks only that `arguments` is an object, so the value reaches the handler and the handler crashes:
```
ProtocolError -32603: query.toLowerCase is not a function
```
`callTool` rejected with a protocol error instead of resolving to an `isError: true` tool result — [Errors](../servers/errors.md) covers the difference.
## Validate arguments yourself
From one Zod `inputSchema` the SDK derives the JSON Schema the model sees, validates arguments before your handler runs, and infers the handler's argument types. Here you wrote the JSON Schema by hand, the cast went unchecked, and nothing tied the two together.
`fromJsonSchema` — exported from `@modelcontextprotocol/server` — wraps a JSON Schema object as a validator you run yourself. Registering `tools/call` again replaces the handler; this one rejects before it touches the arguments.
```ts source="../../examples/guides/advanced/low-level-server.examples.ts#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') }] };
});
```
The same `{ query: 42 }` call now comes back as an ordinary tool result the model can read and retry:
```
{
content: [ { type: 'text', text: 'data/query must be string' } ],
isError: true
}
```
Keeping the schema you advertise in `tools/list` identical to the one you validate with is still on you — `registerTool` derives both from the same object.
## Serve it with the same entry points
`serveStdio` — from `@modelcontextprotocol/server/stdio` — and `createMcpHandler` each take an `McpServerFactory`, and the factory returns either an `McpServer` or a `Server`.
```ts source="../../examples/guides/advanced/low-level-server.examples.ts#lowLevel_serve"
serveStdio(() => server);
createMcpHandler(() => server);
```
Every serving recipe — [stdio](../serving/stdio.md), [HTTP](../serving/http.md) — applies to this server unchanged.
## Reach the low level from `McpServer`
Every `McpServer` owns its `Server` as `mcp.server`, so drop down per method, never per program. Declare the extra capability in the constructor, keep `registerTool` for the tools, and hand-register the one method `McpServer` has no API for.
```ts source="../../examples/guides/advanced/low-level-server.examples.ts#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 {};
});
```
`registerTool` still answers `tools/list` and `tools/call`; `resources/subscribe` reaches the handler you wrote. On the 2026-07-28 revision resource subscriptions arrive on a `subscriptions/listen` stream the serving entries answer for you — see [Protocol versions](../protocol-versions.md).
## Decide which layer to build on
Default to `McpServer`. `registerTool`, `registerResource`, and `registerPrompt` cover everything this page rebuilt — schema derivation, argument validation, typed handler arguments — plus the bookkeeping it skipped: `listChanged` notifications, [completions](../servers/completion.md), and the list/read/get dispatch for every registry.
Build on `Server` when you own dispatch: a [gateway](./gateway.md) that forwards whatever method arrives, a tool set computed per request from an external registry, or [custom methods](./custom-methods.md) outside the spec.
You never choose once for the whole program. Start on `McpServer` and take over individual methods through `mcp.server` as they need it.
## Recap
- `Server` is the protocol layer: `setRequestHandler(method, handler)` per spec method, and nothing derived on top.
- On `Server` you write the JSON Schema in `tools/list` and the argument validation in `tools/call`; `registerTool` derives both from one Zod schema.
- A handler exception on `Server` reaches the client as a protocol error, not as an `isError: true` tool result.
- `serveStdio` and `createMcpHandler` accept a factory that returns a `Server` unchanged.
- `mcp.server` is the per-method escape hatch; default to `McpServer` and drop to `Server` only where you own dispatch.

View file

@ -0,0 +1,164 @@
---
shape: how-to
---
# Schema libraries
## Register a tool with an ArkType schema
`inputSchema` accepts any **Standard Schema** that can produce JSON Schema — ArkType works as-is, no wrapper, exactly like the Zod schemas in [Tools](../servers/tools.md).
```ts source="../../examples/guides/advanced/schema-libraries.examples.ts#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') }]
})
);
```
From that one schema the SDK derives the JSON Schema the model sees, validates arguments before your handler runs, and infers the handler's argument types — `name` is `string`, `times` is `number | undefined`.
Every call on this page comes from an in-memory `Client` connected to this server — [Test a server](../testing.md) shows that wiring. Call `greet` with `times: 99` and the SDK rejects the call with ArkType's own message; the handler never runs:
```
{
content: [
{
type: 'text',
text: 'Input validation error: Invalid arguments for tool greet: times: times must be at most 5 (was 99)'
}
],
isError: true
}
```
::: info Coming from v1?
Raw shapes (`inputSchema: { name: z.string() }`) are deprecated — pass a schema object. See the [upgrade guide](../migration/upgrade-to-v2.md).
:::
## Register a tool with a Valibot schema
Valibot does not expose JSON Schema conversion on the schema itself — wrap it with `toStandardJsonSchema` from `@valibot/to-json-schema`.
```ts source="../../examples/guides/advanced/schema-libraries.examples.ts#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()}` }] })
);
```
`tools/list` now advertises `shout` with the JSON Schema the wrapper derives, and Valibot parses every call that reaches the handler.
## Start from JSON Schema you already have
`fromJsonSchema` (exported from `@modelcontextprotocol/server`) wraps a plain JSON Schema document so you can register it without a schema library. The generic parameter types the handler's arguments; omit it and they are `unknown`.
```ts source="../../examples/guides/advanced/schema-libraries.examples.ts#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}` }] })
);
```
`tools/list` advertises the document you passed, unchanged:
```
{
type: 'object',
properties: { name: { type: 'string' } },
required: [ 'name' ]
}
```
The SDK checks every call against it with a real **JSON Schema validator** — the last two sections pick which one.
## Validate structured output with any library
`outputSchema` — and a prompt's `argsSchema` — follow the same Standard Schema rule. Return the matching value as `structuredContent`, next to the human-readable `content`.
```ts source="../../examples/guides/advanced/schema-libraries.examples.ts#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 };
}
);
```
The SDK validates `structuredContent` against the ArkType schema before the result leaves your server. Calling `measure` with `{ name: 'Ada' }` returns both renderings:
```
{
content: [ { type: 'text', text: '{"name":"Ada","length":3}' } ],
structuredContent: { name: 'Ada', length: 3 }
}
```
## Swap the JSON Schema validator
The server runs a JSON Schema validator in two places: a `fromJsonSchema` schema, and [elicitation](../servers/elicitation.md) form responses. Build one from the `validators/ajv` subpath, which re-exports the SDK's bundled `Ajv` and `addFormats`.
```ts source="../../examples/guides/advanced/schema-libraries.examples.ts#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 });
```
`strict` now checks elicitation form responses with your `Ajv` instance.
::: warning
`jsonSchemaValidator` covers elicitation form responses only. A `fromJsonSchema` schema binds its validator at creation — pass yours as the second argument: `fromJsonSchema(document, validator)`.
:::
## Pick the validator for your runtime
Leave `jsonSchemaValidator` unset and the SDK selects by runtime: AJV on Node.js, `@cfworker/json-schema` on workerd and in browsers. Import from the `validators/cf-worker` subpath to pin the lightweight one anywhere.
```ts source="../../examples/guides/advanced/schema-libraries.examples.ts#jsonSchemaValidator_cfWorker"
import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker';
const edge = new McpServer({ name: 'schema-zoo', version: '1.0.0' }, { jsonSchemaValidator: new CfWorkerJsonSchemaValidator() });
```
`edge` runs the same two validation paths through `@cfworker/json-schema` instead of AJV, on any runtime.
## Recap
- `inputSchema`, `outputSchema`, and a prompt's `argsSchema` accept any Standard Schema that exposes JSON Schema — Zod and ArkType as-is, Valibot through `@valibot/to-json-schema`.
- The raw-shape overload (`inputSchema: { name: z.string() }`) is deprecated; pass a schema object.
- `fromJsonSchema(document)` registers a JSON Schema you already have; the generic parameter types the handler's arguments.
- `jsonSchemaValidator` on the server options swaps the validator for elicitation form responses; `fromJsonSchema` takes its own as a second argument.
- The default validator is runtime-selected — AJV on Node.js, `@cfworker/json-schema` on workerd and browsers — and the `validators/ajv` and `validators/cf-worker` subpaths force either one.

View file

@ -0,0 +1,158 @@
---
shape: how-to
---
# Wire schemas
`@modelcontextprotocol/core` exports the **wire schemas** — the exact Zod constants the SDK validates protocol and OAuth payloads against — for code that holds raw JSON instead of SDK objects.
## Validate a wire payload
`CallToolResultSchema.safeParse` validates an upstream body before you relay it.
```ts source="../../examples/guides/advanced/wire-schemas.examples.ts#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);
```
`parsed.data` is the typed result:
```
[ { type: 'text', text: 'Travel mug' } ]
```
Hand the same schema a malformed body and `safeParse` returns the failure instead of throwing.
```ts source="../../examples/guides/advanced/wire-schemas.examples.ts#wireSchemas_validateResult_invalid"
const malformed = CallToolResultSchema.safeParse({ content: 'Travel mug' });
console.log(malformed.error?.issues);
```
The error names the field that broke the contract:
```
[
{
expected: 'array',
code: 'invalid_type',
path: [ 'content' ],
message: 'Invalid input: expected array, received string'
}
]
```
::: info Coming from v1?
These are the `*Schema` constants v1 exported from `@modelcontextprotocol/sdk/types.js`. The codemod rewrites the import path — see the [upgrade guide](../migration/upgrade-to-v2.md).
:::
## Decide whether you need this package at all
If you build with `McpServer` or `Client`, skip this package: [tools](../servers/tools.md) arrive in your handler already validated, and [tool calls](../clients/calling.md) come back as typed results. Reach for `@modelcontextprotocol/core` when nothing stands between you and the JSON — gateways, proxies, test harnesses, [worker fleets](./gateway.md).
Install it separately (`npm install @modelcontextprotocol/core`) — `@modelcontextprotocol/server` and `@modelcontextprotocol/client` keep a Zod-free public surface and never depend on it. The package is runtime-neutral; `zod` is its only dependency.
## Pick the schema for the message you hold
Every named type in the spec has a matching constant, `<SpecType>Schema`. When you do not yet know which one you hold, `JSONRPCMessageSchema` validates the undecoded envelope.
```ts source="../../examples/guides/advanced/wire-schemas.examples.ts#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));
```
`message` narrows to one of the four JSON-RPC shapes — request, notification, result response, error response — and an invalid frame throws a `ZodError`.
The constants come in the same families as the spec: requests (`CallToolRequestSchema`), results (`ListToolsResultSchema`), notifications (`ProgressNotificationSchema`), and `*ParamsSchema` for when you hold only the `params` object (`CallToolRequestParamsSchema`).
## Route raw JSON-RPC in a proxy
Parse the envelope once, branch on `method`, then validate with the per-method request schema before forwarding.
```ts source="../../examples/guides/advanced/wire-schemas.examples.ts#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`);
}
}
```
`call.params.name` is a typed `string`, with no `Client` or `Server` anywhere in the path:
```
forward tools/call for search upstream
```
For everything beyond validation — sessions, capability negotiation, request correlation — build on the SDK instead: see the [low-level server](./low-level-server.md).
## Validate OAuth and discovery metadata
The second export group covers OAuth and OpenID discovery. `OAuthMetadataSchema` validates an authorization server's metadata document.
```ts source="../../examples/guides/advanced/wire-schemas.examples.ts#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);
```
A document missing a required endpoint fails the parse; a valid one comes back typed:
```
https://auth.example.com/token
```
The group follows the same naming convention: `OAuthTokensSchema` for token responses, `OAuthProtectedResourceMetadataSchema` for protected-resource metadata, `OpenIdProviderDiscoveryMetadataSchema` for OpenID provider discovery.
## Get the TypeScript types, guards and errors from the SDK packages
`@modelcontextprotocol/core` exports Zod values and nothing else. The spec types, the `isJSONRPCRequest`-style guards, and the error classes are public API of `@modelcontextprotocol/server` and `@modelcontextprotocol/client` — import them from whichever package you already depend on.
```ts source="../../examples/guides/advanced/wire-schemas.examples.ts#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>;
```
The assignment typechecks: what a core schema parses is what the SDK packages type. A package that depends only on core derives the same types with `z.infer`.
::: tip
To check a value's shape without taking a Zod dependency at all, use the `isSpecType` guards exported from `@modelcontextprotocol/client` and `@modelcontextprotocol/server`: `isSpecType.CallToolResult(value)`.
:::
## Recap
- `@modelcontextprotocol/core` exports the SDK's own spec and OAuth/OpenID Zod schemas, and nothing else.
- Its audience is code that holds raw JSON — gateways, proxies, test harnesses — not `Client` or `Server` users.
- Every spec type has a `<Name>Schema` constant; `JSONRPCMessageSchema` validates the undecoded envelope.
- Types, guards and error classes are not in core — import them from `@modelcontextprotocol/server` or `@modelcontextprotocol/client`.
- The package is runtime-neutral; `zod` is its only dependency.