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, 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.
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 numberCall it from the client
Every call on this page comes from an in-memory Client connected to the server above — Test a server 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.
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.
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.
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.
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().
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.
Recap
setRequestHandler(method, { params, result }, handler)handles a non-spec method; spec methods never take the schema bundle.- The SDK validates incoming
paramsbefore the handler runs and rejects what fails with anInvalid paramserror;resulttypes 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.notifyon one side,setNotificationHandlerwith{ params }on the other. capabilities.extensionsadvertises 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.