From af55aa24254006672027dab5dd37b1ca488f972f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 9 Apr 2026 14:02:54 +0000 Subject: [PATCH 1/7] 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. --- .changeset/extension-registrar.md | 6 + docs/migration.md | 30 ++++ packages/client/src/client/client.ts | 39 ++++ packages/core/src/exports/public/index.ts | 4 + packages/core/src/index.ts | 1 + packages/core/src/shared/extensionHandle.ts | 168 ++++++++++++++++++ .../core/test/shared/extensionHandle.test.ts | 148 +++++++++++++++ packages/server/src/server/server.ts | 41 +++++ packages/server/test/server/extension.test.ts | 95 ++++++++++ 9 files changed, 532 insertions(+) create mode 100644 .changeset/extension-registrar.md create mode 100644 packages/core/src/shared/extensionHandle.ts create mode 100644 packages/core/test/shared/extensionHandle.test.ts create mode 100644 packages/server/test/server/extension.test.ts diff --git a/.changeset/extension-registrar.md b/.changeset/extension-registrar.md new file mode 100644 index 000000000..48379ccc2 --- /dev/null +++ b/.changeset/extension-registrar.md @@ -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`. diff --git a/docs/migration.md b/docs/migration.md index 8a63a1162..6a56cdabe 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -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 diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 044b77fe8..3a086a9f1 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -1,5 +1,6 @@ import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/client/_shims'; import type { + AnySchema, BaseContext, CallToolRequest, ClientCapabilities, @@ -8,8 +9,10 @@ import type { ClientRequest, ClientResult, CompleteRequest, + ExtensionOptions, GetPromptRequest, Implementation, + JSONObject, JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, @@ -28,6 +31,7 @@ import type { RequestOptions, RequestTypeMap, ResultTypeMap, + SchemaOutput, ServerCapabilities, SubscribeRequest, TaskManagerOptions, @@ -47,6 +51,7 @@ import { ElicitRequestSchema, ElicitResultSchema, EmptyResultSchema, + ExtensionHandle, extractTaskManagerOptions, GetPromptResultSchema, InitializeResultSchema, @@ -307,6 +312,40 @@ export class Client extends Protocol { 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(id: string, settings: L): ExtensionHandle; + public extension( + id: string, + settings: L, + opts: ExtensionOptions

+ ): ExtensionHandle, ClientContext>; + public extension( + id: string, + settings: L, + opts?: ExtensionOptions

+ ): ExtensionHandle | JSONObject, ClientContext> { + if (this.transport) { + throw new SdkError(SdkErrorCode.AlreadyConnected, 'Cannot register extension after connecting to transport'); + } + 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. diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 353567ba5..05a409b49 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -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'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e707d9939..277d28f4b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/shared/extensionHandle.ts b/packages/core/src/shared/extensionHandle.ts new file mode 100644 index 000000000..f5fe10947 --- /dev/null +++ b/packages/core/src/shared/extensionHandle.ts @@ -0,0 +1,168 @@ +import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js'; +import type { JSONObject, Result } from '../types/types.js'; +import type { AnySchema, SchemaOutput } from '../util/schema.js'; +import { parseSchema } from '../util/schema.js'; +import type { BaseContext, NotificationOptions, RequestOptions } from './protocol.js'; + +/** + * The subset of `Client`/`Server` that {@linkcode ExtensionHandle} delegates to. + * + * @internal + */ +export interface ExtensionHost { + setCustomRequestHandler

( + method: string, + paramsSchema: P, + handler: (params: SchemaOutput

, ctx: ContextT) => Result | Promise + ): void; + setCustomNotificationHandler

( + method: string, + paramsSchema: P, + handler: (params: SchemaOutput

) => void | Promise + ): void; + sendCustomRequest( + method: string, + params: Record | undefined, + resultSchema: R, + options?: RequestOptions + ): Promise>; + sendCustomNotification(method: string, params?: Record, options?: NotificationOptions): Promise; +} + +/** + * Options for {@linkcode Client.extension} / {@linkcode Server.extension}. + */ +export interface ExtensionOptions

{ + /** + * Schema to validate the peer's `capabilities.extensions[id]` blob against. When provided, + * {@linkcode ExtensionHandle.getPeerSettings | getPeerSettings()} returns the parsed value + * (typed as `SchemaOutput

`) or `undefined` if the peer's blob does not match. + */ + peerSchema: P; +} + +/** + * A scoped handle for registering and sending custom JSON-RPC methods belonging to a single + * SEP-2133 extension. + * + * Obtained via {@linkcode Client.extension} or {@linkcode Server.extension}. Creating a handle + * declares the extension in `capabilities.extensions[id]` so it is advertised during `initialize`. + * Handlers registered through the handle are thus structurally guaranteed to belong to a declared + * extension. + * + * Send-side methods respect `enforceStrictCapabilities`: when strict, sending throws if the peer + * did not advertise the same extension ID; when lax (the default), sends proceed regardless and + * {@linkcode getPeerSettings} returns `undefined`. + */ +export class ExtensionHandle { + private _peerSettingsCache?: { value: Peer | undefined }; + + /** + * @internal Use {@linkcode Client.extension} or {@linkcode Server.extension} to construct. + */ + constructor( + private readonly _host: ExtensionHost, + /** The SEP-2133 extension identifier (e.g. `io.modelcontextprotocol/ui`). */ + public readonly id: string, + /** The local settings object advertised in `capabilities.extensions[id]`. */ + public readonly settings: Local, + private readonly _getPeerExtensionSettings: () => JSONObject | undefined, + private readonly _enforceStrictCapabilities: boolean, + private readonly _peerSchema?: AnySchema + ) {} + + /** + * Returns the peer's `capabilities.extensions[id]` settings, or `undefined` if the peer did not + * advertise this extension or (when `peerSchema` was provided) if the peer's blob fails + * validation. The result is parsed once and cached. + */ + getPeerSettings(): Peer | undefined { + if (this._peerSettingsCache) { + return this._peerSettingsCache.value; + } + const raw = this._getPeerExtensionSettings(); + if (raw === undefined) { + // Don't cache: peer may not have connected yet. + return undefined; + } + let value: Peer | undefined; + if (this._peerSchema === undefined) { + value = raw as Peer; + } else { + const parsed = parseSchema(this._peerSchema, raw); + if (parsed.success) { + value = parsed.data as Peer; + } else { + console.warn( + `[ExtensionHandle] Peer's capabilities.extensions["${this.id}"] failed schema validation: ${parsed.error.message}` + ); + value = undefined; + } + } + this._peerSettingsCache = { value }; + return value; + } + + /** + * Registers a request handler for a custom method belonging to this extension. Delegates to + * {@linkcode Protocol.setCustomRequestHandler | setCustomRequestHandler}; the collision guard + * against standard MCP methods applies. + */ + setRequestHandler

( + method: string, + paramsSchema: P, + handler: (params: SchemaOutput

, ctx: ContextT) => Result | Promise + ): void { + this._host.setCustomRequestHandler(method, paramsSchema, handler); + } + + /** + * Registers a notification handler for a custom method belonging to this extension. Delegates + * to {@linkcode Protocol.setCustomNotificationHandler | setCustomNotificationHandler}. + */ + setNotificationHandler

( + method: string, + paramsSchema: P, + handler: (params: SchemaOutput

) => void | Promise + ): void { + this._host.setCustomNotificationHandler(method, paramsSchema, handler); + } + + /** + * Sends a custom request belonging to this extension and waits for a response. + * + * When `enforceStrictCapabilities` is enabled and the peer did not advertise + * `capabilities.extensions[id]`, throws {@linkcode SdkError} with + * {@linkcode SdkErrorCode.CapabilityNotSupported}. + */ + sendRequest( + method: string, + params: Record | undefined, + resultSchema: R, + options?: RequestOptions + ): Promise> { + this._assertPeerCapability(method); + return this._host.sendCustomRequest(method, params, resultSchema, options); + } + + /** + * Sends a custom notification belonging to this extension. + * + * When `enforceStrictCapabilities` is enabled and the peer did not advertise + * `capabilities.extensions[id]`, throws {@linkcode SdkError} with + * {@linkcode SdkErrorCode.CapabilityNotSupported}. + */ + sendNotification(method: string, params?: Record, options?: NotificationOptions): Promise { + this._assertPeerCapability(method); + return this._host.sendCustomNotification(method, params, options); + } + + private _assertPeerCapability(method: string): void { + if (this._enforceStrictCapabilities && this._getPeerExtensionSettings() === undefined) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Peer does not support extension "${this.id}" (required for ${method})` + ); + } + } +} diff --git a/packages/core/test/shared/extensionHandle.test.ts b/packages/core/test/shared/extensionHandle.test.ts new file mode 100644 index 000000000..08c6e25df --- /dev/null +++ b/packages/core/test/shared/extensionHandle.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, test, vi } from 'vitest'; +import * as z from 'zod/v4'; + +import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; +import type { ExtensionHost } from '../../src/shared/extensionHandle.js'; +import { ExtensionHandle } from '../../src/shared/extensionHandle.js'; +import type { BaseContext } from '../../src/shared/protocol.js'; +import type { JSONObject } from '../../src/types/types.js'; + +type MockHost = { + setCustomRequestHandler: ReturnType; + setCustomNotificationHandler: ReturnType; + sendCustomRequest: ReturnType; + sendCustomNotification: ReturnType; +}; + +function makeMockHost(): MockHost { + return { + setCustomRequestHandler: vi.fn(), + setCustomNotificationHandler: vi.fn(), + sendCustomRequest: vi.fn().mockResolvedValue({ ok: true }), + sendCustomNotification: vi.fn().mockResolvedValue(undefined) + }; +} + +function makeHandle(opts: { peer?: JSONObject | undefined; strict?: boolean; peerSchema?: z.core.$ZodType }): { + host: MockHost; + handle: ExtensionHandle; +} { + const host = makeMockHost(); + const handle = new ExtensionHandle( + host as unknown as ExtensionHost, + 'io.example/ui', + { local: true }, + () => opts.peer, + opts.strict ?? false, + opts.peerSchema + ); + return { host, handle }; +} + +describe('ExtensionHandle.getPeerSettings', () => { + test('returns raw blob when no peerSchema given', () => { + const { handle } = makeHandle({ peer: { feature: 'x' } }); + expect(handle.getPeerSettings()).toEqual({ feature: 'x' }); + }); + + test('returns undefined when peer did not advertise', () => { + const { handle } = makeHandle({ peer: undefined }); + expect(handle.getPeerSettings()).toBeUndefined(); + }); + + test('parses and returns typed value when peerSchema matches', () => { + const PeerSchema = z.object({ openLinks: z.boolean(), maxSize: z.number() }); + const { handle } = makeHandle({ peer: { openLinks: true, maxSize: 5 }, peerSchema: PeerSchema }); + expect(handle.getPeerSettings()).toEqual({ openLinks: true, maxSize: 5 }); + }); + + test('returns undefined and warns when peerSchema does not match', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const PeerSchema = z.object({ openLinks: z.boolean() }); + const { handle } = makeHandle({ peer: { openLinks: 'yes' }, peerSchema: PeerSchema }); + expect(handle.getPeerSettings()).toBeUndefined(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toMatch(/io\.example\/ui.*failed schema validation/); + warn.mockRestore(); + }); + + test('caches the parsed result once peer has advertised', () => { + const getter = vi.fn().mockReturnValue({ a: 1 }); + const host = makeMockHost() as unknown as ExtensionHost; + const handle = new ExtensionHandle(host, 'io.example/ui', {}, getter, false); + handle.getPeerSettings(); + handle.getPeerSettings(); + handle.getPeerSettings(); + expect(getter).toHaveBeenCalledTimes(1); + }); + + test('does not cache undefined (so a later-connecting peer is observable)', () => { + let peer: JSONObject | undefined; + const getter = vi.fn(() => peer); + const host = makeMockHost() as unknown as ExtensionHost; + const handle = new ExtensionHandle(host, 'io.example/ui', {}, getter, false); + expect(handle.getPeerSettings()).toBeUndefined(); + peer = { now: 'connected' }; + expect(handle.getPeerSettings()).toEqual({ now: 'connected' }); + expect(handle.getPeerSettings()).toEqual({ now: 'connected' }); + expect(getter).toHaveBeenCalledTimes(2); + }); +}); + +describe('ExtensionHandle.setRequestHandler / setNotificationHandler', () => { + test('delegates to host setCustom* (anytime)', () => { + const { host, handle } = makeHandle({ peer: undefined }); + const params = z.object({ q: z.string() }); + const reqHandler = vi.fn(); + const notifHandler = vi.fn(); + + handle.setRequestHandler('ui/search', params, reqHandler); + expect(host.setCustomRequestHandler).toHaveBeenCalledWith('ui/search', params, reqHandler); + + handle.setNotificationHandler('ui/ping', params, notifHandler); + expect(host.setCustomNotificationHandler).toHaveBeenCalledWith('ui/ping', params, notifHandler); + }); +}); + +describe('ExtensionHandle.sendRequest / sendNotification — peer gating', () => { + const Result = z.object({ ok: z.boolean() }); + + test('lax mode (default): sends even when peer did not advertise', async () => { + const { host, handle } = makeHandle({ peer: undefined, strict: false }); + await handle.sendRequest('ui/do', { x: 1 }, Result); + expect(host.sendCustomRequest).toHaveBeenCalledWith('ui/do', { x: 1 }, Result, undefined); + await handle.sendNotification('ui/ping', {}); + expect(host.sendCustomNotification).toHaveBeenCalledWith('ui/ping', {}, undefined); + }); + + test('strict mode: throws CapabilityNotSupported when peer did not advertise', () => { + const { host, handle } = makeHandle({ peer: undefined, strict: true }); + let thrown: unknown; + try { + void handle.sendRequest('ui/do', {}, Result); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(SdkError); + expect((thrown as SdkError).code).toBe(SdkErrorCode.CapabilityNotSupported); + expect((thrown as SdkError).message).toMatch(/io\.example\/ui.*ui\/do/); + expect(host.sendCustomRequest).not.toHaveBeenCalled(); + expect(() => handle.sendNotification('ui/ping')).toThrow(SdkError); + }); + + test('strict mode: sends when peer did advertise', async () => { + const { host, handle } = makeHandle({ peer: { ok: true }, strict: true }); + await handle.sendRequest('ui/do', {}, Result); + expect(host.sendCustomRequest).toHaveBeenCalledTimes(1); + await handle.sendNotification('ui/ping'); + expect(host.sendCustomNotification).toHaveBeenCalledTimes(1); + }); +}); + +describe('ExtensionHandle — id and settings', () => { + test('exposes id and local settings as readonly fields', () => { + const { handle } = makeHandle({ peer: undefined }); + expect(handle.id).toBe('io.example/ui'); + expect(handle.settings).toEqual({ local: true }); + }); +}); diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 406a39e25..91869d25d 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -1,4 +1,5 @@ import type { + AnySchema, BaseContext, ClientCapabilities, CreateMessageRequest, @@ -9,9 +10,11 @@ import type { ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, + ExtensionOptions, Implementation, InitializeRequest, InitializeResult, + JSONObject, JsonSchemaType, jsonSchemaValidator, ListRootsRequest, @@ -26,6 +29,7 @@ import type { RequestTypeMap, ResourceUpdatedNotification, ResultTypeMap, + SchemaOutput, ServerCapabilities, ServerContext, ServerResult, @@ -43,6 +47,7 @@ import { CreateTaskResultSchema, ElicitResultSchema, EmptyResultSchema, + ExtensionHandle, extractTaskManagerOptions, LATEST_PROTOCOL_VERSION, ListRootsResultSchema, @@ -102,6 +107,7 @@ export class Server extends Protocol { private _capabilities: ServerCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; + private _enforceStrictCapabilities: boolean; private _experimental?: { tasks: ExperimentalServerTasks }; /** @@ -123,6 +129,7 @@ export class Server extends Protocol { this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._instructions = options?.instructions; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); + this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; // Strip runtime-only fields from advertised capabilities if (options?.capabilities?.tasks) { @@ -219,6 +226,40 @@ export class Server extends Protocol { } } + /** + * 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 client + * in the `initialize` result. Must be called before {@linkcode connect}. After connecting, + * {@linkcode ExtensionHandle.getPeerSettings | handle.getPeerSettings()} returns the client's + * `capabilities.extensions[id]` blob (validated against `peerSchema` if provided). + */ + public extension(id: string, settings: L): ExtensionHandle; + public extension( + id: string, + settings: L, + opts: ExtensionOptions

+ ): ExtensionHandle, ServerContext>; + public extension( + id: string, + settings: L, + opts?: ExtensionOptions

+ ): ExtensionHandle | JSONObject, ServerContext> { + if (this.transport) { + throw new SdkError(SdkErrorCode.AlreadyConnected, 'Cannot register extension after connecting to transport'); + } + this._capabilities.extensions = { ...this._capabilities.extensions, [id]: settings }; + return new ExtensionHandle( + this, + id, + settings, + () => this._clientCapabilities?.extensions?.[id], + this._enforceStrictCapabilities, + opts?.peerSchema + ); + } + /** * Override request handler registration to enforce server-side validation for `tools/call`. */ diff --git a/packages/server/test/server/extension.test.ts b/packages/server/test/server/extension.test.ts new file mode 100644 index 000000000..8a7482c59 --- /dev/null +++ b/packages/server/test/server/extension.test.ts @@ -0,0 +1,95 @@ +import { InMemoryTransport, type JSONRPCMessage, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; +import { describe, expect, test } from 'vitest'; +import * as z from 'zod/v4'; + +import { Server } from '../../src/server/server.js'; + +/** + * These tests exercise the `Server.extension()` factory and the server side of the + * `capabilities.extensions` round-trip via `initialize`. The `ExtensionHandle` class itself is + * unit-tested in `@modelcontextprotocol/core/test/shared/extensionHandle.test.ts`. + */ + +async function rawInitialize( + clientSide: InMemoryTransport, + clientCapabilities: Record = {} +): Promise> { + const result = new Promise>((resolve, reject) => { + clientSide.onmessage = (msg: JSONRPCMessage) => { + if ('id' in msg && msg.id === 1) { + if ('result' in msg) resolve(msg.result as Record); + else if ('error' in msg) reject(new Error(JSON.stringify(msg.error))); + } + }; + }); + await clientSide.start(); + await clientSide.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-11-25', + capabilities: clientCapabilities, + clientInfo: { name: 'raw-client', version: '0.0.0' } + } + }); + return result; +} + +describe('Server.extension()', () => { + test('merges settings into capabilities.extensions and advertises them in initialize result', async () => { + const server = new Server({ name: 's', version: '1.0.0' }, { capabilities: {} }); + server.extension('io.example/ui', { contentTypes: ['text/html'] }); + server.extension('com.acme/widgets', { v: 2 }); + + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + await server.connect(serverSide); + const result = await rawInitialize(clientSide); + + const caps = result.capabilities as Record; + expect(caps.extensions).toEqual({ + 'io.example/ui': { contentTypes: ['text/html'] }, + 'com.acme/widgets': { v: 2 } + }); + }); + + test('throws AlreadyConnected after connect()', async () => { + const server = new Server({ name: 's', version: '1.0.0' }); + const [, serverSide] = InMemoryTransport.createLinkedPair(); + await server.connect(serverSide); + + expect(() => server.extension('io.example/ui', {})).toThrow(SdkError); + try { + server.extension('io.example/ui', {}); + expect.fail('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(SdkError); + expect((e as SdkError).code).toBe(SdkErrorCode.AlreadyConnected); + } + }); + + test("getPeerSettings() reads the client's capabilities.extensions[id] after initialize", async () => { + const PeerSchema = z.object({ availableDisplayModes: z.array(z.string()) }); + const server = new Server({ name: 's', version: '1.0.0' }); + const handle = server.extension('io.example/ui', { hostSide: true }, { peerSchema: PeerSchema }); + + expect(handle.getPeerSettings()).toBeUndefined(); + + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + await server.connect(serverSide); + await rawInitialize(clientSide, { + extensions: { 'io.example/ui': { availableDisplayModes: ['inline', 'fullscreen'] } } + }); + + expect(handle.getPeerSettings()).toEqual({ availableDisplayModes: ['inline', 'fullscreen'] }); + }); + + test('handle.setRequestHandler can be called after connect()', async () => { + const server = new Server({ name: 's', version: '1.0.0' }); + const handle = server.extension('io.example/ui', {}); + const [, serverSide] = InMemoryTransport.createLinkedPair(); + await server.connect(serverSide); + + expect(() => handle.setRequestHandler('ui/late', z.object({}), () => ({}))).not.toThrow(); + }); +}); From 88a84e754d5f609a0eb0222494d22c5e0904f416 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 9 Apr 2026 14:10:55 +0000 Subject: [PATCH 2/7] docs(core): fix typedoc cross-package and internal-class link resolution in ExtensionHandle JSDoc --- packages/core/src/shared/extensionHandle.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/core/src/shared/extensionHandle.ts b/packages/core/src/shared/extensionHandle.ts index f5fe10947..ac98bdc1f 100644 --- a/packages/core/src/shared/extensionHandle.ts +++ b/packages/core/src/shared/extensionHandle.ts @@ -30,7 +30,8 @@ export interface ExtensionHost { } /** - * Options for {@linkcode Client.extension} / {@linkcode Server.extension}. + * Options for {@linkcode @modelcontextprotocol/client!client/client.Client#extension | Client.extension} / + * {@linkcode @modelcontextprotocol/server!server/server.Server#extension | Server.extension}. */ export interface ExtensionOptions

{ /** @@ -45,7 +46,8 @@ export interface ExtensionOptions

{ * A scoped handle for registering and sending custom JSON-RPC methods belonging to a single * SEP-2133 extension. * - * Obtained via {@linkcode Client.extension} or {@linkcode Server.extension}. Creating a handle + * Obtained via {@linkcode @modelcontextprotocol/client!client/client.Client#extension | Client.extension} or + * {@linkcode @modelcontextprotocol/server!server/server.Server#extension | Server.extension}. Creating a handle * declares the extension in `capabilities.extensions[id]` so it is advertised during `initialize`. * Handlers registered through the handle are thus structurally guaranteed to belong to a declared * extension. @@ -58,7 +60,7 @@ export class ExtensionHandle, @@ -105,7 +107,7 @@ export class ExtensionHandle( @@ -118,7 +120,7 @@ export class ExtensionHandle( method: string, From 5648da78e9c52d8d19dace47f357648982a7848c Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 9 Apr 2026 15:49:17 +0000 Subject: [PATCH 3/7] fix(core): address review findings on ExtensionHandle - sendRequest/sendNotification are now async so the strict-mode CapabilityNotSupported throw surfaces as a rejection (catchable via .catch()) instead of escaping synchronously - getPeerSettings() no longer caches; reads current peer capabilities on each call so it reflects close()/connect() to a different peer - Client.extension()/Server.extension() throw on duplicate id - add packages/client/test/client/extension.test.ts covering the client-side initialize round-trip and reconnect behavior --- packages/client/src/client/client.ts | 3 + packages/client/test/client/extension.test.ts | 111 ++++++++++++++++++ packages/core/src/shared/extensionHandle.ts | 36 ++---- .../core/test/shared/extensionHandle.test.ts | 44 +++---- packages/server/src/server/server.ts | 3 + packages/server/test/server/extension.test.ts | 7 ++ 6 files changed, 155 insertions(+), 49 deletions(-) create mode 100644 packages/client/test/client/extension.test.ts diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 3a086a9f1..2abc2e73c 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -335,6 +335,9 @@ export class Client extends Protocol { 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`); + } this._capabilities.extensions = { ...this._capabilities.extensions, [id]: settings }; return new ExtensionHandle( this, diff --git a/packages/client/test/client/extension.test.ts b/packages/client/test/client/extension.test.ts new file mode 100644 index 000000000..8cbe05438 --- /dev/null +++ b/packages/client/test/client/extension.test.ts @@ -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>; +} + +function rawServer(serverCapabilities: Record = {}): RawServerHarness { + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + let resolveInit: (p: Record) => void; + const capturedInitParams = new Promise>(r => { + resolveInit = r; + }); + serverSide.onmessage = (msg: JSONRPCMessage) => { + if ('method' in msg && msg.method === 'initialize' && 'id' in msg) { + resolveInit((msg.params ?? {}) as Record); + 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; + 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 }); + }); +}); diff --git a/packages/core/src/shared/extensionHandle.ts b/packages/core/src/shared/extensionHandle.ts index ac98bdc1f..8324778f4 100644 --- a/packages/core/src/shared/extensionHandle.ts +++ b/packages/core/src/shared/extensionHandle.ts @@ -57,8 +57,6 @@ export interface ExtensionOptions

{ * {@linkcode getPeerSettings} returns `undefined`. */ export class ExtensionHandle { - private _peerSettingsCache?: { value: Peer | undefined }; - /** * @internal Use `Client.extension()` or `Server.extension()` to construct. */ @@ -76,33 +74,25 @@ export class ExtensionHandle( + async sendRequest( method: string, params: Record | undefined, resultSchema: R, @@ -154,7 +144,7 @@ export class ExtensionHandle, options?: NotificationOptions): Promise { + async sendNotification(method: string, params?: Record, options?: NotificationOptions): Promise { this._assertPeerCapability(method); return this._host.sendCustomNotification(method, params, options); } diff --git a/packages/core/test/shared/extensionHandle.test.ts b/packages/core/test/shared/extensionHandle.test.ts index 08c6e25df..990463f80 100644 --- a/packages/core/test/shared/extensionHandle.test.ts +++ b/packages/core/test/shared/extensionHandle.test.ts @@ -66,26 +66,20 @@ describe('ExtensionHandle.getPeerSettings', () => { warn.mockRestore(); }); - test('caches the parsed result once peer has advertised', () => { - const getter = vi.fn().mockReturnValue({ a: 1 }); - const host = makeMockHost() as unknown as ExtensionHost; - const handle = new ExtensionHandle(host, 'io.example/ui', {}, getter, false); - handle.getPeerSettings(); - handle.getPeerSettings(); - handle.getPeerSettings(); - expect(getter).toHaveBeenCalledTimes(1); - }); - - test('does not cache undefined (so a later-connecting peer is observable)', () => { + test('reflects current peer settings on each call (no caching across reconnects)', () => { let peer: JSONObject | undefined; const getter = vi.fn(() => peer); const host = makeMockHost() as unknown as ExtensionHost; const handle = new ExtensionHandle(host, 'io.example/ui', {}, getter, false); + + expect(handle.getPeerSettings()).toBeUndefined(); + peer = { v: 1 }; + expect(handle.getPeerSettings()).toEqual({ v: 1 }); + peer = { v: 2 }; + expect(handle.getPeerSettings()).toEqual({ v: 2 }); + peer = undefined; expect(handle.getPeerSettings()).toBeUndefined(); - peer = { now: 'connected' }; - expect(handle.getPeerSettings()).toEqual({ now: 'connected' }); - expect(handle.getPeerSettings()).toEqual({ now: 'connected' }); - expect(getter).toHaveBeenCalledTimes(2); + expect(getter).toHaveBeenCalledTimes(4); }); }); @@ -115,19 +109,17 @@ describe('ExtensionHandle.sendRequest / sendNotification — peer gating', () => expect(host.sendCustomNotification).toHaveBeenCalledWith('ui/ping', {}, undefined); }); - test('strict mode: throws CapabilityNotSupported when peer did not advertise', () => { + test('strict mode: rejects with CapabilityNotSupported when peer did not advertise', async () => { const { host, handle } = makeHandle({ peer: undefined, strict: true }); - let thrown: unknown; - try { - void handle.sendRequest('ui/do', {}, Result); - } catch (e) { - thrown = e; - } - expect(thrown).toBeInstanceOf(SdkError); - expect((thrown as SdkError).code).toBe(SdkErrorCode.CapabilityNotSupported); - expect((thrown as SdkError).message).toMatch(/io\.example\/ui.*ui\/do/); + await expect(handle.sendRequest('ui/do', {}, Result)).rejects.toSatisfy( + (e: unknown) => + e instanceof SdkError && e.code === SdkErrorCode.CapabilityNotSupported && /io\.example\/ui.*ui\/do/.test(e.message) + ); expect(host.sendCustomRequest).not.toHaveBeenCalled(); - expect(() => handle.sendNotification('ui/ping')).toThrow(SdkError); + await expect(handle.sendNotification('ui/ping')).rejects.toSatisfy( + (e: unknown) => e instanceof SdkError && e.code === SdkErrorCode.CapabilityNotSupported + ); + expect(host.sendCustomNotification).not.toHaveBeenCalled(); }); test('strict mode: sends when peer did advertise', async () => { diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 91869d25d..333cb0121 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -249,6 +249,9 @@ export class Server extends Protocol { 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`); + } this._capabilities.extensions = { ...this._capabilities.extensions, [id]: settings }; return new ExtensionHandle( this, diff --git a/packages/server/test/server/extension.test.ts b/packages/server/test/server/extension.test.ts index 8a7482c59..e87ea4b63 100644 --- a/packages/server/test/server/extension.test.ts +++ b/packages/server/test/server/extension.test.ts @@ -68,6 +68,13 @@ describe('Server.extension()', () => { } }); + test('throws on duplicate extension id', () => { + const server = new Server({ name: 's', version: '1.0.0' }); + server.extension('io.example/ui', { v: 1 }); + expect(() => server.extension('io.example/ui', { v: 2 })).toThrow(/already registered/); + expect(() => server.extension('com.other/thing', {})).not.toThrow(); + }); + test("getPeerSettings() reads the client's capabilities.extensions[id] after initialize", async () => { const PeerSchema = z.object({ availableDisplayModes: z.array(z.string()) }); const server = new Server({ name: 's', version: '1.0.0' }); From 356ec40eb00fa54650c6984dd6e1a658b935af6e Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 13 Apr 2026 14:10:37 +0000 Subject: [PATCH 4/7] fix(core): pass enforceStrictCapabilities as getter; document registerCapabilities/extension ordering ExtensionHandle now reads enforceStrictCapabilities via a getter (matching the existing _getPeerExtensionSettings pattern), so a future setter on the host would be observed. JSDoc on extension() notes that a later registerCapabilities({extensions:{id:...}}) overwrites the wire value while handle.settings reflects the original call. --- packages/client/src/client/client.ts | 6 +++++- packages/core/src/shared/extensionHandle.ts | 4 ++-- packages/core/test/shared/extensionHandle.test.ts | 4 ++-- packages/server/src/server/server.ts | 6 +++++- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 2abc2e73c..4e1de5ceb 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -320,6 +320,10 @@ export class Client extends Protocol { * 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(id: string, settings: L): ExtensionHandle; public extension( @@ -344,7 +348,7 @@ export class Client extends Protocol { id, settings, () => this._serverCapabilities?.extensions?.[id], - this._enforceStrictCapabilities, + () => this._enforceStrictCapabilities, opts?.peerSchema ); } diff --git a/packages/core/src/shared/extensionHandle.ts b/packages/core/src/shared/extensionHandle.ts index 8324778f4..9219ca5b1 100644 --- a/packages/core/src/shared/extensionHandle.ts +++ b/packages/core/src/shared/extensionHandle.ts @@ -67,7 +67,7 @@ export class ExtensionHandle JSONObject | undefined, - private readonly _enforceStrictCapabilities: boolean, + private readonly _getEnforceStrictCapabilities: () => boolean, private readonly _peerSchema?: AnySchema ) {} @@ -150,7 +150,7 @@ export class ExtensionHandle opts.peer, - opts.strict ?? false, + () => opts.strict ?? false, opts.peerSchema ); return { host, handle }; @@ -70,7 +70,7 @@ describe('ExtensionHandle.getPeerSettings', () => { let peer: JSONObject | undefined; const getter = vi.fn(() => peer); const host = makeMockHost() as unknown as ExtensionHost; - const handle = new ExtensionHandle(host, 'io.example/ui', {}, getter, false); + const handle = new ExtensionHandle(host, 'io.example/ui', {}, getter, () => false); expect(handle.getPeerSettings()).toBeUndefined(); peer = { v: 1 }; diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 333cb0121..88be4f1a0 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -234,6 +234,10 @@ export class Server extends Protocol { * in the `initialize` result. Must be called before {@linkcode connect}. After connecting, * {@linkcode ExtensionHandle.getPeerSettings | handle.getPeerSettings()} returns the client'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(id: string, settings: L): ExtensionHandle; public extension( @@ -258,7 +262,7 @@ export class Server extends Protocol { id, settings, () => this._clientCapabilities?.extensions?.[id], - this._enforceStrictCapabilities, + () => this._enforceStrictCapabilities, opts?.peerSchema ); } From 6dc66ebbb74423e74881bc8fb12ca08730f0dbed Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 13 Apr 2026 15:04:17 +0000 Subject: [PATCH 5/7] fix(core): use SdkError for duplicate extension; defer capability check pre-connect - duplicate-ID guard now throws SdkError(ExtensionAlreadyRegistered) instead of plain Error - _assertPeerCapability defers when peer capabilities are not yet populated, so strict-mode sendRequest before connect() surfaces NotConnected (from the underlying send) rather than a misleading CapabilityNotSupported --- packages/client/src/client/client.ts | 3 ++- packages/core/src/errors/sdkErrors.ts | 1 + packages/core/src/shared/extensionHandle.ts | 5 ++++ .../core/test/shared/extensionHandle.test.ts | 24 +++++++++++++++---- packages/server/src/server/server.ts | 3 ++- 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 4e1de5ceb..b845682ee 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -340,7 +340,7 @@ export class Client extends Protocol { 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`); + throw new SdkError(SdkErrorCode.ExtensionAlreadyRegistered, `Extension "${id}" is already registered`); } this._capabilities.extensions = { ...this._capabilities.extensions, [id]: settings }; return new ExtensionHandle( @@ -348,6 +348,7 @@ export class Client extends Protocol { id, settings, () => this._serverCapabilities?.extensions?.[id], + () => this._serverCapabilities !== undefined, () => this._enforceStrictCapabilities, opts?.peerSchema ); diff --git a/packages/core/src/errors/sdkErrors.ts b/packages/core/src/errors/sdkErrors.ts index f53c07ccf..aaa023be3 100644 --- a/packages/core/src/errors/sdkErrors.ts +++ b/packages/core/src/errors/sdkErrors.ts @@ -10,6 +10,7 @@ export enum SdkErrorCode { // State errors /** Transport is not connected */ NotConnected = 'NOT_CONNECTED', + ExtensionAlreadyRegistered = 'EXTENSION_ALREADY_REGISTERED', /** Transport is already connected */ AlreadyConnected = 'ALREADY_CONNECTED', /** Protocol is not initialized */ diff --git a/packages/core/src/shared/extensionHandle.ts b/packages/core/src/shared/extensionHandle.ts index 9219ca5b1..a219d21fc 100644 --- a/packages/core/src/shared/extensionHandle.ts +++ b/packages/core/src/shared/extensionHandle.ts @@ -67,6 +67,7 @@ export class ExtensionHandle JSONObject | undefined, + private readonly _getPeerCapabilitiesPresent: () => boolean, private readonly _getEnforceStrictCapabilities: () => boolean, private readonly _peerSchema?: AnySchema ) {} @@ -150,6 +151,10 @@ export class ExtensionHandle; } { @@ -33,6 +33,7 @@ function makeHandle(opts: { peer?: JSONObject | undefined; strict?: boolean; pee 'io.example/ui', { local: true }, () => opts.peer, + () => opts.peerPresent ?? opts.peer !== undefined, () => opts.strict ?? false, opts.peerSchema ); @@ -70,7 +71,14 @@ describe('ExtensionHandle.getPeerSettings', () => { let peer: JSONObject | undefined; const getter = vi.fn(() => peer); const host = makeMockHost() as unknown as ExtensionHost; - const handle = new ExtensionHandle(host, 'io.example/ui', {}, getter, () => false); + const handle = new ExtensionHandle( + host, + 'io.example/ui', + {}, + getter, + () => true, + () => false + ); expect(handle.getPeerSettings()).toBeUndefined(); peer = { v: 1 }; @@ -102,7 +110,7 @@ describe('ExtensionHandle.sendRequest / sendNotification — peer gating', () => const Result = z.object({ ok: z.boolean() }); test('lax mode (default): sends even when peer did not advertise', async () => { - const { host, handle } = makeHandle({ peer: undefined, strict: false }); + const { host, handle } = makeHandle({ peer: undefined, peerPresent: true, strict: false }); await handle.sendRequest('ui/do', { x: 1 }, Result); expect(host.sendCustomRequest).toHaveBeenCalledWith('ui/do', { x: 1 }, Result, undefined); await handle.sendNotification('ui/ping', {}); @@ -110,7 +118,7 @@ describe('ExtensionHandle.sendRequest / sendNotification — peer gating', () => }); test('strict mode: rejects with CapabilityNotSupported when peer did not advertise', async () => { - const { host, handle } = makeHandle({ peer: undefined, strict: true }); + const { host, handle } = makeHandle({ peer: undefined, peerPresent: true, strict: true }); await expect(handle.sendRequest('ui/do', {}, Result)).rejects.toSatisfy( (e: unknown) => e instanceof SdkError && e.code === SdkErrorCode.CapabilityNotSupported && /io\.example\/ui.*ui\/do/.test(e.message) @@ -138,3 +146,11 @@ describe('ExtensionHandle — id and settings', () => { expect(handle.settings).toEqual({ local: true }); }); }); + +describe('ExtensionHandle — pre-connect strict mode defers to NotConnected', () => { + test('does not throw CapabilityNotSupported before peer capabilities are known', async () => { + const { host, handle } = makeHandle({ peer: undefined, peerPresent: false, strict: true }); + await handle.sendRequest('ui/do', {}, z.object({})); + expect(host.sendCustomRequest).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 88be4f1a0..edad28eb5 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -254,7 +254,7 @@ export class Server extends Protocol { 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`); + throw new SdkError(SdkErrorCode.ExtensionAlreadyRegistered, `Extension "${id}" is already registered`); } this._capabilities.extensions = { ...this._capabilities.extensions, [id]: settings }; return new ExtensionHandle( @@ -262,6 +262,7 @@ export class Server extends Protocol { id, settings, () => this._clientCapabilities?.extensions?.[id], + () => this._clientCapabilities !== undefined, () => this._enforceStrictCapabilities, opts?.peerSchema ); From 641cc7abdf49eadd86baa73cc9e4c40af314390b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 13 Apr 2026 17:59:10 +0000 Subject: [PATCH 6/7] fix(core): adapt getPeerSettings to async parseSchema after #1846 widen Calls peerSchema['~standard'].validate directly (sync path); falls back to raw blob with a warning if the schema's validation is async, since getPeerSettings() is a synchronous read of already-received capabilities. --- packages/core/src/shared/extensionHandle.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/core/src/shared/extensionHandle.ts b/packages/core/src/shared/extensionHandle.ts index a219d21fc..c7dfa45db 100644 --- a/packages/core/src/shared/extensionHandle.ts +++ b/packages/core/src/shared/extensionHandle.ts @@ -1,7 +1,6 @@ import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js'; import type { JSONObject, Result } from '../types/types.js'; import type { AnySchema, SchemaOutput } from '../util/schema.js'; -import { parseSchema } from '../util/schema.js'; import type { BaseContext, NotificationOptions, RequestOptions } from './protocol.js'; /** @@ -86,14 +85,20 @@ export class ExtensionHandle 0) { + console.warn( + `[ExtensionHandle] Peer's capabilities.extensions["${this.id}"] failed schema validation: ${result.issues.map(i => i.message).join(', ')}` ); return undefined; } - return parsed.data as Peer; + return (result as { value: Peer }).value; } /** From 9434b466b9a90562e25b67e5cb092cf9620a856f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 13 Apr 2026 19:03:55 +0000 Subject: [PATCH 7/7] docs: add client.md/server.md sections and @example blocks for extension() and custom methods --- docs/client.md | 61 ++++++++++++++++ docs/server.md | 70 +++++++++++++++++- examples/client/src/clientGuide.examples.ts | 55 ++++++++++++++ examples/server/src/serverGuide.examples.ts | 63 +++++++++++++++- packages/client/src/client/client.examples.ts | 25 +++++++ packages/client/src/client/client.ts | 17 +++++ .../src/shared/extensionHandle.examples.ts | 58 +++++++++++++++ packages/core/src/shared/extensionHandle.ts | 30 ++++++++ packages/core/src/shared/protocol.examples.ts | 73 +++++++++++++++++++ packages/core/src/shared/protocol.ts | 41 +++++++++++ packages/server/src/server/server.examples.ts | 35 +++++++++ packages/server/src/server/server.ts | 18 +++++ 12 files changed, 544 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/shared/extensionHandle.examples.ts create mode 100644 packages/core/src/shared/protocol.examples.ts create mode 100644 packages/server/src/server/server.examples.ts diff --git a/docs/client.md b/docs/client.md index b5086f531..2016c867c 100644 --- a/docs/client.md +++ b/docs/client.md @@ -29,6 +29,7 @@ import { StdioClientTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import * as z from 'zod/v4'; ``` ## Connecting to a server @@ -596,6 +597,66 @@ console.log(result); 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). +## Protocol extensions + +[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. + +### Declaring an extension + +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: + +```ts source="../examples/client/src/clientGuide.examples.ts#extension_declare" +const client = new Client({ name: 'ui-view', version: '1.0.0' }); + +// Declare the extension. `settings` is advertised in capabilities.extensions[id] during initialize. +const ui = client.extension( + 'io.modelcontextprotocol/ui', + { availableModes: ['inline', 'fullscreen'] }, + { peerSchema: z.object({ openLinks: z.boolean().optional() }) } +); + +// Handle incoming custom notifications from the server. +ui.setNotificationHandler('ui/host-context-changed', z.object({ theme: z.enum(['light', 'dark']) }), params => { + document.body.dataset.theme = params.theme; +}); +``` + +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. + +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: + +```ts source="../examples/client/src/clientGuide.examples.ts#extension_send" +await client.connect(transport); + +// After connect, read the server's advertised settings for this extension. +if (ui.getPeerSettings()?.openLinks) { + const result = await ui.sendRequest('ui/open-link', { url: 'https://example.com' }, z.object({ opened: z.boolean() })); + console.log(result.opened); +} +``` + +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`. + +### Ungated custom methods + +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: + +```ts source="../examples/client/src/clientGuide.examples.ts#customMethod_ungated" +// For one-off vendor methods that do not warrant an SEP-2133 capability entry, +// use the flat custom-method API directly. +const result = await client.sendCustomRequest('acme/search', { query: 'widgets' }, z.object({ hits: z.array(z.string()) })); +console.log(result.hits); +``` + +Standard MCP method names are rejected with a clear error — use {@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} and friends for those. + +### When to use which + +| Use | When | +| --- | --- | +| `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. | +| `sendCustomRequest` etc. | You need a single vendor-specific method without capability negotiation, or are prototyping before defining an extension. | + ## Tasks (experimental) > [!WARNING] diff --git a/docs/server.md b/docs/server.md index 1848a820e..05d2b5d1f 100644 --- a/docs/server.md +++ b/docs/server.md @@ -22,7 +22,7 @@ import { randomUUID } from 'node:crypto'; import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { CallToolResult, ResourceLink } from '@modelcontextprotocol/server'; -import { completable, McpServer, ResourceTemplate, StdioServerTransport } from '@modelcontextprotocol/server'; +import { completable, McpServer, ResourceTemplate, Server, StdioServerTransport } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; ``` @@ -494,6 +494,74 @@ server.registerTool( ); ``` +## Protocol extensions + +[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. + +### Declaring an extension + +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: + +```ts source="../examples/server/src/serverGuide.examples.ts#extension_declare" +const server = new Server({ name: 'host', version: '1.0.0' }, { capabilities: {} }); + +// Declare the extension. `settings` is advertised in capabilities.extensions[id] during initialize. +const ui = server.extension( + 'io.modelcontextprotocol/ui', + { openLinks: true, downloadFile: true }, + { peerSchema: z.object({ availableModes: z.array(z.string()) }) } +); + +// Register handlers for the extension's custom methods. The handle is proof of declaration — +// you cannot reach this point without the capability having been merged in above. +ui.setRequestHandler('ui/open-link', z.object({ url: z.string() }), async params => { + return { opened: params.url.startsWith('https://') }; +}); + +ui.setNotificationHandler('ui/size-changed', z.object({ width: z.number(), height: z.number() }), params => { + console.log(`view resized to ${params.width}x${params.height}`); +}); +``` + +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. + +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: + +```ts source="../examples/server/src/serverGuide.examples.ts#extension_peerSettings" +await server.connect(transport); + +// After connect, read what the client advertised for this extension. +const clientUi = ui.getPeerSettings(); // { availableModes: string[] } | undefined +if (clientUi?.availableModes.includes('fullscreen')) { + await ui.sendNotification('ui/mode-available', { mode: 'fullscreen' }); +} +``` + +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`. + +### Ungated custom methods + +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: + +```ts source="../examples/server/src/serverGuide.examples.ts#customMethod_ungated" +// For one-off vendor methods that do not warrant an SEP-2133 capability entry, +// use the flat custom-method API directly. +server.setCustomRequestHandler('acme/search', z.object({ query: z.string() }), async params => { + return { hits: [`result for ${params.query}`] }; +}); +``` + +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. + +### When to use which + +| Use | When | +| --- | --- | +| `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. | +| `setCustomRequestHandler` etc. | You need a single vendor-specific method without capability negotiation, or are prototyping before defining an extension. | + +For a full runnable example, see [`customMethodExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/customMethodExample.ts). + ## Tasks (experimental) > [!WARNING] diff --git a/examples/client/src/clientGuide.examples.ts b/examples/client/src/clientGuide.examples.ts index f07d272db..8d9efed68 100644 --- a/examples/client/src/clientGuide.examples.ts +++ b/examples/client/src/clientGuide.examples.ts @@ -24,6 +24,7 @@ import { StdioClientTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import * as z from 'zod/v4'; //#endregion imports // --------------------------------------------------------------------------- @@ -544,6 +545,54 @@ async function resumptionToken_basic(client: Client) { //#endregion resumptionToken_basic } +// --------------------------------------------------------------------------- +// Protocol extensions +// --------------------------------------------------------------------------- + +/** Example: declare an SEP-2133 extension on a Client and wire handlers + sends. */ +function extension_declare() { + //#region extension_declare + const client = new Client({ name: 'ui-view', version: '1.0.0' }); + + // Declare the extension. `settings` is advertised in capabilities.extensions[id] during initialize. + const ui = client.extension( + 'io.modelcontextprotocol/ui', + { availableModes: ['inline', 'fullscreen'] }, + { peerSchema: z.object({ openLinks: z.boolean().optional() }) } + ); + + // Handle incoming custom notifications from the server. + ui.setNotificationHandler('ui/host-context-changed', z.object({ theme: z.enum(['light', 'dark']) }), params => { + document.body.dataset.theme = params.theme; + }); + //#endregion extension_declare + return { client, ui }; +} + +/** Example: send a custom request through the handle and read peer settings. */ +async function extension_send() { + const { client, ui } = extension_declare(); + //#region extension_send + await client.connect(transport); + + // After connect, read the server's advertised settings for this extension. + if (ui.getPeerSettings()?.openLinks) { + const result = await ui.sendRequest('ui/open-link', { url: 'https://example.com' }, z.object({ opened: z.boolean() })); + console.log(result.opened); + } + //#endregion extension_send +} + +/** Example: ungated custom method (no capability negotiation). */ +async function customMethod_ungated(client: Client) { + //#region customMethod_ungated + // For one-off vendor methods that do not warrant an SEP-2133 capability entry, + // use the flat custom-method API directly. + const result = await client.sendCustomRequest('acme/search', { query: 'widgets' }, z.object({ hits: z.array(z.string()) })); + console.log(result.hits); + //#endregion customMethod_ungated +} + // Suppress unused-function warnings (functions exist solely for type-checking) void connect_streamableHttp; void connect_stdio; @@ -573,3 +622,9 @@ void errorHandling_lifecycle; void errorHandling_timeout; void middleware_basic; void resumptionToken_basic; +void extension_declare; +void extension_send; +void customMethod_ungated; + +declare const transport: import('@modelcontextprotocol/client').Transport; +declare const document: { body: { dataset: Record } }; diff --git a/examples/server/src/serverGuide.examples.ts b/examples/server/src/serverGuide.examples.ts index 70cd002d1..1e718e74c 100644 --- a/examples/server/src/serverGuide.examples.ts +++ b/examples/server/src/serverGuide.examples.ts @@ -13,7 +13,7 @@ import { randomUUID } from 'node:crypto'; import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { CallToolResult, ResourceLink } from '@modelcontextprotocol/server'; -import { completable, McpServer, ResourceTemplate, StdioServerTransport } from '@modelcontextprotocol/server'; +import { completable, McpServer, ResourceTemplate, Server, StdioServerTransport } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; //#endregion imports @@ -534,6 +534,62 @@ function dnsRebinding_allowedHosts() { return app; } +// --------------------------------------------------------------------------- +// Protocol extensions +// --------------------------------------------------------------------------- + +/** Example: declare an SEP-2133 extension on a low-level Server and wire handlers. */ +function extension_declare() { + //#region extension_declare + const server = new Server({ name: 'host', version: '1.0.0' }, { capabilities: {} }); + + // Declare the extension. `settings` is advertised in capabilities.extensions[id] during initialize. + const ui = server.extension( + 'io.modelcontextprotocol/ui', + { openLinks: true, downloadFile: true }, + { peerSchema: z.object({ availableModes: z.array(z.string()) }) } + ); + + // Register handlers for the extension's custom methods. The handle is proof of declaration — + // you cannot reach this point without the capability having been merged in above. + ui.setRequestHandler('ui/open-link', z.object({ url: z.string() }), async params => { + return { opened: params.url.startsWith('https://') }; + }); + + ui.setNotificationHandler('ui/size-changed', z.object({ width: z.number(), height: z.number() }), params => { + console.log(`view resized to ${params.width}x${params.height}`); + }); + //#endregion extension_declare + return { server, ui }; +} + +/** Example: read the connected client's extension settings. */ +async function extension_peerSettings() { + const { server, ui } = extension_declare(); + //#region extension_peerSettings + await server.connect(transport); + + // After connect, read what the client advertised for this extension. + const clientUi = ui.getPeerSettings(); // { availableModes: string[] } | undefined + if (clientUi?.availableModes.includes('fullscreen')) { + await ui.sendNotification('ui/mode-available', { mode: 'fullscreen' }); + } + //#endregion extension_peerSettings +} + +/** Example: ungated custom method (no capability negotiation). */ +function customMethod_ungated() { + const server = new Server({ name: 'host', version: '1.0.0' }, { capabilities: {} }); + //#region customMethod_ungated + // For one-off vendor methods that do not warrant an SEP-2133 capability entry, + // use the flat custom-method API directly. + server.setCustomRequestHandler('acme/search', z.object({ query: z.string() }), async params => { + return { hits: [`result for ${params.query}`] }; + }); + //#endregion customMethod_ungated + return server; +} + // Suppress unused-function warnings (functions exist solely for type-checking) void instructions_basic; void registerTool_basic; @@ -557,3 +613,8 @@ void shutdown_statefulHttp; void shutdown_stdio; void dnsRebinding_basic; void dnsRebinding_allowedHosts; +void extension_declare; +void extension_peerSettings; +void customMethod_ungated; + +declare const transport: import('@modelcontextprotocol/server').Transport; diff --git a/packages/client/src/client/client.examples.ts b/packages/client/src/client/client.examples.ts index b08694cfb..d28dc3d8b 100644 --- a/packages/client/src/client/client.examples.ts +++ b/packages/client/src/client/client.examples.ts @@ -8,6 +8,7 @@ */ import type { Prompt, Resource, Tool } from '@modelcontextprotocol/core'; +import * as z from 'zod/v4'; import { Client } from './client.js'; import { SSEClientTransport } from './sse.js'; @@ -192,3 +193,27 @@ async function Client_listResources_pagination(client: Client) { ); //#endregion Client_listResources_pagination } + +/** + * Example: declare an SEP-2133 extension and use the returned handle. + */ +function Client_extension_basic() { + //#region Client_extension_basic + const client = new Client({ name: 'ui-view', version: '1.0.0' }); + + const ui = client.extension( + 'io.modelcontextprotocol/ui', + { availableModes: ['inline', 'fullscreen'] }, + { peerSchema: z.object({ openLinks: z.boolean().optional() }) } + ); + + ui.setNotificationHandler('ui/tool-result', z.object({ content: z.array(z.unknown()) }), params => { + console.log('tool result:', params.content); + }); + + // After connect: ui.getPeerSettings() returns the server's extensions['io.modelcontextprotocol/ui'] + //#endregion Client_extension_basic + return { client, ui }; +} + +void Client_extension_basic; diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index b845682ee..fd33d300e 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -324,6 +324,23 @@ export class Client extends Protocol { * 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. + * + * @example + * ```ts source="./client.examples.ts#Client_extension_basic" + * const client = new Client({ name: 'ui-view', version: '1.0.0' }); + * + * const ui = client.extension( + * 'io.modelcontextprotocol/ui', + * { availableModes: ['inline', 'fullscreen'] }, + * { peerSchema: z.object({ openLinks: z.boolean().optional() }) } + * ); + * + * ui.setNotificationHandler('ui/tool-result', z.object({ content: z.array(z.unknown()) }), params => { + * console.log('tool result:', params.content); + * }); + * + * // After connect: ui.getPeerSettings() returns the server's extensions['io.modelcontextprotocol/ui'] + * ``` */ public extension(id: string, settings: L): ExtensionHandle; public extension( diff --git a/packages/core/src/shared/extensionHandle.examples.ts b/packages/core/src/shared/extensionHandle.examples.ts new file mode 100644 index 000000000..710028091 --- /dev/null +++ b/packages/core/src/shared/extensionHandle.examples.ts @@ -0,0 +1,58 @@ +/** + * Type-checked examples for `extensionHandle.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * + * @module + */ + +import * as z from 'zod/v4'; + +import type { JSONObject } from '../types/types.js'; +import type { ExtensionHandle } from './extensionHandle.js'; +import type { BaseContext } from './protocol.js'; + +// In practice, obtain a handle via `client.extension(...)` or `server.extension(...)`. +declare const ui: ExtensionHandle<{ availableModes: string[] }, { openLinks?: boolean }, BaseContext>; +declare function display(_: JSONObject): void; + +/** Example: register handlers for an extension's custom methods. */ +function ExtensionHandle_setRequestHandler_basic() { + //#region ExtensionHandle_setRequestHandler_basic + const OpenLinkParams = z.object({ url: z.string().url() }); + + ui.setRequestHandler('ui/open-link', OpenLinkParams, async params => { + // ... open params.url in the host UI + return { opened: true }; + }); + + ui.setNotificationHandler('ui/size-changed', z.object({ width: z.number(), height: z.number() }), params => { + console.log(`resized to ${params.width}x${params.height}`); + }); + //#endregion ExtensionHandle_setRequestHandler_basic +} + +/** Example: send a request through the handle (peer-gated under enforceStrictCapabilities). */ +async function ExtensionHandle_sendRequest_basic() { + //#region ExtensionHandle_sendRequest_basic + const OpenLinkResult = z.object({ opened: z.boolean() }); + + const result = await ui.sendRequest('ui/open-link', { url: 'https://example.com' }, OpenLinkResult); + console.log(result.opened); + //#endregion ExtensionHandle_sendRequest_basic +} + +/** Example: read the peer's advertised settings for this extension. */ +function ExtensionHandle_getPeerSettings_basic() { + //#region ExtensionHandle_getPeerSettings_basic + const peer = ui.getPeerSettings(); + if (peer?.openLinks) { + // peer supports the open-link feature + } + //#endregion ExtensionHandle_getPeerSettings_basic + void display; +} + +void ExtensionHandle_setRequestHandler_basic; +void ExtensionHandle_sendRequest_basic; +void ExtensionHandle_getPeerSettings_basic; diff --git a/packages/core/src/shared/extensionHandle.ts b/packages/core/src/shared/extensionHandle.ts index c7dfa45db..427b28512 100644 --- a/packages/core/src/shared/extensionHandle.ts +++ b/packages/core/src/shared/extensionHandle.ts @@ -54,6 +54,36 @@ export interface ExtensionOptions

{ * Send-side methods respect `enforceStrictCapabilities`: when strict, sending throws if the peer * did not advertise the same extension ID; when lax (the default), sends proceed regardless and * {@linkcode getPeerSettings} returns `undefined`. + * + * @example Register handlers + * ```ts source="./extensionHandle.examples.ts#ExtensionHandle_setRequestHandler_basic" + * const OpenLinkParams = z.object({ url: z.string().url() }); + * + * ui.setRequestHandler('ui/open-link', OpenLinkParams, async params => { + * // ... open params.url in the host UI + * return { opened: true }; + * }); + * + * ui.setNotificationHandler('ui/size-changed', z.object({ width: z.number(), height: z.number() }), params => { + * console.log(`resized to ${params.width}x${params.height}`); + * }); + * ``` + * + * @example Send a request + * ```ts source="./extensionHandle.examples.ts#ExtensionHandle_sendRequest_basic" + * const OpenLinkResult = z.object({ opened: z.boolean() }); + * + * const result = await ui.sendRequest('ui/open-link', { url: 'https://example.com' }, OpenLinkResult); + * console.log(result.opened); + * ``` + * + * @example Read peer settings + * ```ts source="./extensionHandle.examples.ts#ExtensionHandle_getPeerSettings_basic" + * const peer = ui.getPeerSettings(); + * if (peer?.openLinks) { + * // peer supports the open-link feature + * } + * ``` */ export class ExtensionHandle { /** diff --git a/packages/core/src/shared/protocol.examples.ts b/packages/core/src/shared/protocol.examples.ts new file mode 100644 index 000000000..fdd2c8f6d --- /dev/null +++ b/packages/core/src/shared/protocol.examples.ts @@ -0,0 +1,73 @@ +/** + * Type-checked examples for `protocol.ts` (custom-method API). + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * + * @module + */ + +import * as z from 'zod/v4'; + +import type { Protocol } from './protocol.js'; + +// The custom-method API is inherited by both Client and Server. Examples here use a +// generic `peer` of type `Protocol` to keep them role-neutral; in practice +// callers use `client.setCustomRequestHandler(...)` or `server.setCustomRequestHandler(...)`. +declare const peer: Protocol; + +/** Example: Register a handler for a vendor-specific request method. */ +function Protocol_setCustomRequestHandler_basic() { + //#region Protocol_setCustomRequestHandler_basic + const SearchParams = z.object({ query: z.string(), limit: z.number().optional() }); + + peer.setCustomRequestHandler('acme/search', SearchParams, async params => { + return { hits: [`result for ${params.query}`] }; + }); + //#endregion Protocol_setCustomRequestHandler_basic +} + +/** Example: Register a handler for a vendor-specific notification. */ +function Protocol_setCustomNotificationHandler_basic() { + //#region Protocol_setCustomNotificationHandler_basic + const ProgressParams = z.object({ percent: z.number() }); + + peer.setCustomNotificationHandler('acme/progress', ProgressParams, params => { + console.log(`progress: ${params.percent}%`); + }); + //#endregion Protocol_setCustomNotificationHandler_basic +} + +/** Example: Send a custom request and await the typed result. */ +async function Protocol_sendCustomRequest_basic() { + //#region Protocol_sendCustomRequest_basic + const SearchResult = z.object({ hits: z.array(z.string()) }); + + const result = await peer.sendCustomRequest('acme/search', { query: 'widgets' }, SearchResult); + console.log(result.hits); + //#endregion Protocol_sendCustomRequest_basic +} + +/** Example: Send a custom request with both params and result schemas (pre-send validation). */ +async function Protocol_sendCustomRequest_bundle() { + //#region Protocol_sendCustomRequest_bundle + const SearchParams = z.object({ query: z.string() }); + const SearchResult = z.object({ hits: z.array(z.string()) }); + + // Passing { params, result } validates outbound params before sending and types both ends. + const result = await peer.sendCustomRequest('acme/search', { query: 'widgets' }, { params: SearchParams, result: SearchResult }); + console.log(result.hits); + //#endregion Protocol_sendCustomRequest_bundle +} + +/** Example: Send a custom notification. */ +async function Protocol_sendCustomNotification_basic() { + //#region Protocol_sendCustomNotification_basic + await peer.sendCustomNotification('acme/heartbeat', { timestamp: Date.now() }); + //#endregion Protocol_sendCustomNotification_basic +} + +void Protocol_setCustomRequestHandler_basic; +void Protocol_setCustomNotificationHandler_basic; +void Protocol_sendCustomRequest_basic; +void Protocol_sendCustomRequest_bundle; +void Protocol_sendCustomNotification_basic; diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index d07709e09..c6ec7ff80 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -1070,6 +1070,15 @@ export abstract class Protocol { * * Absent or undefined `params` are normalized to `{}` (after stripping `_meta`) before * validation, so for no-params methods use `z.object({})` rather than `z.undefined()`. + * + * @example + * ```ts source="./protocol.examples.ts#Protocol_setCustomRequestHandler_basic" + * const SearchParams = z.object({ query: z.string(), limit: z.number().optional() }); + * + * peer.setCustomRequestHandler('acme/search', SearchParams, async params => { + * return { hits: [`result for ${params.query}`] }; + * }); + * ``` */ setCustomRequestHandler

( method: string, @@ -1110,6 +1119,15 @@ export abstract class Protocol { * * Absent or undefined `params` are normalized to `{}` (after stripping `_meta`) before * validation, so for no-params methods use `z.object({})` rather than `z.undefined()`. + * + * @example + * ```ts source="./protocol.examples.ts#Protocol_setCustomNotificationHandler_basic" + * const ProgressParams = z.object({ percent: z.number() }); + * + * peer.setCustomNotificationHandler('acme/progress', ProgressParams, params => { + * console.log(`progress: ${params.percent}%`); + * }); + * ``` */ setCustomNotificationHandler

( method: string, @@ -1157,6 +1175,24 @@ export abstract class Protocol { * The `params` schema is used only for validation — the value you pass is sent as-is. * Transforms (e.g. `.trim()`) and defaults (e.g. `.default(n)`) on the schema are not * applied to outbound data, matching the behavior of {@linkcode Protocol.request | request}. + * + * @example Bare result schema + * ```ts source="./protocol.examples.ts#Protocol_sendCustomRequest_basic" + * const SearchResult = z.object({ hits: z.array(z.string()) }); + * + * const result = await peer.sendCustomRequest('acme/search', { query: 'widgets' }, SearchResult); + * console.log(result.hits); + * ``` + * + * @example With params + result schema bundle + * ```ts source="./protocol.examples.ts#Protocol_sendCustomRequest_bundle" + * const SearchParams = z.object({ query: z.string() }); + * const SearchResult = z.object({ hits: z.array(z.string()) }); + * + * // Passing { params, result } validates outbound params before sending and types both ends. + * const result = await peer.sendCustomRequest('acme/search', { query: 'widgets' }, { params: SearchParams, result: SearchResult }); + * console.log(result.hits); + * ``` */ sendCustomRequest

( method: string, @@ -1201,6 +1237,11 @@ export abstract class Protocol { * Pass a `{ params }` schema bundle as the third argument to get typed `params` and pre-send * validation. The schema validates only — transforms and defaults are not applied to * outbound data; the value you pass is sent as-is. + * + * @example + * ```ts source="./protocol.examples.ts#Protocol_sendCustomNotification_basic" + * await peer.sendCustomNotification('acme/heartbeat', { timestamp: Date.now() }); + * ``` */ sendCustomNotification

( method: string, diff --git a/packages/server/src/server/server.examples.ts b/packages/server/src/server/server.examples.ts new file mode 100644 index 000000000..148cf8c28 --- /dev/null +++ b/packages/server/src/server/server.examples.ts @@ -0,0 +1,35 @@ +/** + * Type-checked examples for `server.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * + * @module + */ + +import * as z from 'zod/v4'; + +import { Server } from './server.js'; + +/** Example: declare an SEP-2133 extension and wire its handlers. */ +function Server_extension_basic() { + //#region Server_extension_basic + const server = new Server({ name: 'host', version: '1.0.0' }, { capabilities: {} }); + + const ui = server.extension( + 'io.modelcontextprotocol/ui', + { openLinks: true }, // advertised in capabilities.extensions[id] + { peerSchema: z.object({ availableModes: z.array(z.string()) }) } + ); + + ui.setRequestHandler('ui/open-link', z.object({ url: z.string() }), async params => { + return { opened: params.url.startsWith('https://') }; + }); + + // After connect(): read what the client advertised for this extension + const clientUiSettings = ui.getPeerSettings(); // { availableModes: string[] } | undefined + //#endregion Server_extension_basic + void clientUiSettings; + return server; +} + +void Server_extension_basic; diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index edad28eb5..4a7ea5667 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -238,6 +238,24 @@ export class Server extends Protocol { * 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. + * + * @example + * ```ts source="./server.examples.ts#Server_extension_basic" + * const server = new Server({ name: 'host', version: '1.0.0' }, { capabilities: {} }); + * + * const ui = server.extension( + * 'io.modelcontextprotocol/ui', + * { openLinks: true }, // advertised in capabilities.extensions[id] + * { peerSchema: z.object({ availableModes: z.array(z.string()) }) } + * ); + * + * ui.setRequestHandler('ui/open-link', z.object({ url: z.string() }), async params => { + * return { opened: params.url.startsWith('https://') }; + * }); + * + * // After connect(): read what the client advertised for this extension + * const clientUiSettings = ui.getPeerSettings(); // { availableModes: string[] } | undefined + * ``` */ public extension(id: string, settings: L): ExtensionHandle; public extension(