Skip to content

Commit 9434b46

Browse files
docs: add client.md/server.md sections and @example blocks for extension() and custom methods
1 parent 641cc7a commit 9434b46

12 files changed

Lines changed: 544 additions & 2 deletions

File tree

docs/client.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
StdioClientTransport,
3030
StreamableHTTPClientTransport
3131
} from '@modelcontextprotocol/client';
32+
import * as z from 'zod/v4';
3233
```
3334

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

597598
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).
598599

600+
## Protocol extensions
601+
602+
[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.
603+
604+
### Declaring an extension
605+
606+
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:
607+
608+
```ts source="../examples/client/src/clientGuide.examples.ts#extension_declare"
609+
const client = new Client({ name: 'ui-view', version: '1.0.0' });
610+
611+
// Declare the extension. `settings` is advertised in capabilities.extensions[id] during initialize.
612+
const ui = client.extension(
613+
'io.modelcontextprotocol/ui',
614+
{ availableModes: ['inline', 'fullscreen'] },
615+
{ peerSchema: z.object({ openLinks: z.boolean().optional() }) }
616+
);
617+
618+
// Handle incoming custom notifications from the server.
619+
ui.setNotificationHandler('ui/host-context-changed', z.object({ theme: z.enum(['light', 'dark']) }), params => {
620+
document.body.dataset.theme = params.theme;
621+
});
622+
```
623+
624+
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.
625+
626+
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:
627+
628+
```ts source="../examples/client/src/clientGuide.examples.ts#extension_send"
629+
await client.connect(transport);
630+
631+
// After connect, read the server's advertised settings for this extension.
632+
if (ui.getPeerSettings()?.openLinks) {
633+
const result = await ui.sendRequest('ui/open-link', { url: 'https://example.com' }, z.object({ opened: z.boolean() }));
634+
console.log(result.opened);
635+
}
636+
```
637+
638+
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`.
639+
640+
### Ungated custom methods
641+
642+
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:
643+
644+
```ts source="../examples/client/src/clientGuide.examples.ts#customMethod_ungated"
645+
// For one-off vendor methods that do not warrant an SEP-2133 capability entry,
646+
// use the flat custom-method API directly.
647+
const result = await client.sendCustomRequest('acme/search', { query: 'widgets' }, z.object({ hits: z.array(z.string()) }));
648+
console.log(result.hits);
649+
```
650+
651+
Standard MCP method names are rejected with a clear error — use {@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} and friends for those.
652+
653+
### When to use which
654+
655+
| Use | When |
656+
| --- | --- |
657+
| `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. |
658+
| `sendCustomRequest` etc. | You need a single vendor-specific method without capability negotiation, or are prototyping before defining an extension. |
659+
599660
## Tasks (experimental)
600661

601662
> [!WARNING]

docs/server.md

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { randomUUID } from 'node:crypto';
2222
import { createMcpExpressApp } from '@modelcontextprotocol/express';
2323
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
2424
import type { CallToolResult, ResourceLink } from '@modelcontextprotocol/server';
25-
import { completable, McpServer, ResourceTemplate, StdioServerTransport } from '@modelcontextprotocol/server';
25+
import { completable, McpServer, ResourceTemplate, Server, StdioServerTransport } from '@modelcontextprotocol/server';
2626
import * as z from 'zod/v4';
2727
```
2828

@@ -494,6 +494,74 @@ server.registerTool(
494494
);
495495
```
496496

497+
## Protocol extensions
498+
499+
[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.
500+
501+
### Declaring an extension
502+
503+
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:
504+
505+
```ts source="../examples/server/src/serverGuide.examples.ts#extension_declare"
506+
const server = new Server({ name: 'host', version: '1.0.0' }, { capabilities: {} });
507+
508+
// Declare the extension. `settings` is advertised in capabilities.extensions[id] during initialize.
509+
const ui = server.extension(
510+
'io.modelcontextprotocol/ui',
511+
{ openLinks: true, downloadFile: true },
512+
{ peerSchema: z.object({ availableModes: z.array(z.string()) }) }
513+
);
514+
515+
// Register handlers for the extension's custom methods. The handle is proof of declaration —
516+
// you cannot reach this point without the capability having been merged in above.
517+
ui.setRequestHandler('ui/open-link', z.object({ url: z.string() }), async params => {
518+
return { opened: params.url.startsWith('https://') };
519+
});
520+
521+
ui.setNotificationHandler('ui/size-changed', z.object({ width: z.number(), height: z.number() }), params => {
522+
console.log(`view resized to ${params.width}x${params.height}`);
523+
});
524+
```
525+
526+
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.
527+
528+
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:
529+
530+
```ts source="../examples/server/src/serverGuide.examples.ts#extension_peerSettings"
531+
await server.connect(transport);
532+
533+
// After connect, read what the client advertised for this extension.
534+
const clientUi = ui.getPeerSettings(); // { availableModes: string[] } | undefined
535+
if (clientUi?.availableModes.includes('fullscreen')) {
536+
await ui.sendNotification('ui/mode-available', { mode: 'fullscreen' });
537+
}
538+
```
539+
540+
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`.
541+
542+
### Ungated custom methods
543+
544+
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:
545+
546+
```ts source="../examples/server/src/serverGuide.examples.ts#customMethod_ungated"
547+
// For one-off vendor methods that do not warrant an SEP-2133 capability entry,
548+
// use the flat custom-method API directly.
549+
server.setCustomRequestHandler('acme/search', z.object({ query: z.string() }), async params => {
550+
return { hits: [`result for ${params.query}`] };
551+
});
552+
```
553+
554+
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.
555+
556+
### When to use which
557+
558+
| Use | When |
559+
| --- | --- |
560+
| `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. |
561+
| `setCustomRequestHandler` etc. | You need a single vendor-specific method without capability negotiation, or are prototyping before defining an extension. |
562+
563+
For a full runnable example, see [`customMethodExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/customMethodExample.ts).
564+
497565
## Tasks (experimental)
498566

499567
> [!WARNING]

examples/client/src/clientGuide.examples.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
StdioClientTransport,
2525
StreamableHTTPClientTransport
2626
} from '@modelcontextprotocol/client';
27+
import * as z from 'zod/v4';
2728
//#endregion imports
2829

2930
// ---------------------------------------------------------------------------
@@ -544,6 +545,54 @@ async function resumptionToken_basic(client: Client) {
544545
//#endregion resumptionToken_basic
545546
}
546547

548+
// ---------------------------------------------------------------------------
549+
// Protocol extensions
550+
// ---------------------------------------------------------------------------
551+
552+
/** Example: declare an SEP-2133 extension on a Client and wire handlers + sends. */
553+
function extension_declare() {
554+
//#region extension_declare
555+
const client = new Client({ name: 'ui-view', version: '1.0.0' });
556+
557+
// Declare the extension. `settings` is advertised in capabilities.extensions[id] during initialize.
558+
const ui = client.extension(
559+
'io.modelcontextprotocol/ui',
560+
{ availableModes: ['inline', 'fullscreen'] },
561+
{ peerSchema: z.object({ openLinks: z.boolean().optional() }) }
562+
);
563+
564+
// Handle incoming custom notifications from the server.
565+
ui.setNotificationHandler('ui/host-context-changed', z.object({ theme: z.enum(['light', 'dark']) }), params => {
566+
document.body.dataset.theme = params.theme;
567+
});
568+
//#endregion extension_declare
569+
return { client, ui };
570+
}
571+
572+
/** Example: send a custom request through the handle and read peer settings. */
573+
async function extension_send() {
574+
const { client, ui } = extension_declare();
575+
//#region extension_send
576+
await client.connect(transport);
577+
578+
// After connect, read the server's advertised settings for this extension.
579+
if (ui.getPeerSettings()?.openLinks) {
580+
const result = await ui.sendRequest('ui/open-link', { url: 'https://example.com' }, z.object({ opened: z.boolean() }));
581+
console.log(result.opened);
582+
}
583+
//#endregion extension_send
584+
}
585+
586+
/** Example: ungated custom method (no capability negotiation). */
587+
async function customMethod_ungated(client: Client) {
588+
//#region customMethod_ungated
589+
// For one-off vendor methods that do not warrant an SEP-2133 capability entry,
590+
// use the flat custom-method API directly.
591+
const result = await client.sendCustomRequest('acme/search', { query: 'widgets' }, z.object({ hits: z.array(z.string()) }));
592+
console.log(result.hits);
593+
//#endregion customMethod_ungated
594+
}
595+
547596
// Suppress unused-function warnings (functions exist solely for type-checking)
548597
void connect_streamableHttp;
549598
void connect_stdio;
@@ -573,3 +622,9 @@ void errorHandling_lifecycle;
573622
void errorHandling_timeout;
574623
void middleware_basic;
575624
void resumptionToken_basic;
625+
void extension_declare;
626+
void extension_send;
627+
void customMethod_ungated;
628+
629+
declare const transport: import('@modelcontextprotocol/client').Transport;
630+
declare const document: { body: { dataset: Record<string, string> } };

examples/server/src/serverGuide.examples.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { randomUUID } from 'node:crypto';
1313
import { createMcpExpressApp } from '@modelcontextprotocol/express';
1414
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
1515
import type { CallToolResult, ResourceLink } from '@modelcontextprotocol/server';
16-
import { completable, McpServer, ResourceTemplate, StdioServerTransport } from '@modelcontextprotocol/server';
16+
import { completable, McpServer, ResourceTemplate, Server, StdioServerTransport } from '@modelcontextprotocol/server';
1717
import * as z from 'zod/v4';
1818
//#endregion imports
1919

@@ -534,6 +534,62 @@ function dnsRebinding_allowedHosts() {
534534
return app;
535535
}
536536

537+
// ---------------------------------------------------------------------------
538+
// Protocol extensions
539+
// ---------------------------------------------------------------------------
540+
541+
/** Example: declare an SEP-2133 extension on a low-level Server and wire handlers. */
542+
function extension_declare() {
543+
//#region extension_declare
544+
const server = new Server({ name: 'host', version: '1.0.0' }, { capabilities: {} });
545+
546+
// Declare the extension. `settings` is advertised in capabilities.extensions[id] during initialize.
547+
const ui = server.extension(
548+
'io.modelcontextprotocol/ui',
549+
{ openLinks: true, downloadFile: true },
550+
{ peerSchema: z.object({ availableModes: z.array(z.string()) }) }
551+
);
552+
553+
// Register handlers for the extension's custom methods. The handle is proof of declaration —
554+
// you cannot reach this point without the capability having been merged in above.
555+
ui.setRequestHandler('ui/open-link', z.object({ url: z.string() }), async params => {
556+
return { opened: params.url.startsWith('https://') };
557+
});
558+
559+
ui.setNotificationHandler('ui/size-changed', z.object({ width: z.number(), height: z.number() }), params => {
560+
console.log(`view resized to ${params.width}x${params.height}`);
561+
});
562+
//#endregion extension_declare
563+
return { server, ui };
564+
}
565+
566+
/** Example: read the connected client's extension settings. */
567+
async function extension_peerSettings() {
568+
const { server, ui } = extension_declare();
569+
//#region extension_peerSettings
570+
await server.connect(transport);
571+
572+
// After connect, read what the client advertised for this extension.
573+
const clientUi = ui.getPeerSettings(); // { availableModes: string[] } | undefined
574+
if (clientUi?.availableModes.includes('fullscreen')) {
575+
await ui.sendNotification('ui/mode-available', { mode: 'fullscreen' });
576+
}
577+
//#endregion extension_peerSettings
578+
}
579+
580+
/** Example: ungated custom method (no capability negotiation). */
581+
function customMethod_ungated() {
582+
const server = new Server({ name: 'host', version: '1.0.0' }, { capabilities: {} });
583+
//#region customMethod_ungated
584+
// For one-off vendor methods that do not warrant an SEP-2133 capability entry,
585+
// use the flat custom-method API directly.
586+
server.setCustomRequestHandler('acme/search', z.object({ query: z.string() }), async params => {
587+
return { hits: [`result for ${params.query}`] };
588+
});
589+
//#endregion customMethod_ungated
590+
return server;
591+
}
592+
537593
// Suppress unused-function warnings (functions exist solely for type-checking)
538594
void instructions_basic;
539595
void registerTool_basic;
@@ -557,3 +613,8 @@ void shutdown_statefulHttp;
557613
void shutdown_stdio;
558614
void dnsRebinding_basic;
559615
void dnsRebinding_allowedHosts;
616+
void extension_declare;
617+
void extension_peerSettings;
618+
void customMethod_ungated;
619+
620+
declare const transport: import('@modelcontextprotocol/server').Transport;

packages/client/src/client/client.examples.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99

1010
import type { Prompt, Resource, Tool } from '@modelcontextprotocol/core';
11+
import * as z from 'zod/v4';
1112

1213
import { Client } from './client.js';
1314
import { SSEClientTransport } from './sse.js';
@@ -192,3 +193,27 @@ async function Client_listResources_pagination(client: Client) {
192193
);
193194
//#endregion Client_listResources_pagination
194195
}
196+
197+
/**
198+
* Example: declare an SEP-2133 extension and use the returned handle.
199+
*/
200+
function Client_extension_basic() {
201+
//#region Client_extension_basic
202+
const client = new Client({ name: 'ui-view', version: '1.0.0' });
203+
204+
const ui = client.extension(
205+
'io.modelcontextprotocol/ui',
206+
{ availableModes: ['inline', 'fullscreen'] },
207+
{ peerSchema: z.object({ openLinks: z.boolean().optional() }) }
208+
);
209+
210+
ui.setNotificationHandler('ui/tool-result', z.object({ content: z.array(z.unknown()) }), params => {
211+
console.log('tool result:', params.content);
212+
});
213+
214+
// After connect: ui.getPeerSettings() returns the server's extensions['io.modelcontextprotocol/ui']
215+
//#endregion Client_extension_basic
216+
return { client, ui };
217+
}
218+
219+
void Client_extension_basic;

packages/client/src/client/client.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,23 @@ export class Client extends Protocol<ClientContext> {
324324
* Note: a later {@linkcode registerCapabilities} call that includes `extensions[id]` will
325325
* overwrite the wire value declared here; the returned handle's `settings` reflects what
326326
* was passed to this call, not subsequent overwrites.
327+
*
328+
* @example
329+
* ```ts source="./client.examples.ts#Client_extension_basic"
330+
* const client = new Client({ name: 'ui-view', version: '1.0.0' });
331+
*
332+
* const ui = client.extension(
333+
* 'io.modelcontextprotocol/ui',
334+
* { availableModes: ['inline', 'fullscreen'] },
335+
* { peerSchema: z.object({ openLinks: z.boolean().optional() }) }
336+
* );
337+
*
338+
* ui.setNotificationHandler('ui/tool-result', z.object({ content: z.array(z.unknown()) }), params => {
339+
* console.log('tool result:', params.content);
340+
* });
341+
*
342+
* // After connect: ui.getPeerSettings() returns the server's extensions['io.modelcontextprotocol/ui']
343+
* ```
327344
*/
328345
public extension<L extends JSONObject>(id: string, settings: L): ExtensionHandle<L, JSONObject, ClientContext>;
329346
public extension<L extends JSONObject, P extends AnySchema>(

0 commit comments

Comments
 (0)