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`.
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
47 changes: 47 additions & 0 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/client/_shims';
import type {
AnySchema,
BaseContext,
CallToolRequest,
ClientCapabilities,
Expand All @@ -8,8 +9,10 @@ import type {
ClientRequest,
ClientResult,
CompleteRequest,
ExtensionOptions,
GetPromptRequest,
Implementation,
JSONObject,
JsonSchemaType,
JsonSchemaValidator,
jsonSchemaValidator,
Expand All @@ -28,6 +31,7 @@ import type {
RequestOptions,
RequestTypeMap,
ResultTypeMap,
SchemaOutput,
ServerCapabilities,
SubscribeRequest,
TaskManagerOptions,
Expand All @@ -47,6 +51,7 @@ import {
ElicitRequestSchema,
ElicitResultSchema,
EmptyResultSchema,
ExtensionHandle,
extractTaskManagerOptions,
GetPromptResultSchema,
InitializeResultSchema,
Expand Down Expand Up @@ -307,6 +312,48 @@ export class Client extends Protocol<ClientContext> {
this._capabilities = mergeCapabilities(this._capabilities, capabilities);
}

/**
* Declares an SEP-2133 extension and returns a scoped {@linkcode ExtensionHandle} for
* registering and sending its custom JSON-RPC methods.
*
* Merges `settings` into `capabilities.extensions[id]`, which is advertised to the server
* during `initialize`. Must be called before {@linkcode connect}. After connecting,
* {@linkcode ExtensionHandle.getPeerSettings | handle.getPeerSettings()} returns the server's
* `capabilities.extensions[id]` blob (validated against `peerSchema` if provided).
*
* Note: a later {@linkcode registerCapabilities} call that includes `extensions[id]` will
* overwrite the wire value declared here; the returned handle's `settings` reflects what
* was passed to this call, not subsequent overwrites.
*/
public extension<L extends JSONObject>(id: string, settings: L): ExtensionHandle<L, JSONObject, ClientContext>;
public extension<L extends JSONObject, P extends AnySchema>(
id: string,
settings: L,
opts: ExtensionOptions<P>
): ExtensionHandle<L, SchemaOutput<P>, ClientContext>;
public extension<L extends JSONObject, P extends AnySchema>(
id: string,
settings: L,
opts?: ExtensionOptions<P>
): ExtensionHandle<L, SchemaOutput<P> | JSONObject, ClientContext> {
if (this.transport) {
throw new SdkError(SdkErrorCode.AlreadyConnected, 'Cannot register extension after connecting to transport');
}
if (this._capabilities.extensions && Object.hasOwn(this._capabilities.extensions, id)) {
throw new SdkError(SdkErrorCode.ExtensionAlreadyRegistered, `Extension "${id}" is already registered`);
}
this._capabilities.extensions = { ...this._capabilities.extensions, [id]: settings };
return new ExtensionHandle(
this,
id,
settings,
Comment thread
felixweinberger marked this conversation as resolved.
() => this._serverCapabilities?.extensions?.[id],
() => this._serverCapabilities !== undefined,
() => this._enforceStrictCapabilities,
opts?.peerSchema
);
}

/**
* Registers a handler for server-initiated requests (sampling, elicitation, roots).
* The client must declare the corresponding capability for the handler to be accepted.
Expand Down
111 changes: 111 additions & 0 deletions packages/client/test/client/extension.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { InMemoryTransport, type JSONRPCMessage, SdkError, SdkErrorCode } from '@modelcontextprotocol/core';
import { describe, expect, test } from 'vitest';
import * as z from 'zod/v4';

import { Client } from '../../src/client/client.js';

/**
* These tests exercise the `Client.extension()` factory and the client side of the
* `capabilities.extensions` round-trip via `initialize`. The `ExtensionHandle` class itself is
* unit-tested in `@modelcontextprotocol/core/test/shared/extensionHandle.test.ts`.
*/

interface RawServerHarness {
serverSide: InMemoryTransport;
capturedInitParams: Promise<Record<string, unknown>>;
}

function rawServer(serverCapabilities: Record<string, unknown> = {}): RawServerHarness {
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
let resolveInit: (p: Record<string, unknown>) => void;
const capturedInitParams = new Promise<Record<string, unknown>>(r => {
resolveInit = r;
});
serverSide.onmessage = (msg: JSONRPCMessage) => {
if ('method' in msg && msg.method === 'initialize' && 'id' in msg) {
resolveInit((msg.params ?? {}) as Record<string, unknown>);
void serverSide.send({
jsonrpc: '2.0',
id: msg.id,
result: {
protocolVersion: '2025-11-25',
capabilities: serverCapabilities,
serverInfo: { name: 'raw-server', version: '0.0.0' }
}
});
}
};
void serverSide.start();
// Expose clientSide via the harness's serverSide.peer for the test to connect to.
return { serverSide: clientSide, capturedInitParams };
}

describe('Client.extension()', () => {
test('merges settings into capabilities.extensions and advertises them in initialize request', async () => {
const client = new Client({ name: 'c', version: '1.0.0' }, { capabilities: {} });
client.extension('io.example/ui', { contentTypes: ['text/html'] });
client.extension('com.acme/widgets', { v: 2 });

const harness = rawServer();
await client.connect(harness.serverSide);
const initParams = await harness.capturedInitParams;

const caps = initParams.capabilities as Record<string, unknown>;
expect(caps.extensions).toEqual({
'io.example/ui': { contentTypes: ['text/html'] },
'com.acme/widgets': { v: 2 }
});
});

test('throws AlreadyConnected after connect()', async () => {
const client = new Client({ name: 'c', version: '1.0.0' });
const harness = rawServer();
await client.connect(harness.serverSide);

expect(() => client.extension('io.example/ui', {})).toThrow(SdkError);
try {
client.extension('io.example/ui', {});
expect.fail('should have thrown');
} catch (e) {
expect(e).toBeInstanceOf(SdkError);
expect((e as SdkError).code).toBe(SdkErrorCode.AlreadyConnected);
}
});

test('throws on duplicate extension id', () => {
const client = new Client({ name: 'c', version: '1.0.0' });
client.extension('io.example/ui', { v: 1 });
expect(() => client.extension('io.example/ui', { v: 2 })).toThrow(/already registered/);
expect(() => client.extension('com.other/thing', {})).not.toThrow();
});

test("getPeerSettings() reads the server's capabilities.extensions[id] from initialize result", async () => {
const PeerSchema = z.object({ availableDisplayModes: z.array(z.string()) });
const client = new Client({ name: 'c', version: '1.0.0' });
const handle = client.extension('io.example/ui', { clientSide: true }, { peerSchema: PeerSchema });

expect(handle.getPeerSettings()).toBeUndefined();

const harness = rawServer({
extensions: { 'io.example/ui': { availableDisplayModes: ['inline', 'fullscreen'] } }
});
await client.connect(harness.serverSide);

expect(handle.getPeerSettings()).toEqual({ availableDisplayModes: ['inline', 'fullscreen'] });
});

test('getPeerSettings() reflects reconnect to a different server', async () => {
const client = new Client({ name: 'c', version: '1.0.0' });
const handle = client.extension('io.example/ui', {});

const harnessA = rawServer({ extensions: { 'io.example/ui': { v: 1 } } });
await client.connect(harnessA.serverSide);
expect(handle.getPeerSettings()).toEqual({ v: 1 });

await client.close();

const harnessB = rawServer({ extensions: { 'io.example/ui': { v: 2 } } });
await client.connect(harnessB.serverSide);
expect(handle.getPeerSettings()).toEqual({ v: 2 });
});
});
1 change: 1 addition & 0 deletions packages/core/src/errors/sdkErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
// State errors
/** Transport is not connected */
NotConnected = 'NOT_CONNECTED',
ExtensionAlreadyRegistered = 'EXTENSION_ALREADY_REGISTERED',

Check warning on line 13 in packages/core/src/errors/sdkErrors.ts

View check run for this annotation

Claude / Claude Code Review

ExtensionAlreadyRegistered enum member lacks JSDoc and is awkwardly placed

The new `ExtensionAlreadyRegistered` member is missing a JSDoc comment and is wedged between `NotConnected` and the `/** Transport is already connected */` comment for `AlreadyConnected`, breaking up an obvious pair. Consider adding a JSDoc line (e.g. `/** Extension ID is already registered via Client.extension()/Server.extension() */`) and moving it after `AlreadyConnected`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new ExtensionAlreadyRegistered member is missing a JSDoc comment and is wedged between NotConnected and the /** Transport is already connected */ comment for AlreadyConnected, breaking up an obvious pair. Consider adding a JSDoc line (e.g. /** Extension ID is already registered via Client.extension()/Server.extension() */) and moving it after AlreadyConnected.

Extended reasoning...

What the issue is

The new enum member at line 13 is inserted without a JSDoc comment, while every other member in the // State errors group (NotConnected, AlreadyConnected, NotInitialized) has one:

// State errors
/** Transport is not connected */
NotConnected = 'NOT_CONNECTED',
ExtensionAlreadyRegistered = 'EXTENSION_ALREADY_REGISTERED',
/** Transport is already connected */
AlreadyConnected = 'ALREADY_CONNECTED',
/** Protocol is not initialized */
NotInitialized = 'NOT_INITIALIZED',

The placement also splits the obvious NotConnected/AlreadyConnected pair, which reads oddly when scanning the source.

Why this is only a nit

There is no functional or tooling impact: the /** Transport is already connected */ comment still sits directly above AlreadyConnected, so TypeDoc/IDE hover correctly attributes it. Also, the file is not perfectly uniform — the ClientHttp* members at lines 32–37 also lack JSDoc — so the original claim that every member is documented overstates things. But within the State-errors group the pattern is consistent, and this PR also documents ExtensionAlreadyRegistered in docs/migration.md's SdkErrorCode table only by implication (it's actually not listed there either), so a one-liner here is the only place a reader will find what it means.

Step-by-step proof

  1. Open packages/core/src/errors/sdkErrors.ts at the // State errors group.
  2. Line 11–12: /** Transport is not connected */ → NotConnected. Documented.
  3. Line 13: ExtensionAlreadyRegistered = 'EXTENSION_ALREADY_REGISTERED', — no preceding /** ... */. Undocumented.
  4. Line 14–15: /** Transport is already connected */ → AlreadyConnected. Documented, and still correctly attached (comment is immediately above its target).
  5. Line 16–17: /** Protocol is not initialized */ → NotInitialized. Documented.
  6. Result: 3/4 members in the group documented; the new one is the odd one out, and it sits between two transport-connection-state siblings rather than after them.

How to fix

// State errors
/** Transport is not connected */
NotConnected = 'NOT_CONNECTED',
/** Transport is already connected */
AlreadyConnected = 'ALREADY_CONNECTED',
/** Protocol is not initialized */
NotInitialized = 'NOT_INITIALIZED',
/** Extension ID is already registered via Client.extension()/Server.extension() */
ExtensionAlreadyRegistered = 'EXTENSION_ALREADY_REGISTERED',

(Or keep it in the State-errors block but after AlreadyConnected — either way, add the comment.)

/** Transport is already connected */
AlreadyConnected = 'ALREADY_CONNECTED',
/** Protocol is not initialized */
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/exports/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ export type {
// Auth utilities
export { checkResourceAllowed, resourceUrlFromServerUrl } from '../../shared/authUtils.js';

// Extension registrar (SEP-2133 capability-aware custom methods)
export type { ExtensionOptions } from '../../shared/extensionHandle.js';
export { ExtensionHandle } from '../../shared/extensionHandle.js';

// Metadata utilities
export { getDisplayName } from '../../shared/metadataUtils.js';

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from './auth/errors.js';
export * from './errors/sdkErrors.js';
export * from './shared/auth.js';
export * from './shared/authUtils.js';
export * from './shared/extensionHandle.js';
export * from './shared/metadataUtils.js';
export * from './shared/protocol.js';
export * from './shared/responseMessage.js';
Expand Down
Loading
Loading