Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
42 changes: 42 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 @@
ClientRequest,
ClientResult,
CompleteRequest,
ExtensionOptions,
GetPromptRequest,
Implementation,
JSONObject,
JsonSchemaType,
JsonSchemaValidator,
jsonSchemaValidator,
Expand All @@ -28,6 +31,7 @@
RequestOptions,
RequestTypeMap,
ResultTypeMap,
SchemaOutput,
ServerCapabilities,
SubscribeRequest,
TaskManagerOptions,
Expand All @@ -47,6 +51,7 @@
ElicitRequestSchema,
ElicitResultSchema,
EmptyResultSchema,
ExtensionHandle,
extractTaskManagerOptions,
GetPromptResultSchema,
InitializeResultSchema,
Expand Down Expand Up @@ -307,6 +312,43 @@
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).
*/
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 Error(`Extension "${id}" is already registered`);

Check warning on line 339 in packages/client/src/client/client.ts

View check run for this annotation

Claude / Claude Code Review

registerCapabilities() silently overwrites extension() settings breaking handle.settings invariant

After calling extension('io.a', {v:1}), a subsequent registerCapabilities({extensions: {'io.a': {different: true}}}) silently overwrites the wire capabilities for that extension, while handle.settings still holds the original {v:1} — violating the documented invariant that handle.settings is 'The local settings object advertised in capabilities.extensions[id]'. The inconsistency is compounded by an API asymmetry: calling extension() twice for the same ID throws 'already registered', but register
Comment on lines +337 to +339
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 After calling extension('io.a', {v:1}), a subsequent registerCapabilities({extensions: {'io.a': {different: true}}}) silently overwrites the wire capabilities for that extension, while handle.settings still holds the original {v:1} — violating the documented invariant that handle.settings is 'The local settings object advertised in capabilities.extensions[id]'. The inconsistency is compounded by an API asymmetry: calling extension() twice for the same ID throws 'already registered', but registerCapabilities() can silently overwrite it.

Extended reasoning...

What the bug is and how it manifests

extension(id, settings) creates an ExtensionHandle and sets _capabilities.extensions[id] = settings. The returned handle stores settings as a readonly field, with the JSDoc contract: 'The local settings object advertised in capabilities.extensions[id]'. However, registerCapabilities() delegates to mergeCapabilities(), which does a one-level-deep object spread: { ...base.extensions, ...additional.extensions }. This means any extension ID present in both base and additional is overwritten at the entry level, not merged.

The specific code path that triggers it

  1. client.extension('io.a', {v:1}) sets _capabilities.extensions = {'io.a': {v:1}} and handle.settings = {v:1}
  2. client.registerCapabilities({extensions: {'io.a': {different: true}}}) calls mergeCapabilities which spreads extensions, resulting in _capabilities.extensions = {'io.a': {different: true}}
  3. handle.settings still returns {v:1} (captured at construction time)
  4. On connect(), the server receives {different: true} for 'io.a' in the initialize request, not {v:1}

Why existing code does not prevent it

The extension() method guards against duplicate registrations with Object.hasOwn(this._capabilities.extensions, id) and throws 'already registered'. But this guard only covers the extension() to extension() path. The registerCapabilities() path has no such guard and calls mergeCapabilities() unconditionally, which can silently stomp any previously-registered extension entry.

Addressing the refutation

The refutation notes the scenario is implausible in normal usage — no sane caller would call extension('io.a', {v:1}) and then immediately call registerCapabilities({extensions: {'io.a': {v:2}}}). This is a fair point for the common case. However: (a) the API contract expressed in JSDoc is unambiguously violated, (b) the asymmetry where extension() throws on duplicates while registerCapabilities() silently overwrites is a genuine footgun for callers who compose multiple libraries that each call registerCapabilities(), and (c) the failure is silent with no warning. The scenario is unusual but the contract violation is real. Severity: nit.

Impact

Any caller reading handle.settings after a registerCapabilities() call that overlapped the extension ID will see stale local data while the peer was advertised something different. Extension-aware logic relying on handle.settings as the source of truth would be silently wrong.

How to fix it

The simplest fix is to add an overlap check in registerCapabilities() for IDs already registered via extension(). A cleaner approach is to maintain a _registeredExtensionIds Set and throw (or at minimum warn) in registerCapabilities() when the incoming extensions object overlaps that set. Alternatively, document clearly that registerCapabilities() can override extension settings registered via extension(), downgrading the JSDoc invariant so callers are not misled.

Step-by-step proof

  1. const client = new Client({name: 'c', version: '1'})
  2. const handle = client.extension('io.a', {v: 1}) — _capabilities.extensions = {'io.a': {v:1}}, handle.settings = {v:1}
  3. client.registerCapabilities({extensions: {'io.a': {different: true}}}) — mergeCapabilities spreads extensions at the top level, result: _capabilities.extensions = {'io.a': {different: true}}
  4. handle.settings still returns {v:1} — stale
  5. await client.connect(transport) — initialize params contain capabilities.extensions = {'io.a': {different: true}}
  6. Server receives {different: true}; handle.settings still says {v:1}. JSDoc invariant broken.

}
this._capabilities.extensions = { ...this._capabilities.extensions, [id]: settings };
return new ExtensionHandle(
this,
id,
settings,
() => this._serverCapabilities?.extensions?.[id],
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 });
});
});
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