Skip to content

Commit baf0a0e

Browse files
feat(core): add extension() registrar for SEP-2133 capability-aware custom methods
Adds Client.extension(id, settings, {peerSchema?}) and Server.extension(...) returning an ExtensionHandle that: - merges settings into capabilities.extensions[id] (advertised in initialize) - exposes getPeerSettings() with optional schema validation of the peer blob - wraps setCustom*/sendCustom* with peer-capability gating under enforceStrictCapabilities Connects the SEP-2133 capabilities.extensions field to the custom-method API from #1846. Declare-before-register is structural (you cannot get a handle without declaring); peer-gating on send mirrors assertCapabilityForMethod. Stacked on #1846.
1 parent 1f5fa16 commit baf0a0e

10 files changed

Lines changed: 589 additions & 49 deletions

File tree

.changeset/extension-registrar.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@modelcontextprotocol/client': minor
3+
'@modelcontextprotocol/server': minor
4+
---
5+
6+
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`.

docs/migration.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,36 @@ before sending and gives typed `params`; passing a bare result schema sends para
435435
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/customMethodExtAppsExample.ts` for a worked
436436
example.
437437

438+
#### Declaring extension capabilities (SEP-2133)
439+
440+
When your custom methods constitute a formal extension with an SEP-2133 identifier (e.g.
441+
`io.modelcontextprotocol/ui`), use `Client.extension()` / `Server.extension()` instead of the flat
442+
`*Custom*` methods. This declares the extension in `capabilities.extensions[id]` so it is
443+
negotiated during `initialize`, and returns a scoped `ExtensionHandle` whose `setRequestHandler` /
444+
`sendRequest` calls are tied to that declared capability:
445+
446+
```typescript
447+
import { Client } from '@modelcontextprotocol/client';
448+
449+
const client = new Client({ name: 'app', version: '1.0.0' });
450+
const ui = client.extension(
451+
'io.modelcontextprotocol/ui',
452+
{ availableDisplayModes: ['inline'] },
453+
{ peerSchema: HostCapabilitiesSchema }
454+
);
455+
456+
ui.setRequestHandler('ui/resource-teardown', TeardownParams, p => onTeardown(p));
457+
458+
await client.connect(transport);
459+
ui.getPeerSettings(); // server's capabilities.extensions['io.modelcontextprotocol/ui'], typed via peerSchema
460+
await ui.sendRequest('ui/open-link', { url }, OpenLinkResult);
461+
```
462+
463+
`handle.sendRequest`/`sendNotification` respect `enforceStrictCapabilities`: when strict, sending
464+
throws if the peer did not advertise the same extension ID. The flat `setCustomRequestHandler` /
465+
`sendCustomRequest` methods remain available as the ungated escape hatch for one-off vendor
466+
methods that do not warrant a SEP-2133 entry.
467+
438468

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

examples/server/src/customMethodExtAppsExample.ts

Lines changed: 57 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,40 @@
11
#!/usr/bin/env node
22
/**
33
* Demonstrates that the ext-apps (mcp-ui) pattern is fully implementable on top of the v2
4-
* SDK's custom-method-handler API, without extending Protocol or relying on the v1 generic
4+
* SDK's `extension()` registrar, without extending Protocol or relying on the v1 generic
55
* type parameters.
66
*
77
* In v1, ext-apps defined `class ProtocolWithEvents<...> extends Protocol<SendRequestT, ...>` to
8-
* widen the request/notification type unions. In v2, the same is achieved by composing
9-
* setCustomRequestHandler / setCustomNotificationHandler / sendCustomRequest / sendCustomNotification
10-
* on top of the standard Client and Server classes.
8+
* widen the request/notification type unions. In v2, the same is achieved by composing a
9+
* `Client`/`Server`, declaring an SEP-2133 extension via `.extension(id, settings)`, and using the
10+
* returned `ExtensionHandle` for all `ui/*` methods. Capability negotiation happens via the
11+
* standard MCP `initialize` exchange — no separate `ui/initialize` round-trip needed.
1112
*/
1213

13-
import { Client } from '@modelcontextprotocol/client';
14+
import { Client, type ExtensionHandle } from '@modelcontextprotocol/client';
1415
import { InMemoryTransport, Server } from '@modelcontextprotocol/server';
1516
import { z } from 'zod';
1617

18+
const EXT_ID = 'io.modelcontextprotocol/ui';
19+
1720
// ───────────────────────────────────────────────────────────────────────────────
18-
// Custom method schemas (mirror the ext-apps spec.types.ts pattern)
21+
// SEP-2133 extension capability shapes (asymmetric — App and Host advertise different things)
1922
// ───────────────────────────────────────────────────────────────────────────────
2023

21-
const InitializeParams = z.object({
22-
protocolVersion: z.string(),
23-
appInfo: z.object({ name: z.string(), version: z.string() })
24+
const AppCapabilities = z.object({
25+
availableDisplayModes: z.array(z.enum(['inline', 'fullscreen']))
2426
});
25-
const InitializeResult = z.object({
26-
protocolVersion: z.string(),
27-
hostInfo: z.object({ name: z.string(), version: z.string() }),
27+
type AppCapabilities = z.infer<typeof AppCapabilities>;
28+
29+
const HostCapabilities = z.object({
30+
openLinks: z.boolean(),
2831
hostContext: z.object({ theme: z.enum(['light', 'dark']), locale: z.string() })
2932
});
33+
type HostCapabilities = z.infer<typeof HostCapabilities>;
34+
35+
// ───────────────────────────────────────────────────────────────────────────────
36+
// Custom method schemas (mirror the ext-apps spec.types.ts pattern)
37+
// ───────────────────────────────────────────────────────────────────────────────
3038

3139
const OpenLinkParams = z.object({ url: z.url() });
3240
const OpenLinkResult = z.object({ opened: z.boolean() });
@@ -43,32 +51,32 @@ type AppEventMap = {
4351
};
4452

4553
// ───────────────────────────────────────────────────────────────────────────────
46-
// App: wraps Client, exposes typed mcp-ui/* methods + DOM-style events
54+
// App: wraps Client + ExtensionHandle, exposes typed mcp-ui/* methods + DOM-style events
4755
// (replaces v1's `class App extends ProtocolWithEvents<AppRequest, AppNotification, AppResult, AppEventMap>`)
4856
// ───────────────────────────────────────────────────────────────────────────────
4957

5058
class App {
5159
readonly client: Client;
60+
readonly ui: ExtensionHandle<AppCapabilities, HostCapabilities>;
5261
private _listeners: { [K in keyof AppEventMap]: ((p: AppEventMap[K]) => void)[] } = {
5362
toolresult: [],
5463
hostcontextchanged: []
5564
};
56-
private _hostContext?: z.infer<typeof InitializeResult>['hostContext'];
65+
private _hostContext?: HostCapabilities['hostContext'];
5766

5867
onTeardown?: (params: z.infer<typeof TeardownParams>) => void | Promise<void>;
5968

60-
constructor(appInfo: { name: string; version: string }) {
69+
constructor(appInfo: { name: string; version: string }, caps: AppCapabilities) {
6170
this.client = new Client(appInfo, { capabilities: {} });
71+
this.ui = this.client.extension(EXT_ID, caps, { peerSchema: HostCapabilities });
6272

63-
// Incoming custom request from host
64-
this.client.setCustomRequestHandler('mcp-ui/resourceTeardown', TeardownParams, async params => {
73+
this.ui.setRequestHandler('mcp-ui/resourceTeardown', TeardownParams, async params => {
6574
await this.onTeardown?.(params);
6675
return {};
6776
});
6877

69-
// Incoming custom notifications from host -> DOM-style event slots
70-
this.client.setCustomNotificationHandler('mcp-ui/toolResult', ToolResultParams, p => this._dispatch('toolresult', p));
71-
this.client.setCustomNotificationHandler('mcp-ui/hostContextChanged', HostContextChangedParams, p => {
78+
this.ui.setNotificationHandler('mcp-ui/toolResult', ToolResultParams, p => this._dispatch('toolresult', p));
79+
this.ui.setNotificationHandler('mcp-ui/hostContextChanged', HostContextChangedParams, p => {
7280
this._hostContext = { ...this._hostContext!, ...p };
7381
this._dispatch('hostcontextchanged', p);
7482
});
@@ -89,77 +97,74 @@ class App {
8997
}
9098

9199
async connect(transport: Parameters<Client['connect']>[0]): Promise<void> {
100+
// MCP `initialize` carries capabilities.extensions[EXT_ID] both ways — no separate
101+
// mcp-ui/initialize round-trip needed.
92102
await this.client.connect(transport);
93-
const result = await this.client.sendCustomRequest(
94-
'mcp-ui/initialize',
95-
{ protocolVersion: '2026-01-26', appInfo: { name: 'demo-app', version: '1.0.0' } },
96-
InitializeResult
97-
);
98-
this._hostContext = result.hostContext;
99-
await this.client.sendCustomNotification('mcp-ui/initialized', {});
103+
this._hostContext = this.ui.getPeerSettings()?.hostContext;
104+
}
105+
106+
get hostCapabilities(): HostCapabilities | undefined {
107+
return this.ui.getPeerSettings();
100108
}
101109

102110
getHostContext() {
103111
return this._hostContext;
104112
}
105113

106114
openLink(url: string) {
107-
return this.client.sendCustomRequest('mcp-ui/openLink', { url }, OpenLinkResult);
115+
return this.ui.sendRequest('mcp-ui/openLink', { url }, OpenLinkResult);
108116
}
109117

110118
notifySizeChanged(width: number, height: number) {
111-
return this.client.sendCustomNotification('mcp-ui/sizeChanged', { width, height });
119+
return this.ui.sendNotification('mcp-ui/sizeChanged', { width, height });
112120
}
113121
}
114122

115123
// ───────────────────────────────────────────────────────────────────────────────
116-
// Host: wraps Server, handles mcp-ui/* requests and emits mcp-ui/* notifications
124+
// Host: wraps Server + ExtensionHandle, handles mcp-ui/* requests and emits mcp-ui/* notifications
117125
// ───────────────────────────────────────────────────────────────────────────────
118126

119127
class Host {
120128
readonly server: Server;
129+
readonly ui: ExtensionHandle<HostCapabilities, AppCapabilities>;
121130
onSizeChanged?: (p: z.infer<typeof SizeChangedParams>) => void;
122131

123132
constructor() {
124133
this.server = new Server({ name: 'demo-host', version: '1.0.0' }, { capabilities: {} });
134+
this.ui = this.server.extension(
135+
EXT_ID,
136+
{ openLinks: true, hostContext: { theme: 'dark', locale: 'en-US' } },
137+
{ peerSchema: AppCapabilities }
138+
);
125139

126-
this.server.setCustomRequestHandler('mcp-ui/initialize', InitializeParams, params => {
127-
console.log(`[host] mcp-ui/initialize from ${params.appInfo.name}@${params.appInfo.version}`);
128-
return {
129-
protocolVersion: params.protocolVersion,
130-
hostInfo: { name: 'demo-host', version: '1.0.0' },
131-
hostContext: { theme: 'dark', locale: 'en-US' }
132-
};
133-
});
134-
135-
this.server.setCustomRequestHandler('mcp-ui/openLink', OpenLinkParams, params => {
140+
this.ui.setRequestHandler('mcp-ui/openLink', OpenLinkParams, params => {
136141
console.log(`[host] mcp-ui/openLink url=${params.url}`);
137142
return { opened: true };
138143
});
139144

140-
this.server.setCustomNotificationHandler('mcp-ui/initialized', z.object({}).optional(), () => {
141-
console.log('[host] mcp-ui/initialized');
142-
});
143-
144-
this.server.setCustomNotificationHandler('mcp-ui/sizeChanged', SizeChangedParams, p => {
145+
this.ui.setNotificationHandler('mcp-ui/sizeChanged', SizeChangedParams, p => {
145146
console.log(`[host] mcp-ui/sizeChanged ${p.width}x${p.height}`);
146147
this.onSizeChanged?.(p);
147148
});
148149
}
149150

151+
get appCapabilities(): AppCapabilities | undefined {
152+
return this.ui.getPeerSettings();
153+
}
154+
150155
notifyToolResult(toolName: string, text: string) {
151-
return this.server.sendCustomNotification('mcp-ui/toolResult', {
156+
return this.ui.sendNotification('mcp-ui/toolResult', {
152157
toolName,
153158
content: [{ type: 'text', text }]
154159
});
155160
}
156161

157162
notifyHostContextChanged(patch: z.infer<typeof HostContextChangedParams>) {
158-
return this.server.sendCustomNotification('mcp-ui/hostContextChanged', patch);
163+
return this.ui.sendNotification('mcp-ui/hostContextChanged', patch);
159164
}
160165

161166
requestTeardown(reason: string) {
162-
return this.server.sendCustomRequest('mcp-ui/resourceTeardown', { reason }, z.object({}));
167+
return this.ui.sendRequest('mcp-ui/resourceTeardown', { reason }, z.object({}));
163168
}
164169
}
165170

@@ -169,7 +174,7 @@ class Host {
169174

170175
async function main() {
171176
const host = new Host();
172-
const app = new App({ name: 'demo-app', version: '1.0.0' });
177+
const app = new App({ name: 'demo-app', version: '1.0.0' }, { availableDisplayModes: ['inline', 'fullscreen'] });
173178

174179
app.addEventListener('toolresult', p => console.log(`[app] toolresult: ${p.toolName} -> "${p.content[0]?.text}"`));
175180
app.addEventListener('hostcontextchanged', p => console.log(`[app] hostcontextchanged: ${JSON.stringify(p)}`));
@@ -180,6 +185,9 @@ async function main() {
180185
await host.server.connect(serverTransport);
181186
await app.connect(clientTransport);
182187

188+
// Capability negotiation via MCP initialize — both sides see each other's extensions[EXT_ID]
189+
console.log(`[app] hostCapabilities: ${JSON.stringify(app.hostCapabilities)}`);
190+
console.log(`[host] appCapabilities: ${JSON.stringify(host.appCapabilities)}`);
183191
console.log(`[app] hostContext after init: ${JSON.stringify(app.getHostContext())}`);
184192

185193
// App -> Host: custom request

packages/client/src/client/client.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/client/_shims';
22
import type {
3+
AnySchema,
34
BaseContext,
45
CallToolRequest,
56
ClientCapabilities,
@@ -8,8 +9,10 @@ import type {
89
ClientRequest,
910
ClientResult,
1011
CompleteRequest,
12+
ExtensionOptions,
1113
GetPromptRequest,
1214
Implementation,
15+
JSONObject,
1316
JsonSchemaType,
1417
JsonSchemaValidator,
1518
jsonSchemaValidator,
@@ -28,6 +31,7 @@ import type {
2831
RequestOptions,
2932
RequestTypeMap,
3033
ResultTypeMap,
34+
SchemaOutput,
3135
ServerCapabilities,
3236
SubscribeRequest,
3337
TaskManagerOptions,
@@ -47,6 +51,7 @@ import {
4751
ElicitRequestSchema,
4852
ElicitResultSchema,
4953
EmptyResultSchema,
54+
ExtensionHandle,
5055
extractTaskManagerOptions,
5156
GetPromptResultSchema,
5257
InitializeResultSchema,
@@ -307,6 +312,40 @@ export class Client extends Protocol<ClientContext> {
307312
this._capabilities = mergeCapabilities(this._capabilities, capabilities);
308313
}
309314

315+
/**
316+
* Declares an SEP-2133 extension and returns a scoped {@linkcode ExtensionHandle} for
317+
* registering and sending its custom JSON-RPC methods.
318+
*
319+
* Merges `settings` into `capabilities.extensions[id]`, which is advertised to the server
320+
* during `initialize`. Must be called before {@linkcode connect}. After connecting,
321+
* {@linkcode ExtensionHandle.getPeerSettings | handle.getPeerSettings()} returns the server's
322+
* `capabilities.extensions[id]` blob (validated against `peerSchema` if provided).
323+
*/
324+
public extension<L extends JSONObject>(id: string, settings: L): ExtensionHandle<L, JSONObject, ClientContext>;
325+
public extension<L extends JSONObject, P extends AnySchema>(
326+
id: string,
327+
settings: L,
328+
opts: ExtensionOptions<P>
329+
): ExtensionHandle<L, SchemaOutput<P>, ClientContext>;
330+
public extension<L extends JSONObject, P extends AnySchema>(
331+
id: string,
332+
settings: L,
333+
opts?: ExtensionOptions<P>
334+
): ExtensionHandle<L, SchemaOutput<P> | JSONObject, ClientContext> {
335+
if (this.transport) {
336+
throw new SdkError(SdkErrorCode.AlreadyConnected, 'Cannot register extension after connecting to transport');
337+
}
338+
this._capabilities.extensions = { ...this._capabilities.extensions, [id]: settings };
339+
return new ExtensionHandle(
340+
this,
341+
id,
342+
settings,
343+
() => this._serverCapabilities?.extensions?.[id],
344+
this._enforceStrictCapabilities,
345+
opts?.peerSchema
346+
);
347+
}
348+
310349
/**
311350
* Registers a handler for server-initiated requests (sampling, elicitation, roots).
312351
* The client must declare the corresponding capability for the handler to be accepted.

packages/core/src/exports/public/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export type {
3535
// Auth utilities
3636
export { checkResourceAllowed, resourceUrlFromServerUrl } from '../../shared/authUtils.js';
3737

38+
// Extension registrar (SEP-2133 capability-aware custom methods)
39+
export type { ExtensionOptions } from '../../shared/extensionHandle.js';
40+
export { ExtensionHandle } from '../../shared/extensionHandle.js';
41+
3842
// Metadata utilities
3943
export { getDisplayName } from '../../shared/metadataUtils.js';
4044

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export * from './auth/errors.js';
22
export * from './errors/sdkErrors.js';
33
export * from './shared/auth.js';
44
export * from './shared/authUtils.js';
5+
export * from './shared/extensionHandle.js';
56
export * from './shared/metadataUtils.js';
67
export * from './shared/protocol.js';
78
export * from './shared/responseMessage.js';

0 commit comments

Comments
 (0)