# 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`](https://ts.sdk.modelcontextprotocol.io/v2/advanced/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
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](https://ts.sdk.modelcontextprotocol.io/v2/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
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
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
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
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
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](https://ts.sdk.modelcontextprotocol.io/v2/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.
