Skip to content
6 changes: 6 additions & 0 deletions .changeset/extension-registrar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/client': minor
'@modelcontextprotocol/server': minor
---

Add `Client.extension()` / `Server.extension()` registrar for SEP-2133 capability-aware custom methods. Declares an extension in `capabilities.extensions[id]` and returns an `ExtensionHandle` whose `setRequestHandler`/`sendRequest`/`setNotificationHandler`/`sendNotification` calls are tied to that declared capability. `getPeerSettings()` returns the peer's extension settings, optionally validated against a `peerSchema`.
61 changes: 61 additions & 0 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
StdioClientTransport,
StreamableHTTPClientTransport
} from '@modelcontextprotocol/client';
import * as z from 'zod/v4';
```

## Connecting to a server
Expand Down Expand Up @@ -596,6 +597,66 @@ console.log(result);

For an end-to-end example of server-initiated SSE disconnection and automatic client reconnection with event replay, see [`ssePollingClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/ssePollingClient.ts).

## Protocol extensions

[SEP-2133](https://modelcontextprotocol.io/seps/2133) defines a `capabilities.extensions` field that lets clients and servers advertise support for protocol extensions outside the core MCP spec. This SDK provides two layers for implementing the JSON-RPC methods such an extension defines: {@linkcode @modelcontextprotocol/client!client/client.Client#extension | Client.extension()} for capability-aware extensions, and the lower-level {@linkcode @modelcontextprotocol/client!client/client.Client#setCustomRequestHandler | setCustomRequestHandler} / {@linkcode @modelcontextprotocol/client!client/client.Client#sendCustomRequest | sendCustomRequest} family for ungated one-off methods.

### Declaring an extension

Call {@linkcode @modelcontextprotocol/client!client/client.Client#extension | client.extension(id, settings)} before connecting. This merges `settings` into `capabilities.extensions[id]` (sent to the server during `initialize`) and returns an {@linkcode @modelcontextprotocol/client!index.ExtensionHandle | ExtensionHandle} for registering handlers and sending requests:

```ts source="../examples/client/src/clientGuide.examples.ts#extension_declare"
const client = new Client({ name: 'ui-view', version: '1.0.0' });

// Declare the extension. `settings` is advertised in capabilities.extensions[id] during initialize.
const ui = client.extension(
'io.modelcontextprotocol/ui',
{ availableModes: ['inline', 'fullscreen'] },
{ peerSchema: z.object({ openLinks: z.boolean().optional() }) }
);

// Handle incoming custom notifications from the server.
ui.setNotificationHandler('ui/host-context-changed', z.object({ theme: z.enum(['light', 'dark']) }), params => {
document.body.dataset.theme = params.theme;
});
```

The handle is the only way to reach `ui.setNotificationHandler(...)`, so a handler registered through it is structurally guaranteed to belong to a declared extension.

After connecting, {@linkcode @modelcontextprotocol/client!index.ExtensionHandle#getPeerSettings | handle.getPeerSettings()} returns what the server advertised for the same extension ID, and {@linkcode @modelcontextprotocol/client!index.ExtensionHandle#sendRequest | handle.sendRequest()} sends a custom request gated on that:

```ts source="../examples/client/src/clientGuide.examples.ts#extension_send"
await client.connect(transport);

// After connect, read the server's advertised settings for this extension.
if (ui.getPeerSettings()?.openLinks) {
const result = await ui.sendRequest('ui/open-link', { url: 'https://example.com' }, z.object({ opened: z.boolean() }));
console.log(result.opened);
}
```

When `enforceStrictCapabilities` is enabled, `sendRequest()` and `sendNotification()` throw if the server did not advertise the extension. Under the default (lax) mode they send regardless, and `getPeerSettings()` returns `undefined`.

### Ungated custom methods

For a one-off vendor method that does not warrant an SEP-2133 capability entry, use the flat custom-method API directly on the client. This skips capability negotiation entirely:

```ts source="../examples/client/src/clientGuide.examples.ts#customMethod_ungated"
// For one-off vendor methods that do not warrant an SEP-2133 capability entry,
// use the flat custom-method API directly.
const result = await client.sendCustomRequest('acme/search', { query: 'widgets' }, z.object({ hits: z.array(z.string()) }));
console.log(result.hits);
```

Standard MCP method names are rejected with a clear error — use {@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} and friends for those.

### When to use which

| Use | When |
| --- | --- |
| `client.extension(id, ...)` | You implement an SEP-2133 extension with a published ID, want it advertised in `capabilities`, and want sends gated on the peer supporting it. |
| `sendCustomRequest` etc. | You need a single vendor-specific method without capability negotiation, or are prototyping before defining an extension. |

## Tasks (experimental)

> [!WARNING]
Expand Down
30 changes: 30 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,36 @@ before sending and gives typed `params`; passing a bare result schema sends para

For larger sub-protocols where neither side is semantically an MCP client or server, prefer composition: hold a `Client` (or `Server`) instance, register custom handlers on it, and expose typed facade methods. See `examples/server/src/customMethodExample.ts` and `examples/client/src/customMethodExample.ts` for runnable examples.

#### Declaring extension capabilities (SEP-2133)

When your custom methods constitute a formal extension with an SEP-2133 identifier (e.g.
`io.modelcontextprotocol/ui`), use `Client.extension()` / `Server.extension()` instead of the flat
`*Custom*` methods. This declares the extension in `capabilities.extensions[id]` so it is
negotiated during `initialize`, and returns a scoped `ExtensionHandle` whose `setRequestHandler` /
`sendRequest` calls are tied to that declared capability:

```typescript
import { Client } from '@modelcontextprotocol/client';

const client = new Client({ name: 'app', version: '1.0.0' });
const ui = client.extension(
'io.modelcontextprotocol/ui',
{ availableDisplayModes: ['inline'] },
{ peerSchema: HostCapabilitiesSchema }
);

ui.setRequestHandler('ui/resource-teardown', TeardownParams, p => onTeardown(p));

await client.connect(transport);
ui.getPeerSettings(); // server's capabilities.extensions['io.modelcontextprotocol/ui'], typed via peerSchema
await ui.sendRequest('ui/open-link', { url }, OpenLinkResult);
```

`handle.sendRequest`/`sendNotification` respect `enforceStrictCapabilities`: when strict, sending
throws if the peer did not advertise the same extension ID. The flat `setCustomRequestHandler` /
`sendCustomRequest` methods remain available as the ungated escape hatch for one-off vendor
methods that do not warrant a SEP-2133 entry.

### `Protocol.request()`, `ctx.mcpReq.send()`, and `Client.callTool()` no longer take a schema parameter

The public `Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` methods no longer accept a Zod result schema argument. The SDK now resolves the correct result schema internally based on the method name. This means you no longer need to import result schemas
Expand Down
70 changes: 69 additions & 1 deletion docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { randomUUID } from 'node:crypto';
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import type { CallToolResult, ResourceLink } from '@modelcontextprotocol/server';
import { completable, McpServer, ResourceTemplate, StdioServerTransport } from '@modelcontextprotocol/server';
import { completable, McpServer, ResourceTemplate, Server, StdioServerTransport } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
```

Expand Down Expand Up @@ -494,6 +494,74 @@ server.registerTool(
);
```

## Protocol extensions

[SEP-2133](https://modelcontextprotocol.io/seps/2133) defines a `capabilities.extensions` field that lets servers and clients advertise support for protocol extensions outside the core MCP spec — for example, [MCP Apps](https://modelcontextprotocol.io/seps/1865) (`io.modelcontextprotocol/ui`). This SDK provides two layers for implementing the JSON-RPC methods such an extension defines: {@linkcode @modelcontextprotocol/server!server/server.Server#extension | Server.extension()} for capability-aware extensions, and the lower-level {@linkcode @modelcontextprotocol/server!server/server.Server#setCustomRequestHandler | setCustomRequestHandler} family for ungated one-off methods.

### Declaring an extension

Call {@linkcode @modelcontextprotocol/server!server/server.Server#extension | server.extension(id, settings)} before connecting. This merges `settings` into `capabilities.extensions[id]` (advertised to the client during `initialize`) and returns an {@linkcode @modelcontextprotocol/server!index.ExtensionHandle | ExtensionHandle} for registering handlers and sending requests:

```ts source="../examples/server/src/serverGuide.examples.ts#extension_declare"
const server = new Server({ name: 'host', version: '1.0.0' }, { capabilities: {} });

// Declare the extension. `settings` is advertised in capabilities.extensions[id] during initialize.
const ui = server.extension(
'io.modelcontextprotocol/ui',
{ openLinks: true, downloadFile: true },
{ peerSchema: z.object({ availableModes: z.array(z.string()) }) }
);

// Register handlers for the extension's custom methods. The handle is proof of declaration —
// you cannot reach this point without the capability having been merged in above.
ui.setRequestHandler('ui/open-link', z.object({ url: z.string() }), async params => {
return { opened: params.url.startsWith('https://') };
});

ui.setNotificationHandler('ui/size-changed', z.object({ width: z.number(), height: z.number() }), params => {
console.log(`view resized to ${params.width}x${params.height}`);
});
```

The handle is the only way to reach `ui.setRequestHandler(...)`, so a handler registered through it is structurally guaranteed to belong to a declared extension — you cannot forget the capability declaration.

After connecting, {@linkcode @modelcontextprotocol/server!index.ExtensionHandle#getPeerSettings | handle.getPeerSettings()} returns what the client advertised for the same extension ID. Pass a `peerSchema` to type and validate that blob:

```ts source="../examples/server/src/serverGuide.examples.ts#extension_peerSettings"
await server.connect(transport);

// After connect, read what the client advertised for this extension.
const clientUi = ui.getPeerSettings(); // { availableModes: string[] } | undefined
if (clientUi?.availableModes.includes('fullscreen')) {
await ui.sendNotification('ui/mode-available', { mode: 'fullscreen' });
}
```

When `enforceStrictCapabilities` is enabled, {@linkcode @modelcontextprotocol/server!index.ExtensionHandle#sendRequest | handle.sendRequest()} and {@linkcode @modelcontextprotocol/server!index.ExtensionHandle#sendNotification | sendNotification()} throw if the client did not advertise the extension. Under the default (lax) mode they send regardless, and `getPeerSettings()` returns `undefined`.

### Ungated custom methods

For a one-off vendor method that does not warrant an SEP-2133 capability entry, use the flat custom-method API directly on the server. This skips capability negotiation entirely:

```ts source="../examples/server/src/serverGuide.examples.ts#customMethod_ungated"
// For one-off vendor methods that do not warrant an SEP-2133 capability entry,
// use the flat custom-method API directly.
server.setCustomRequestHandler('acme/search', z.object({ query: z.string() }), async params => {
return { hits: [`result for ${params.query}`] };
});
```

The companion {@linkcode @modelcontextprotocol/server!server/server.Server#sendCustomRequest | sendCustomRequest}, {@linkcode @modelcontextprotocol/server!server/server.Server#setCustomNotificationHandler | setCustomNotificationHandler}, and {@linkcode @modelcontextprotocol/server!server/server.Server#sendCustomNotification | sendCustomNotification} cover the other directions. Standard MCP method names are rejected with a clear error — use {@linkcode @modelcontextprotocol/server!server/server.Server#setRequestHandler | setRequestHandler} for those.

### When to use which

| Use | When |
| --- | --- |
| `server.extension(id, ...)` | You implement an SEP-2133 extension with a published ID, want it advertised in `capabilities`, and want sends gated on the peer supporting it. |
| `setCustomRequestHandler` etc. | You need a single vendor-specific method without capability negotiation, or are prototyping before defining an extension. |

For a full runnable example, see [`customMethodExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/customMethodExample.ts).

## Tasks (experimental)

> [!WARNING]
Expand Down
55 changes: 55 additions & 0 deletions examples/client/src/clientGuide.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
StdioClientTransport,
StreamableHTTPClientTransport
} from '@modelcontextprotocol/client';
import * as z from 'zod/v4';
//#endregion imports

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -544,6 +545,54 @@ async function resumptionToken_basic(client: Client) {
//#endregion resumptionToken_basic
}

// ---------------------------------------------------------------------------
// Protocol extensions
// ---------------------------------------------------------------------------

/** Example: declare an SEP-2133 extension on a Client and wire handlers + sends. */
function extension_declare() {
//#region extension_declare
const client = new Client({ name: 'ui-view', version: '1.0.0' });

// Declare the extension. `settings` is advertised in capabilities.extensions[id] during initialize.
const ui = client.extension(
'io.modelcontextprotocol/ui',
{ availableModes: ['inline', 'fullscreen'] },
{ peerSchema: z.object({ openLinks: z.boolean().optional() }) }
);

// Handle incoming custom notifications from the server.
ui.setNotificationHandler('ui/host-context-changed', z.object({ theme: z.enum(['light', 'dark']) }), params => {
document.body.dataset.theme = params.theme;
});
//#endregion extension_declare
return { client, ui };
}

/** Example: send a custom request through the handle and read peer settings. */
async function extension_send() {
const { client, ui } = extension_declare();
//#region extension_send
await client.connect(transport);

// After connect, read the server's advertised settings for this extension.
if (ui.getPeerSettings()?.openLinks) {
const result = await ui.sendRequest('ui/open-link', { url: 'https://example.com' }, z.object({ opened: z.boolean() }));
console.log(result.opened);
}
//#endregion extension_send
}

/** Example: ungated custom method (no capability negotiation). */
async function customMethod_ungated(client: Client) {
//#region customMethod_ungated
// For one-off vendor methods that do not warrant an SEP-2133 capability entry,
// use the flat custom-method API directly.
const result = await client.sendCustomRequest('acme/search', { query: 'widgets' }, z.object({ hits: z.array(z.string()) }));
console.log(result.hits);
//#endregion customMethod_ungated
}

// Suppress unused-function warnings (functions exist solely for type-checking)
void connect_streamableHttp;
void connect_stdio;
Expand Down Expand Up @@ -573,3 +622,9 @@ void errorHandling_lifecycle;
void errorHandling_timeout;
void middleware_basic;
void resumptionToken_basic;
void extension_declare;
void extension_send;
void customMethod_ungated;

declare const transport: import('@modelcontextprotocol/client').Transport;
declare const document: { body: { dataset: Record<string, string> } };
63 changes: 62 additions & 1 deletion examples/server/src/serverGuide.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { randomUUID } from 'node:crypto';
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import type { CallToolResult, ResourceLink } from '@modelcontextprotocol/server';
import { completable, McpServer, ResourceTemplate, StdioServerTransport } from '@modelcontextprotocol/server';
import { completable, McpServer, ResourceTemplate, Server, StdioServerTransport } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
//#endregion imports

Expand Down Expand Up @@ -534,6 +534,62 @@ function dnsRebinding_allowedHosts() {
return app;
}

// ---------------------------------------------------------------------------
// Protocol extensions
// ---------------------------------------------------------------------------

/** Example: declare an SEP-2133 extension on a low-level Server and wire handlers. */
function extension_declare() {
//#region extension_declare
const server = new Server({ name: 'host', version: '1.0.0' }, { capabilities: {} });

// Declare the extension. `settings` is advertised in capabilities.extensions[id] during initialize.
const ui = server.extension(
'io.modelcontextprotocol/ui',
{ openLinks: true, downloadFile: true },
{ peerSchema: z.object({ availableModes: z.array(z.string()) }) }
);

// Register handlers for the extension's custom methods. The handle is proof of declaration —
// you cannot reach this point without the capability having been merged in above.
ui.setRequestHandler('ui/open-link', z.object({ url: z.string() }), async params => {
return { opened: params.url.startsWith('https://') };
});

ui.setNotificationHandler('ui/size-changed', z.object({ width: z.number(), height: z.number() }), params => {
console.log(`view resized to ${params.width}x${params.height}`);
});
//#endregion extension_declare
return { server, ui };
}

/** Example: read the connected client's extension settings. */
async function extension_peerSettings() {
const { server, ui } = extension_declare();
//#region extension_peerSettings
await server.connect(transport);

// After connect, read what the client advertised for this extension.
const clientUi = ui.getPeerSettings(); // { availableModes: string[] } | undefined
if (clientUi?.availableModes.includes('fullscreen')) {
await ui.sendNotification('ui/mode-available', { mode: 'fullscreen' });
}
//#endregion extension_peerSettings
}

/** Example: ungated custom method (no capability negotiation). */
function customMethod_ungated() {
const server = new Server({ name: 'host', version: '1.0.0' }, { capabilities: {} });
//#region customMethod_ungated
// For one-off vendor methods that do not warrant an SEP-2133 capability entry,
// use the flat custom-method API directly.
server.setCustomRequestHandler('acme/search', z.object({ query: z.string() }), async params => {
return { hits: [`result for ${params.query}`] };
});
//#endregion customMethod_ungated
return server;
}

// Suppress unused-function warnings (functions exist solely for type-checking)
void instructions_basic;
void registerTool_basic;
Expand All @@ -557,3 +613,8 @@ void shutdown_statefulHttp;
void shutdown_stdio;
void dnsRebinding_basic;
void dnsRebinding_allowedHosts;
void extension_declare;
void extension_peerSettings;
void customMethod_ungated;

declare const transport: import('@modelcontextprotocol/server').Transport;
Loading
Loading