diff --git a/.changeset/generic-extension-helpers.md b/.changeset/generic-extension-helpers.md new file mode 100644 index 000000000..4837e789b --- /dev/null +++ b/.changeset/generic-extension-helpers.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/server': minor +'@modelcontextprotocol/client': minor +--- + +Add generic SEP-2133 extension helpers: `McpServer.enableExtension(identifier, settings?)` / `McpServer.getClientExtension(identifier)` and the `Client` mirrors `enableExtension` / `getServerExtension` — thin convenience over `registerCapabilities` / `get*Capabilities` for the `capabilities.extensions` map. `McpRequestContext` now carries `clientCapabilities` (populated on the modern HTTP path from the validated per-request envelope) so a `createMcpHandler` factory can branch on extension support at construction time. diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index c48c0a2b9..6bad9aa0e 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -333,6 +333,39 @@ schema when calling a spec method. The return type is inferred from the method name via `ResultTypeMap` (e.g. `client.request({ method: 'tools/call', ... })` returns `Promise`). +#### Subclassing `Protocol` → subclass `Client` or `Server` + +The abstract `Protocol` base class is **no longer exported** (it stays internal to +`@modelcontextprotocol/core-internal`). Code that did `class X extends Protocol<…>` — +typically extension runtimes that reuse the SDK's JSON-RPC engine for a second, +non-MCP-wire link — must move to subclassing one of the two concrete peers instead: + +- `class X extends Client` for the side that **initiates** the link (sends the first + request / handshake). +- `class X extends Server` for the side that **answers** it. + +What this buys you over re-exporting `Protocol`: the v2 base has a different generic +shape (`Protocol` instead of +`Protocol`) and a new abstract `buildContext()` member, +so a v1 subclass would not have compiled against it anyway. `Client` and `Server` +already implement `buildContext()` and pin `ContextT` to `ClientContext` / +`ServerContext`, so a subclass gets the handler context for free. + +Mechanical changes inside the subclass: + +| v1 | v2 | +| --- | --- | +| `extends Protocol` | `extends Client` (or `extends Server`) | +| `RequestHandlerExtra` | `ClientContext` / `ServerContext` | +| `setRequestHandler(MyZodSchema, h)` | `setRequestHandler('my/method', { params: MyParams }, h)` — the [3-arg custom form](#setrequesthandler--setnotificationhandler-use-method-strings) | +| `Parameters['setRequestHandler']>` derivations | name `ClientContext` / `ServerContext` directly | + +`Transport` (the interface) and `InMemoryTransport` remain public, so a custom transport +that the subclass connects over is unchanged. If the link is genuinely not an MCP wire, +keep every handler on the 3-arg custom form and skip `connect()`-time version +negotiation by leaving `versionNegotiation` at its default — the subclass then behaves +exactly like a v1 `Protocol` subclass with string-keyed handlers. + ### Server registration API The deprecated variadic `.tool()`, `.prompt()`, `.resource()` are removed. Use diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 5028e937c..f5ef0d531 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -15,6 +15,7 @@ import type { GetPromptResult, Implementation, InputRequiredOptions, + JSONObject, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, @@ -741,6 +742,26 @@ export class Client extends Protocol { this._capabilities = mergeCapabilities(this._capabilities, capabilities); } + /** + * Advertise a protocol extension (SEP-2133) under + * `ClientCapabilities.extensions`. The `identifier` is the vendor-prefixed + * extension key (for example `'io.modelcontextprotocol/ui'`); `settings` + * defaults to `{}`. Thin convenience over + * {@linkcode Client.registerCapabilities} — call it before connecting. + */ + public enableExtension(identifier: string, settings: JSONObject = {}): void { + this.registerCapabilities({ extensions: { [identifier]: settings } }); + } + + /** + * The settings object the connected server advertised for the given + * extension identifier under `ServerCapabilities.extensions`, or + * `undefined` when the server did not declare it. + */ + public getServerExtension(identifier: string): JSONObject | undefined { + return this._serverCapabilities?.extensions?.[identifier]; + } + /** * Configure protocol version negotiation before connecting (equivalent to * passing `versionNegotiation` at construction time). Can only be called diff --git a/packages/client/test/client/extensionHelpers.test.ts b/packages/client/test/client/extensionHelpers.test.ts new file mode 100644 index 000000000..d5e8d6944 --- /dev/null +++ b/packages/client/test/client/extensionHelpers.test.ts @@ -0,0 +1,47 @@ +/** + * SEP-2133 generic extension helpers — `enableExtension` / `getServerExtension` + * on `Client`. Mirrors the server-side helpers in `@modelcontextprotocol/server`. + */ +import { InMemoryTransport } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it } from 'vitest'; + +import { Client } from '../../src/client/client'; + +const UI = 'io.modelcontextprotocol/ui'; + +describe('Client.enableExtension / getServerExtension', () => { + it('writes ClientCapabilities.extensions[identifier] and reads the server mirror after connect', async () => { + const client = new Client({ name: 'c', version: '1.0.0' }); + client.enableExtension(UI); + client.enableExtension('vendor.example/thing', { mode: 'fast' }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + // Minimal hand-rolled server end: answer `initialize`, echoing back an + // `extensions` declaration so `getServerExtension` has something to read. + let sawClientExtensions: unknown; + serverTransport.onmessage = msg => { + if ('method' in msg && msg.method === 'initialize' && 'id' in msg) { + sawClientExtensions = (msg.params as { capabilities?: { extensions?: unknown } }).capabilities?.extensions; + void serverTransport.send({ + jsonrpc: '2.0', + id: msg.id, + result: { + protocolVersion: (msg.params as { protocolVersion: string }).protocolVersion, + serverInfo: { name: 's', version: '1.0.0' }, + capabilities: { extensions: { [UI]: { contentTypes: ['text/html'] } } } + } + }); + } + }; + await serverTransport.start(); + + await client.connect(clientTransport); + + expect(sawClientExtensions).toEqual({ [UI]: {}, 'vendor.example/thing': { mode: 'fast' } }); + expect(client.getServerExtension(UI)).toEqual({ contentTypes: ['text/html'] }); + expect(client.getServerExtension('absent/key')).toBeUndefined(); + + expect(() => client.enableExtension('late/key')).toThrow(/after connecting/); + await client.close(); + }); +}); diff --git a/packages/core-internal/src/wire/rev2025-11-25/wireTypes.ts b/packages/core-internal/src/wire/rev2025-11-25/wireTypes.ts index 73aad9981..283070511 100644 --- a/packages/core-internal/src/wire/rev2025-11-25/wireTypes.ts +++ b/packages/core-internal/src/wire/rev2025-11-25/wireTypes.ts @@ -20,7 +20,9 @@ * neutral follows 2026 (`JSONValue`-capable open schema objects). * - capability blobs (`experimental`, `sampling`, `elicitation`, `tasks`, * `logging`, `completions`): 2025 wire `object`; neutral `JSONObject`. - * - `extensions` capability key: 2026-only; absent from the 2025 wire view. + * - `extensions` capability key: absent from the 2025 wire-type view only — + * the 2025 codec is identity for capability objects, so the key passes + * through at runtime as an open-set member. * - `CreateMessageRequestParams.metadata`: 2025 wire `object`; neutral * `JSONObject`. * - SEP-2106: `CallToolResult.structuredContent` / the `tool_result` diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index b2e4828a2..fde76e759 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -102,6 +102,16 @@ export interface McpRequestContext { authInfo?: AuthInfo; /** The original HTTP request being served, when available (HTTP only — `serveStdio` never sets it). */ requestInfo?: Request; + /** + * The client's declared capabilities, when known **before** the factory + * runs. Populated on the modern HTTP path from the validated per-request + * envelope; `undefined` on the legacy fallback and on `serveStdio` (where + * capabilities arrive on `initialize`, after the factory has produced the + * instance). Use this to branch on extension support at construction time + * — for example + * `ctx.clientCapabilities?.extensions?.['io.modelcontextprotocol/ui']`. + */ + clientCapabilities?: ClientCapabilities; } /** @@ -685,7 +695,8 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa const product = await factory({ era: 'modern', ...(authInfo !== undefined && { authInfo }), - requestInfo: request + requestInfo: request, + clientCapabilities: declaredClientCapabilities }); const server = product instanceof McpServer ? product.server : product; diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index e0d10b5a8..4f8c7cffc 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -9,6 +9,7 @@ import type { Icon, Implementation, InputRequiredResult, + JSONObject, ListPromptsResult, ListResourcesResult, ListToolsResult, @@ -156,6 +157,33 @@ export class McpServer { await this.server.close(); } + /** + * Advertise a protocol extension (SEP-2133) under + * `ServerCapabilities.extensions`. The `identifier` is the vendor-prefixed + * extension key (for example `'io.modelcontextprotocol/ui'`); `settings` + * defaults to `{}`. Thin convenience over + * {@linkcode Server.registerCapabilities} — call it before connecting (or + * inside the `createMcpHandler` factory). + */ + enableExtension(identifier: string, settings: JSONObject = {}): void { + this.server.registerCapabilities({ extensions: { [identifier]: settings } }); + } + + /** + * The settings object the connected client advertised for the given + * extension identifier under `ClientCapabilities.extensions`, or + * `undefined` when the client did not declare it. Generic SEP-2133 read + * helper; for factory-time branching prefer + * `McpRequestContext.clientCapabilities`. + */ + getClientExtension(identifier: string): JSONObject | undefined { + // The deprecated accessor remains functional in both eras (modern + // backfills it per request from the validated envelope), so a single + // read path covers stdio, legacy HTTP, and per-request modern HTTP. + // eslint-disable-next-line @typescript-eslint/no-deprecated + return this.server.getClientCapabilities()?.extensions?.[identifier]; + } + private _toolHandlersInitialized = false; private setToolRequestHandlers() { diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index f3237221e..daf761fc0 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -118,6 +118,20 @@ describe('createMcpHandler — modern path', () => { expect(state.contexts).toHaveLength(1); expect(state.contexts[0]?.era).toBe('modern'); expect(state.contexts[0]?.requestInfo).toBeInstanceOf(Request); + expect(state.contexts[0]?.clientCapabilities).toEqual({ elicitation: { form: {} } }); + }); + + it('exposes the envelope-declared extensions on McpRequestContext.clientCapabilities', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); + + const envelope = { + ...ENVELOPE, + [CLIENT_CAPABILITIES_META_KEY]: { extensions: { 'io.modelcontextprotocol/ui': { version: '1' } } } + }; + const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'x' }, envelope))); + expect(response.status).toBe(200); + expect(state.contexts[0]?.clientCapabilities?.extensions?.['io.modelcontextprotocol/ui']).toEqual({ version: '1' }); }); it('serves server/discover on the modern path with the modern supported list', async () => { diff --git a/packages/server/test/server/extensionHelpers.test.ts b/packages/server/test/server/extensionHelpers.test.ts new file mode 100644 index 000000000..c8d24273f --- /dev/null +++ b/packages/server/test/server/extensionHelpers.test.ts @@ -0,0 +1,59 @@ +/** + * SEP-2133 generic extension helpers — `enableExtension` / `getClientExtension` + * on `McpServer`. Thin convenience over `registerCapabilities` / + * `getClientCapabilities`; the helpers are not Apps-specific. + */ +import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it } from 'vitest'; + +import { McpServer } from '../../src/server/mcp'; + +const UI = 'io.modelcontextprotocol/ui'; + +describe('McpServer.enableExtension', () => { + it('writes ServerCapabilities.extensions[identifier], defaulting settings to {}', () => { + const server = new McpServer({ name: 's', version: '1.0.0' }); + server.enableExtension(UI); + server.enableExtension('vendor.example/thing', { mode: 'fast' }); + expect(server.server.getCapabilities().extensions).toEqual({ + [UI]: {}, + 'vendor.example/thing': { mode: 'fast' } + }); + }); + + it('throws after connecting (delegates to registerCapabilities)', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }); + const [, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + expect(() => server.enableExtension(UI)).toThrow(/after connecting/); + await server.close(); + }); +}); + +describe('McpServer.getClientExtension', () => { + it('reads ClientCapabilities.extensions[identifier] after a legacy initialize', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + + // Drive a 2025-era initialize from the client side carrying an + // `extensions` declaration (open-set; passes through the 2025 codec). + clientTransport.onmessage = () => {}; + await clientTransport.start(); + await clientTransport.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + clientInfo: { name: 'c', version: '1.0.0' }, + capabilities: { extensions: { [UI]: { maxWidth: 800 } } } + } + }); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(server.getClientExtension(UI)).toEqual({ maxWidth: 800 }); + expect(server.getClientExtension('absent/key')).toBeUndefined(); + await server.close(); + }); +});