Skip to content

Commit af55aa2

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 e4cb1a9 commit af55aa2

File tree

9 files changed

+532
-0
lines changed

9 files changed

+532
-0
lines changed

.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
@@ -434,6 +434,36 @@ before sending and gives typed `params`; passing a bare result schema sends para
434434

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/customMethodExample.ts` and `examples/client/src/customMethodExample.ts` for runnable examples.
436436

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

439469
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

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';
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js';
2+
import type { JSONObject, Result } from '../types/types.js';
3+
import type { AnySchema, SchemaOutput } from '../util/schema.js';
4+
import { parseSchema } from '../util/schema.js';
5+
import type { BaseContext, NotificationOptions, RequestOptions } from './protocol.js';
6+
7+
/**
8+
* The subset of `Client`/`Server` that {@linkcode ExtensionHandle} delegates to.
9+
*
10+
* @internal
11+
*/
12+
export interface ExtensionHost<ContextT extends BaseContext> {
13+
setCustomRequestHandler<P extends AnySchema>(
14+
method: string,
15+
paramsSchema: P,
16+
handler: (params: SchemaOutput<P>, ctx: ContextT) => Result | Promise<Result>
17+
): void;
18+
setCustomNotificationHandler<P extends AnySchema>(
19+
method: string,
20+
paramsSchema: P,
21+
handler: (params: SchemaOutput<P>) => void | Promise<void>
22+
): void;
23+
sendCustomRequest<R extends AnySchema>(
24+
method: string,
25+
params: Record<string, unknown> | undefined,
26+
resultSchema: R,
27+
options?: RequestOptions
28+
): Promise<SchemaOutput<R>>;
29+
sendCustomNotification(method: string, params?: Record<string, unknown>, options?: NotificationOptions): Promise<void>;
30+
}
31+
32+
/**
33+
* Options for {@linkcode Client.extension} / {@linkcode Server.extension}.
34+
*/
35+
export interface ExtensionOptions<P extends AnySchema> {
36+
/**
37+
* Schema to validate the peer's `capabilities.extensions[id]` blob against. When provided,
38+
* {@linkcode ExtensionHandle.getPeerSettings | getPeerSettings()} returns the parsed value
39+
* (typed as `SchemaOutput<P>`) or `undefined` if the peer's blob does not match.
40+
*/
41+
peerSchema: P;
42+
}
43+
44+
/**
45+
* A scoped handle for registering and sending custom JSON-RPC methods belonging to a single
46+
* SEP-2133 extension.
47+
*
48+
* Obtained via {@linkcode Client.extension} or {@linkcode Server.extension}. Creating a handle
49+
* declares the extension in `capabilities.extensions[id]` so it is advertised during `initialize`.
50+
* Handlers registered through the handle are thus structurally guaranteed to belong to a declared
51+
* extension.
52+
*
53+
* Send-side methods respect `enforceStrictCapabilities`: when strict, sending throws if the peer
54+
* did not advertise the same extension ID; when lax (the default), sends proceed regardless and
55+
* {@linkcode getPeerSettings} returns `undefined`.
56+
*/
57+
export class ExtensionHandle<Local extends JSONObject, Peer = JSONObject, ContextT extends BaseContext = BaseContext> {
58+
private _peerSettingsCache?: { value: Peer | undefined };
59+
60+
/**
61+
* @internal Use {@linkcode Client.extension} or {@linkcode Server.extension} to construct.
62+
*/
63+
constructor(
64+
private readonly _host: ExtensionHost<ContextT>,
65+
/** The SEP-2133 extension identifier (e.g. `io.modelcontextprotocol/ui`). */
66+
public readonly id: string,
67+
/** The local settings object advertised in `capabilities.extensions[id]`. */
68+
public readonly settings: Local,
69+
private readonly _getPeerExtensionSettings: () => JSONObject | undefined,
70+
private readonly _enforceStrictCapabilities: boolean,
71+
private readonly _peerSchema?: AnySchema
72+
) {}
73+
74+
/**
75+
* Returns the peer's `capabilities.extensions[id]` settings, or `undefined` if the peer did not
76+
* advertise this extension or (when `peerSchema` was provided) if the peer's blob fails
77+
* validation. The result is parsed once and cached.
78+
*/
79+
getPeerSettings(): Peer | undefined {
80+
if (this._peerSettingsCache) {
81+
return this._peerSettingsCache.value;
82+
}
83+
const raw = this._getPeerExtensionSettings();
84+
if (raw === undefined) {
85+
// Don't cache: peer may not have connected yet.
86+
return undefined;
87+
}
88+
let value: Peer | undefined;
89+
if (this._peerSchema === undefined) {
90+
value = raw as Peer;
91+
} else {
92+
const parsed = parseSchema(this._peerSchema, raw);
93+
if (parsed.success) {
94+
value = parsed.data as Peer;
95+
} else {
96+
console.warn(
97+
`[ExtensionHandle] Peer's capabilities.extensions["${this.id}"] failed schema validation: ${parsed.error.message}`
98+
);
99+
value = undefined;
100+
}
101+
}
102+
this._peerSettingsCache = { value };
103+
return value;
104+
}
105+
106+
/**
107+
* Registers a request handler for a custom method belonging to this extension. Delegates to
108+
* {@linkcode Protocol.setCustomRequestHandler | setCustomRequestHandler}; the collision guard
109+
* against standard MCP methods applies.
110+
*/
111+
setRequestHandler<P extends AnySchema>(
112+
method: string,
113+
paramsSchema: P,
114+
handler: (params: SchemaOutput<P>, ctx: ContextT) => Result | Promise<Result>
115+
): void {
116+
this._host.setCustomRequestHandler(method, paramsSchema, handler);
117+
}
118+
119+
/**
120+
* Registers a notification handler for a custom method belonging to this extension. Delegates
121+
* to {@linkcode Protocol.setCustomNotificationHandler | setCustomNotificationHandler}.
122+
*/
123+
setNotificationHandler<P extends AnySchema>(
124+
method: string,
125+
paramsSchema: P,
126+
handler: (params: SchemaOutput<P>) => void | Promise<void>
127+
): void {
128+
this._host.setCustomNotificationHandler(method, paramsSchema, handler);
129+
}
130+
131+
/**
132+
* Sends a custom request belonging to this extension and waits for a response.
133+
*
134+
* When `enforceStrictCapabilities` is enabled and the peer did not advertise
135+
* `capabilities.extensions[id]`, throws {@linkcode SdkError} with
136+
* {@linkcode SdkErrorCode.CapabilityNotSupported}.
137+
*/
138+
sendRequest<R extends AnySchema>(
139+
method: string,
140+
params: Record<string, unknown> | undefined,
141+
resultSchema: R,
142+
options?: RequestOptions
143+
): Promise<SchemaOutput<R>> {
144+
this._assertPeerCapability(method);
145+
return this._host.sendCustomRequest(method, params, resultSchema, options);
146+
}
147+
148+
/**
149+
* Sends a custom notification belonging to this extension.
150+
*
151+
* When `enforceStrictCapabilities` is enabled and the peer did not advertise
152+
* `capabilities.extensions[id]`, throws {@linkcode SdkError} with
153+
* {@linkcode SdkErrorCode.CapabilityNotSupported}.
154+
*/
155+
sendNotification(method: string, params?: Record<string, unknown>, options?: NotificationOptions): Promise<void> {
156+
this._assertPeerCapability(method);
157+
return this._host.sendCustomNotification(method, params, options);
158+
}
159+
160+
private _assertPeerCapability(method: string): void {
161+
if (this._enforceStrictCapabilities && this._getPeerExtensionSettings() === undefined) {
162+
throw new SdkError(
163+
SdkErrorCode.CapabilityNotSupported,
164+
`Peer does not support extension "${this.id}" (required for ${method})`
165+
);
166+
}
167+
}
168+
}

0 commit comments

Comments
 (0)