From fc223d258b9f57ec9585c3fcc74f244a8f89bd06 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Wed, 16 Sep 2026 18:49:03 +0200 Subject: [PATCH 1/9] feat(server): server extensions and Protocol.overrideRequestHandler ServerOptions.extensions takes ServerExtension objects ({ id, capability?, install(server) }). Each is advertised under capabilities.extensions[id] and installed at construction, after the built-in handlers exist. Extensions register custom methods with the explicit-schema setRequestHandler and intercept spec methods with the new Protocol.overrideRequestHandler(method, (request, ctx, next) => ...). Overrides compose around the registered handler at dispatch time, so an override on tools/call applies even though McpServer registers that handler lazily; the returned function removes the override. McpServer tool dispatch re-throws MissingRequiredClientCapabilityError (-32021) as a JSON-RPC error instead of an isError tool result, matching the UrlElicitationRequiredError passthrough. --- .changeset/server-extensions.md | 8 ++ docs/.vitepress/nav.ts | 1 + docs/advanced/extensions.md | 61 +++++++++ packages/core-internal/src/shared/protocol.ts | 68 +++++++++- .../shared/overrideRequestHandler.test.ts | 123 ++++++++++++++++++ packages/server/src/index.ts | 1 + packages/server/src/server/extension.ts | 35 +++++ packages/server/src/server/mcp.ts | 8 +- packages/server/src/server/server.ts | 14 ++ .../server/test/server/extensions.test.ts | 118 +++++++++++++++++ 10 files changed, 434 insertions(+), 3 deletions(-) create mode 100644 .changeset/server-extensions.md create mode 100644 docs/advanced/extensions.md create mode 100644 packages/core-internal/test/shared/overrideRequestHandler.test.ts create mode 100644 packages/server/src/server/extension.ts create mode 100644 packages/server/test/server/extensions.test.ts diff --git a/.changeset/server-extensions.md b/.changeset/server-extensions.md new file mode 100644 index 0000000000..3fc7e3098b --- /dev/null +++ b/.changeset/server-extensions.md @@ -0,0 +1,8 @@ +--- +'@modelcontextprotocol/core-internal': minor +'@modelcontextprotocol/server': minor +--- + +Server extensions. `ServerOptions.extensions` takes `ServerExtension` objects (`{ id, capability?, install(server) }`): each is advertised under `capabilities.extensions[id]` and installed at construction. Extensions register custom methods with `setRequestHandler(method, { params, result }, handler)` and intercept spec methods with the new `Protocol.overrideRequestHandler(method, (request, ctx, next) => …)`, which composes around the registered handler at dispatch time (so an override on `tools/call` applies even though `McpServer` registers that handler lazily) and returns a remover. + +`McpServer` tool dispatch now re-throws `MissingRequiredClientCapabilityError` (`-32021`) as a JSON-RPC error instead of converting it into an `isError` tool result, matching the existing `UrlElicitationRequiredError` passthrough. diff --git a/docs/.vitepress/nav.ts b/docs/.vitepress/nav.ts index 02ab4d2ff6..f1d5f96cdf 100644 --- a/docs/.vitepress/nav.ts +++ b/docs/.vitepress/nav.ts @@ -66,6 +66,7 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [ items: [ { text: 'Low-level server', link: '/advanced/low-level-server' }, { text: 'Custom methods', link: '/advanced/custom-methods' }, + { text: 'Server extensions', link: '/advanced/extensions' }, { text: 'Schema libraries', link: '/advanced/schema-libraries' }, { text: 'Custom transports', link: '/advanced/custom-transports' }, { text: 'Wire schemas', link: '/advanced/wire-schemas' }, diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md new file mode 100644 index 0000000000..508b437c9c --- /dev/null +++ b/docs/advanced/extensions.md @@ -0,0 +1,61 @@ +--- +shape: how-to +--- + +# Server extensions + +A **server extension** packages protocol behaviour outside the core specification — an MCP extension such as `io.modelcontextprotocol/tasks`, or a vendor feature — as one object you pass to the server. The SDK advertises it, installs it, and gives it two seams: custom methods and overrides of spec methods. What the extension does behind those seams is its own business. + +## Write an extension + +An extension is an `id`, an optional settings object, and an `install` function that receives the low-level `Server`. + +```ts +import type { ServerExtension } from '@modelcontextprotocol/server'; +import { MissingRequiredClientCapabilityError, CLIENT_CAPABILITIES_META_KEY } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const GATE = 'com.example/gate'; + +export const gate: ServerExtension = { + id: GATE, + capability: { modes: ['strict'] }, + install(server) { + // A custom method, exactly as in Custom methods. + server.setRequestHandler('gate/status', { params: z.looseObject({}) }, () => ({ armed: true })); + + // An override of a spec method: runs before the registered handler, + // may answer, transform, or refuse. + server.overrideRequestHandler('tools/call', (request, ctx, next) => { + const declared = ctx.mcpReq.envelope?.[CLIENT_CAPABILITIES_META_KEY]?.extensions ?? {}; + if (!(GATE in declared)) { + throw new MissingRequiredClientCapabilityError({ requiredCapabilities: { extensions: { [GATE]: {} } } }, 'declare the gate'); + } + return next(request, ctx); + }); + } +}; +``` + +## Install it + +Pass extensions at construction. Each is advertised under `capabilities.extensions[id]` — legacy connections see it in the `initialize` result, 2026-07-28 connections in `server/discover` — and installed in order after the built-in handlers exist. + +```ts +const server = new McpServer({ name: 'gated', version: '1.0.0' }, { extensions: [gate] }); +``` + +The same option exists on the low-level `Server`. + +## How overrides compose + +`overrideRequestHandler(method, override)` wraps whatever handler serves `method` at dispatch time. That matters for `tools/call`, which `McpServer` registers on the first tool registration: an override installed at construction still applies. With no underlying handler, `next` throws `MethodNotFound`. Several overrides nest, the latest outermost. The returned function removes the override. + +A thrown `ProtocolError` becomes the JSON-RPC error response. Inside a tool handler, `McpServer` converts most throws into an `isError` tool result; the exceptions are protocol-level errors the client must see as errors — `UrlElicitationRequiredError` and `MissingRequiredClientCapabilityError` (`-32021`). + +## Recap + +- `ServerExtension` is `{ id, capability?, install(server) }`; pass it in `ServerOptions.extensions`. +- `install` gets the low-level `Server`: `setRequestHandler` for custom methods, `overrideRequestHandler` to intercept spec methods. +- Overrides compose at dispatch time and apply to handlers registered later. +- The SDK owns the seams and the capability advertisement, not the extension's state or execution. diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 637be389aa..15149bf1ec 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -559,6 +559,17 @@ export abstract class Protocol { private _transport?: Transport; private _requestMessageId = 0; private _requestHandlers: Map Promise> = new Map(); + /** Overrides installed by `overrideRequestHandler`, in installation order (see `_resolveRequestHandler`). */ + private _requestHandlerOverrides: Map< + string, + Array< + ( + request: JSONRPCRequest, + ctx: ContextT, + next: (request: JSONRPCRequest, ctx: ContextT) => Promise + ) => Result | Promise + > + > = new Map(); private _requestHandlerAbortControllers: Map = new Map(); private _notificationHandlers: Map Promise> = new Map(); private _responseHandlers: Map void> = new Map(); @@ -1005,7 +1016,7 @@ export abstract class Protocol { return; } - const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; + const handler = this._resolveRequestHandler(request.method); if (handler === undefined) { sendErrorResponse(ProtocolErrorCode.MethodNotFound, 'Method not found'); @@ -1754,6 +1765,61 @@ export abstract class Protocol { this._requestHandlers.set(method, this._wrapHandler(method, stored)); } + /** + * Installs an override around the request handler for `method`. The + * override receives the parsed request, the context, and `next` — the + * handler it wraps — and may answer itself, transform what `next` + * returns, or throw a `ProtocolError` that becomes the JSON-RPC error + * response. Overrides compose at dispatch time, so an override installed + * before the underlying handler exists (e.g. `tools/call`, which + * `McpServer` registers on the first tool registration) still applies; + * with no underlying handler and no fallback, `next` throws + * `MethodNotFound`. Later overrides run outside earlier ones. This is the + * seam server extensions use to intercept spec methods. + * + * @returns A function that removes the override. + */ + overrideRequestHandler( + method: RequestMethod | string, + override: ( + request: JSONRPCRequest, + ctx: ContextT, + next: (request: JSONRPCRequest, ctx: ContextT) => Promise + ) => Result | Promise + ): () => void { + const overrides = this._requestHandlerOverrides.get(method) ?? []; + overrides.push(override); + this._requestHandlerOverrides.set(method, overrides); + return () => { + const current = this._requestHandlerOverrides.get(method); + if (current === undefined) return; + const index = current.indexOf(override); + if (index !== -1) current.splice(index, 1); + if (current.length === 0) this._requestHandlerOverrides.delete(method); + }; + } + + /** + * The handler `_onrequest` dispatches to for `method`: the registered + * handler (or the fallback), with any overrides composed around it. + * `undefined` when nothing is registered and nothing overrides. + */ + private _resolveRequestHandler(method: string): ((request: JSONRPCRequest, ctx: ContextT) => Promise) | undefined { + const base = this._requestHandlers.get(method) ?? this.fallbackRequestHandler; + const overrides = this._requestHandlerOverrides.get(method); + if (overrides === undefined || overrides.length === 0) return base; + let composed: (request: JSONRPCRequest, ctx: ContextT) => Promise = + base ?? + (async () => { + throw new ProtocolError(ProtocolErrorCode.MethodNotFound, 'Method not found'); + }); + for (const override of overrides) { + const next = composed; + composed = async (request, ctx) => override(request, ctx, next); + } + return composed; + } + /** * Hook for subclasses to wrap a registered request handler with role-specific * validation or behavior (e.g. `Server` validates `tools/call` results, `Client` diff --git a/packages/core-internal/test/shared/overrideRequestHandler.test.ts b/packages/core-internal/test/shared/overrideRequestHandler.test.ts new file mode 100644 index 0000000000..dc7101c4f2 --- /dev/null +++ b/packages/core-internal/test/shared/overrideRequestHandler.test.ts @@ -0,0 +1,123 @@ +/** + * `Protocol.overrideRequestHandler`: overrides compose around the registered + * handler at dispatch time, may answer or throw themselves, apply to handlers + * registered later, and can be removed. + */ +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import type { BaseContext } from '../../src/shared/protocol'; +import { Protocol } from '../../src/shared/protocol'; +import type { JSONRPCErrorResponse, JSONRPCMessage, JSONRPCResultResponse, Result } from '../../src/types/index'; +import { ProtocolError, ProtocolErrorCode } from '../../src/types/index'; +import { InMemoryTransport } from '../../src/util/inMemory'; + +class TestProtocol extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +const flush = () => new Promise(resolve => setTimeout(resolve, 5)); + +async function harness() { + const [peerTx, protocolTx] = InMemoryTransport.createLinkedPair(); + const sent: JSONRPCMessage[] = []; + peerTx.onmessage = message => void sent.push(message); + await peerTx.start(); + const protocol = new TestProtocol(); + await protocol.connect(protocolTx); + let nextId = 0; + const call = async (method: string, params: Record = {}) => { + const id = ++nextId; + await peerTx.send({ jsonrpc: '2.0', id, method, params }); + await flush(); + const response = sent.find(message => 'id' in message && message.id === id); + return response as JSONRPCResultResponse | JSONRPCErrorResponse; + }; + return { protocol, call }; +} + +describe('Protocol.overrideRequestHandler', () => { + it('wraps the registered handler; next reaches it and the override may transform the result', async () => { + const { protocol, call } = await harness(); + protocol.setRequestHandler('acme/op', { params: z.looseObject({}) }, () => ({ value: 1 }) as Result); + protocol.overrideRequestHandler('acme/op', async (request, ctx, next) => { + const result = (await next(request, ctx)) as { value: number }; + return { value: result.value + 1, wrapped: true } as Result; + }); + const response = await call('acme/op'); + expect((response as JSONRPCResultResponse).result).toEqual({ value: 2, wrapped: true }); + }); + + it('may answer without calling next, and a thrown ProtocolError becomes the JSON-RPC error', async () => { + const { protocol, call } = await harness(); + let handlerRan = false; + protocol.setRequestHandler('acme/op', { params: z.looseObject({ deny: z.boolean().optional() }) }, () => { + handlerRan = true; + return {} as Result; + }); + protocol.overrideRequestHandler('acme/op', (request, ctx, next) => { + if ((request.params as { deny?: boolean }).deny) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'denied by override'); + } + return next(request, ctx); + }); + const denied = await call('acme/op', { deny: true }); + expect((denied as JSONRPCErrorResponse).error).toMatchObject({ + code: ProtocolErrorCode.InvalidParams, + message: 'denied by override' + }); + expect(handlerRan).toBe(false); + await call('acme/op'); + expect(handlerRan).toBe(true); + }); + + it('applies to a handler registered after the override was installed', async () => { + const { protocol, call } = await harness(); + const order: string[] = []; + protocol.overrideRequestHandler('acme/late', async (request, ctx, next) => { + order.push('override'); + return next(request, ctx); + }); + protocol.setRequestHandler('acme/late', { params: z.looseObject({}) }, () => { + order.push('handler'); + return {} as Result; + }); + await call('acme/late'); + expect(order).toEqual(['override', 'handler']); + }); + + it('next throws MethodNotFound when nothing underlies the override', async () => { + const { protocol, call } = await harness(); + protocol.overrideRequestHandler('acme/missing', (request, ctx, next) => next(request, ctx)); + const response = await call('acme/missing'); + expect((response as JSONRPCErrorResponse).error).toMatchObject({ code: ProtocolErrorCode.MethodNotFound }); + }); + + it('later overrides run outside earlier ones, and removal restores the handler', async () => { + const { protocol, call } = await harness(); + const order: string[] = []; + protocol.setRequestHandler('acme/op', { params: z.looseObject({}) }, () => { + order.push('handler'); + return {} as Result; + }); + const removeInner = protocol.overrideRequestHandler('acme/op', async (request, ctx, next) => { + order.push('inner'); + return next(request, ctx); + }); + protocol.overrideRequestHandler('acme/op', async (request, ctx, next) => { + order.push('outer'); + return next(request, ctx); + }); + await call('acme/op'); + expect(order).toEqual(['outer', 'inner', 'handler']); + order.length = 0; + removeInner(); + await call('acme/op'); + expect(order).toEqual(['outer', 'handler']); + }); +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 9b3196a80b..8c51e0124f 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -57,6 +57,7 @@ export { PerRequestHTTPServerTransport } from './server/perRequestTransport'; // convenience codec consumers drop into ServerOptions.requestState.verify. export type { RequestStateCodec, RequestStateCodecOptions } from './server/requestStateCodec'; export { createRequestStateCodec } from './server/requestStateCodec'; +export type { ServerExtension } from './server/extension'; export type { ServerOptions } from './server/server'; export { Server } from './server/server'; // subscriptions/listen change-event sourcing seam (protocol revision 2026-07-28). diff --git a/packages/server/src/server/extension.ts b/packages/server/src/server/extension.ts new file mode 100644 index 0000000000..b7bfb5061c --- /dev/null +++ b/packages/server/src/server/extension.ts @@ -0,0 +1,35 @@ +import type { JSONObject } from '@modelcontextprotocol/core-internal'; + +import type { Server } from './server'; + +/** + * A server extension: a unit of protocol behaviour outside the core + * specification (an MCP extension such as `io.modelcontextprotocol/tasks`, + * or a vendor feature) that installs itself onto a {@linkcode Server}. + * + * Pass extensions at construction — `new McpServer(info, { extensions: [ext] })` + * or `new Server(info, { extensions: [ext] })`. The server advertises each + * extension under `capabilities.extensions[id]` and then calls `install`, + * which is where the extension registers its custom methods + * (`server.setRequestHandler(method, { params, result }, handler)`), + * overrides spec methods it needs to intercept + * (`server.overrideRequestHandler('tools/call', …)`), and adds notification + * handlers. The SDK provides the seams; what an extension does behind them + * — how it stores state, where its work runs — is the extension's own. + */ +export interface ServerExtension { + /** + * The extension identifier, prefix-qualified (`io.modelcontextprotocol/tasks`, + * `com.example/feature-flags`). Advertised as the key under + * `capabilities.extensions`. + */ + readonly id: string; + /** + * The extension's settings object, advertised as the value under + * `capabilities.extensions[id]`. `{}` (the default) means supported with + * no settings. + */ + readonly capability?: JSONObject; + /** Installs the extension's handlers and overrides onto the server. Called once, at construction. */ + install(server: Server): void; +} diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 70a5539bfb..113f17f30a 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -280,8 +280,12 @@ export class McpServer { // wrapped) so the listing and the call cannot diverge. return this.server.projectCallToolResult(result, tool.outputSchemaJson); } catch (error) { - if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) { - throw error; // Return the error to the caller without wrapping in CallToolResult + if ( + error instanceof ProtocolError && + (error.code === ProtocolErrorCode.UrlElicitationRequired || + error.code === ProtocolErrorCode.MissingRequiredClientCapability) + ) { + throw error; // Protocol-level: return the error to the caller without wrapping in CallToolResult } return this.createToolError(error instanceof Error ? error.message : String(error)); } diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 5de0d8919c..7188bf103b 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -72,7 +72,16 @@ import { coerceEmbeddedInputRequest, LegacyInputRequiredShim, resolveLegacyShimO */ const INPUT_REQUIRED_CAPABLE_METHODS: ReadonlySet = new Set(['tools/call', 'prompts/get', 'resources/read']); +import type { ServerExtension } from './extension'; + export type ServerOptions = ProtocolOptions & { + /** + * Extensions to install at construction. Each is advertised under + * `capabilities.extensions[extension.id]` and then installed, in order, + * after the built-in handlers exist — see {@linkcode ServerExtension}. + */ + extensions?: ServerExtension[]; + /** * Capabilities to advertise as being supported by this server. * @@ -349,6 +358,11 @@ export class Server extends Protocol { if (this._capabilities.logging) { this._registerLoggingHandler(); } + + for (const extension of options?.extensions ?? []) { + this.registerCapabilities({ extensions: { [extension.id]: extension.capability ?? {} } }); + extension.install(this); + } } /** diff --git a/packages/server/test/server/extensions.test.ts b/packages/server/test/server/extensions.test.ts new file mode 100644 index 0000000000..bf824cafca --- /dev/null +++ b/packages/server/test/server/extensions.test.ts @@ -0,0 +1,118 @@ +/** + * `ServerOptions.extensions`: an extension is advertised under + * `capabilities.extensions[id]` and installed at construction, where it can + * register custom methods and override spec methods such as `tools/call`. + */ +import type { JSONRPCRequest, MessageClassification } from '@modelcontextprotocol/core-internal'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + MissingRequiredClientCapabilityError, + PROTOCOL_VERSION_META_KEY, + setNegotiatedProtocolVersion +} from '@modelcontextprotocol/core-internal'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import type { ServerExtension } from '../../src/server/extension'; +import { invoke } from '../../src/server/invoke'; +import { McpServer } from '../../src/server/mcp'; +import { Server } from '../../src/server/server'; + +const MODERN_REVISION = '2026-07-28'; +const MODERN: MessageClassification = { era: 'modern', revision: MODERN_REVISION }; +const EXT_ID = 'com.example/gate'; + +const modernRequest = ( + method: string, + params: Record = {}, + clientCapabilities: Record = {} +): JSONRPCRequest => + ({ + jsonrpc: '2.0', + id: 1, + method, + params: { + ...params, + _meta: { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'ext-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: clientCapabilities + } + } + }) as JSONRPCRequest; + +async function exchange(server: Server, request: JSONRPCRequest): Promise> { + setNegotiatedProtocolVersion(server, MODERN_REVISION); + const response = await invoke(server, request, { classification: MODERN }); + return (await response.json()) as Record; +} + +/** An extension that gates `tools/call` on a client capability and adds one custom method. */ +function gateExtension(log: string[]): ServerExtension { + return { + id: EXT_ID, + capability: { modes: ['strict'] }, + install(server) { + log.push('installed'); + server.setRequestHandler('gate/status', { params: z.looseObject({}) }, () => ({ armed: true })); + server.overrideRequestHandler('tools/call', (request, ctx, next) => { + const envelope = ctx.mcpReq.envelope as Record> | undefined; + const extensions = envelope?.[CLIENT_CAPABILITIES_META_KEY]?.['extensions'] as Record | undefined; + if (extensions === undefined || !(EXT_ID in extensions)) { + throw new MissingRequiredClientCapabilityError( + { requiredCapabilities: { extensions: { [EXT_ID]: {} } } }, + 'declare the gate' + ); + } + return next(request, ctx); + }); + } + }; +} + +describe('ServerOptions.extensions', () => { + it('advertises the extension capability and installs it at construction', () => { + const log: string[] = []; + const server = new Server({ name: 's', version: '1' }, { extensions: [gateExtension(log)] }); + expect(log).toEqual(['installed']); + expect(server.getCapabilities().extensions).toEqual({ [EXT_ID]: { modes: ['strict'] } }); + }); + + it('defaults the advertised settings to {} and passes through McpServer', () => { + const ext: ServerExtension = { id: 'com.example/plain', install: () => {} }; + const mcp = new McpServer({ name: 's', version: '1' }, { extensions: [ext] }); + expect(mcp.server.getCapabilities().extensions).toEqual({ 'com.example/plain': {} }); + }); + + it('serves the extension custom method', async () => { + const server = new Server({ name: 's', version: '1' }, { extensions: [gateExtension([])] }); + const body = await exchange(server, modernRequest('gate/status')); + expect(body['result']).toMatchObject({ armed: true }); + }); + + it('overrides tools/call registered later by McpServer: refuses without the capability, passes through with it', async () => { + const mcp = new McpServer({ name: 's', version: '1' }, { extensions: [gateExtension([])] }); + mcp.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] + })); + + const refused = await exchange(mcp.server, modernRequest('tools/call', { name: 'echo', arguments: { text: 'hi' } })); + expect(refused['error']).toMatchObject({ code: -32_021, message: 'declare the gate' }); + + const allowed = await exchange( + mcp.server, + modernRequest('tools/call', { name: 'echo', arguments: { text: 'hi' } }, { extensions: { [EXT_ID]: {} } }) + ); + expect(allowed['result']).toMatchObject({ content: [{ type: 'text', text: 'hi' }] }); + }); + + it('a MissingRequiredClientCapabilityError thrown by a tool handler is a JSON-RPC error, not an isError result', async () => { + const mcp = new McpServer({ name: 's', version: '1' }); + mcp.registerTool('needs-ext', {}, async () => { + throw new MissingRequiredClientCapabilityError({ requiredCapabilities: { extensions: { [EXT_ID]: {} } } }, 'declare the gate'); + }); + const body = await exchange(mcp.server, modernRequest('tools/call', { name: 'needs-ext', arguments: {} })); + expect(body['error']).toMatchObject({ code: -32_021, data: { requiredCapabilities: { extensions: { [EXT_ID]: {} } } } }); + }); +}); From e9c8d46f0bb6b4958af2eaec16fe9b0359c34935 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 17 Sep 2026 09:23:01 +0200 Subject: [PATCH 2/9] chore(server): sort the ServerExtension export --- packages/server/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 8c51e0124f..eca691891d 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -55,9 +55,9 @@ export type { PerRequestHTTPServerTransportOptions, PerRequestMessageExtra, PerR export { PerRequestHTTPServerTransport } from './server/perRequestTransport'; // Opt-in HMAC sealing for the multi-round-trip requestState (SEP-2322): the // convenience codec consumers drop into ServerOptions.requestState.verify. +export type { ServerExtension } from './server/extension'; export type { RequestStateCodec, RequestStateCodecOptions } from './server/requestStateCodec'; export { createRequestStateCodec } from './server/requestStateCodec'; -export type { ServerExtension } from './server/extension'; export type { ServerOptions } from './server/server'; export { Server } from './server/server'; // subscriptions/listen change-event sourcing seam (protocol revision 2026-07-28). From 3d2c128a66ddcfd7393a2390feaec294e5354b65 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 17 Sep 2026 12:30:11 +0200 Subject: [PATCH 3/9] docs(server): use an obviously illustrative extension settings value --- docs/advanced/extensions.md | 2 +- packages/server/test/server/extensions.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index 508b437c9c..c670a5e6d5 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -19,7 +19,7 @@ const GATE = 'com.example/gate'; export const gate: ServerExtension = { id: GATE, - capability: { modes: ['strict'] }, + capability: { exampleData: true }, install(server) { // A custom method, exactly as in Custom methods. server.setRequestHandler('gate/status', { params: z.looseObject({}) }, () => ({ armed: true })); diff --git a/packages/server/test/server/extensions.test.ts b/packages/server/test/server/extensions.test.ts index bf824cafca..8ec100c88a 100644 --- a/packages/server/test/server/extensions.test.ts +++ b/packages/server/test/server/extensions.test.ts @@ -52,7 +52,7 @@ async function exchange(server: Server, request: JSONRPCRequest): Promise ({ armed: true })); @@ -76,7 +76,7 @@ describe('ServerOptions.extensions', () => { const log: string[] = []; const server = new Server({ name: 's', version: '1' }, { extensions: [gateExtension(log)] }); expect(log).toEqual(['installed']); - expect(server.getCapabilities().extensions).toEqual({ [EXT_ID]: { modes: ['strict'] } }); + expect(server.getCapabilities().extensions).toEqual({ [EXT_ID]: { exampleData: true } }); }); it('defaults the advertised settings to {} and passes through McpServer', () => { From 68b6bc65e3ab13b7c8ae5021bfa682693dd93ceb Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 17 Sep 2026 12:35:32 +0200 Subject: [PATCH 4/9] feat(client): client extensions ClientOptions.extensions takes ClientExtension objects ({ id, capability?, install(client) }), the client half of the server extensions seam. Each is advertised under the client's capabilities.extensions[id] (in initialize on a legacy connection, in every request's client-capabilities envelope on 2026-07-28) and installed with the Client at construction. --- .changeset/server-extensions.md | 3 +- docs/advanced/extensions.md | 19 ++- packages/client/src/client/client.ts | 13 ++ packages/client/src/client/extension.ts | 34 +++++ packages/client/src/index.ts | 1 + .../client/test/client/extensions.test.ts | 126 ++++++++++++++++++ 6 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 packages/client/src/client/extension.ts create mode 100644 packages/client/test/client/extensions.test.ts diff --git a/.changeset/server-extensions.md b/.changeset/server-extensions.md index 3fc7e3098b..de886a4bd4 100644 --- a/.changeset/server-extensions.md +++ b/.changeset/server-extensions.md @@ -1,8 +1,9 @@ --- '@modelcontextprotocol/core-internal': minor +'@modelcontextprotocol/client': minor '@modelcontextprotocol/server': minor --- -Server extensions. `ServerOptions.extensions` takes `ServerExtension` objects (`{ id, capability?, install(server) }`): each is advertised under `capabilities.extensions[id]` and installed at construction. Extensions register custom methods with `setRequestHandler(method, { params, result }, handler)` and intercept spec methods with the new `Protocol.overrideRequestHandler(method, (request, ctx, next) => …)`, which composes around the registered handler at dispatch time (so an override on `tools/call` applies even though `McpServer` registers that handler lazily) and returns a remover. +Server and client extensions. `ServerOptions.extensions` takes `ServerExtension` objects (`{ id, capability?, install(server) }`): each is advertised under `capabilities.extensions[id]` and installed at construction. Extensions register custom methods with `setRequestHandler(method, { params, result }, handler)` and intercept spec methods with the new `Protocol.overrideRequestHandler(method, (request, ctx, next) => …)`, which composes around the registered handler at dispatch time (so an override on `tools/call` applies even though `McpServer` registers that handler lazily) and returns a remover. `ClientOptions.extensions` takes the symmetric `ClientExtension` objects, advertised under the client's `capabilities.extensions[id]` (in `initialize` on a legacy connection, in every request's client-capabilities envelope on 2026-07-28) and installed with the `Client`. `McpServer` tool dispatch now re-throws `MissingRequiredClientCapabilityError` (`-32021`) as a JSON-RPC error instead of converting it into an `isError` tool result, matching the existing `UrlElicitationRequiredError` passthrough. diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index c670a5e6d5..2017d59346 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -47,6 +47,23 @@ const server = new McpServer({ name: 'gated', version: '1.0.0' }, { extensions: The same option exists on the low-level `Server`. +## Client extensions + +The client half is symmetric: `ClientExtension` is `{ id, capability?, install(client) }`, passed in `ClientOptions.extensions`. The client advertises it under its own `capabilities.extensions[id]` — in `initialize` on a legacy connection, and in every request's `_meta` client-capabilities envelope on a 2026-07-28 connection, which is where a server extension reads it — and `install` receives the `Client` to register handlers for server-to-client requests and notifications, or override the ones the SDK installs. + +```ts +import type { ClientExtension } from '@modelcontextprotocol/client'; + +const gateClient: ClientExtension = { + id: GATE, + install(client) { + client.setRequestHandler('gate/ping', { params: z.looseObject({}) }, () => ({ pong: true })); + } +}; + +const client = new Client({ name: 'gated-client', version: '1.0.0' }, { extensions: [gateClient] }); +``` + ## How overrides compose `overrideRequestHandler(method, override)` wraps whatever handler serves `method` at dispatch time. That matters for `tools/call`, which `McpServer` registers on the first tool registration: an override installed at construction still applies. With no underlying handler, `next` throws `MethodNotFound`. Several overrides nest, the latest outermost. The returned function removes the override. @@ -55,7 +72,7 @@ A thrown `ProtocolError` becomes the JSON-RPC error response. Inside a tool hand ## Recap -- `ServerExtension` is `{ id, capability?, install(server) }`; pass it in `ServerOptions.extensions`. +- `ServerExtension` is `{ id, capability?, install(server) }`; pass it in `ServerOptions.extensions`. `ClientExtension` mirrors it on `ClientOptions.extensions`. - `install` gets the low-level `Server`: `setRequestHandler` for custom methods, `overrideRequestHandler` to intercept spec methods. - Overrides compose at dispatch time and apply to handlers registered later. - The SDK owns the seams and the capability advertisement, not the extension's state or execution. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 0b386a63e8..bf303b1c1d 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -80,6 +80,7 @@ import { SUPPORTED_MODERN_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core-internal'; +import type { ClientExtension } from './extension'; import type { PriorDiscovery } from './probeClassifier'; import type { CacheMode, CacheScope, ResponseCacheStore } from './responseCache'; import { ClientResponseCache, InMemoryResponseCacheStore, MAX_CACHE_TTL_MS } from './responseCache'; @@ -181,6 +182,13 @@ export function getSupportedElicitationModes(capabilities: ClientCapabilities['e } export type ClientOptions = ProtocolOptions & { + /** + * Extensions to install at construction. Each is advertised under + * `capabilities.extensions[extension.id]` and then installed, in order — + * see {@linkcode ClientExtension}. + */ + extensions?: ClientExtension[]; + /** * Capabilities to advertise as being supported by this client. */ @@ -658,6 +666,11 @@ export class Client extends Protocol { if (options?.listChanged) { this._listChangedConfig = options.listChanged; } + + for (const extension of options?.extensions ?? []) { + this.registerCapabilities({ extensions: { [extension.id]: extension.capability ?? {} } }); + extension.install(this); + } } protected override buildContext(ctx: BaseContext, _transportInfo?: MessageExtraInfo): ClientContext { diff --git a/packages/client/src/client/extension.ts b/packages/client/src/client/extension.ts new file mode 100644 index 0000000000..58793f3889 --- /dev/null +++ b/packages/client/src/client/extension.ts @@ -0,0 +1,34 @@ +import type { JSONObject } from '@modelcontextprotocol/core-internal'; + +import type { Client } from './client'; + +/** + * A client extension: the client half of protocol behaviour outside the + * core specification (an MCP extension such as `io.modelcontextprotocol/tasks`, + * or a vendor feature) that installs itself onto a {@linkcode Client}. + * + * Pass extensions at construction — `new Client(info, { extensions: [ext] })`. + * The client advertises each extension under `capabilities.extensions[id]` + * (in `initialize` on a legacy connection, in every request's + * `_meta` client-capabilities envelope on a 2026-07-28 connection) and then + * calls `install`, which is where the extension registers handlers for + * server-to-client requests and notifications, or overrides the ones the + * SDK installs (`client.overrideRequestHandler('elicitation/create', …)`). + * The SDK provides the seams; what an extension does behind them is its own. + */ +export interface ClientExtension { + /** + * The extension identifier, prefix-qualified (`io.modelcontextprotocol/tasks`, + * `com.example/feature-flags`). Advertised as the key under + * `capabilities.extensions`. + */ + readonly id: string; + /** + * The extension's settings object, advertised as the value under + * `capabilities.extensions[id]`. `{}` (the default) means supported with + * no settings. + */ + readonly capability?: JSONObject; + /** Installs the extension's handlers and overrides onto the client. Called once, at construction. */ + install(client: Client): void; +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 0b5b6e86ea..12c2667b7f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -68,6 +68,7 @@ export { StaticPrivateKeyJwtProvider } from './client/authExtensions'; export type { CacheableRequestOptions, CallToolRequestOptions, ClientOptions, ConnectOptions, McpSubscription } from './client/client'; +export type { ClientExtension } from './client/extension'; export { Client } from './client/client'; export { getSupportedElicitationModes } from './client/client'; export type { DiscoverAndRequestJwtAuthGrantOptions, JwtAuthGrantResult, RequestJwtAuthGrantOptions } from './client/crossAppAccess'; diff --git a/packages/client/test/client/extensions.test.ts b/packages/client/test/client/extensions.test.ts new file mode 100644 index 0000000000..309957779e --- /dev/null +++ b/packages/client/test/client/extensions.test.ts @@ -0,0 +1,126 @@ +/** + * `ClientOptions.extensions`: an extension is advertised under the client's + * `capabilities.extensions[id]` — in `initialize` on a legacy connection, in + * every request's `_meta` client-capabilities envelope on a 2026-07-28 + * connection — and installed at construction, where it can register + * handlers for server-to-client requests. + */ +import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal'; +import { CLIENT_CAPABILITIES_META_KEY, InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { Client } from '../../src/client/client'; +import type { ClientExtension } from '../../src/client/extension'; + +const MODERN = '2026-07-28'; +const EXT_ID = 'com.example/gate'; + +const flush = () => new Promise(resolve => setTimeout(resolve, 20)); + +function gateExtension(log: string[]): ClientExtension { + return { + id: EXT_ID, + capability: { exampleData: true }, + install(client) { + log.push('installed'); + client.setRequestHandler('gate/ping', { params: z.looseObject({}) }, () => ({ pong: true })); + } + }; +} + +/** A scripted server side: answers the handshake for the requested era and records everything the client writes. */ +async function scriptedServer(era: 'modern' | 'legacy') { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + serverTx.onmessage = message => { + written.push(message); + const request = message as { id?: number | string; method?: string }; + if (request.id === undefined) return; + if (request.method === 'server/discover') { + void serverTx.send( + era === 'modern' + ? { + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: {} }, + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'scripted', version: '1.0.0' } } + } + } + : { jsonrpc: '2.0', id: request.id, error: { code: -32_601, message: 'Method not found' } } + ); + } else if (request.method === 'initialize') { + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: 'scripted', version: '1.0.0' } + } + }); + } else if (request.method === 'tools/list') { + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + result: era === 'modern' ? { resultType: 'complete', tools: [], ttlMs: 0, cacheScope: 'public' } : { tools: [] } + }); + } + }; + await serverTx.start(); + return { clientTx, serverTx, written }; +} + +const paramsOf = (message: JSONRPCMessage): Record => (message as { params?: Record }).params ?? {}; + +describe('ClientOptions.extensions', () => { + it('installs the extension at construction', () => { + const log: string[] = []; + new Client({ name: 'c', version: '1' }, { extensions: [gateExtension(log)] }); + expect(log).toEqual(['installed']); + }); + + it('defaults the advertised settings to {}', async () => { + const { clientTx, written } = await scriptedServer('legacy'); + const client = new Client({ name: 'c', version: '1' }, { extensions: [{ id: 'com.example/plain', install: () => {} }] }); + await client.connect(clientTx); + const initialize = written.find(message => (message as { method?: string }).method === 'initialize'); + expect(paramsOf(initialize as JSONRPCMessage)['capabilities']).toMatchObject({ extensions: { 'com.example/plain': {} } }); + await client.close(); + }); + + it('sends the extension in initialize on a legacy connection', async () => { + const { clientTx, written } = await scriptedServer('legacy'); + const client = new Client({ name: 'c', version: '1' }, { extensions: [gateExtension([])] }); + await client.connect(clientTx); + const initialize = written.find(message => (message as { method?: string }).method === 'initialize'); + expect(paramsOf(initialize as JSONRPCMessage)['capabilities']).toMatchObject({ extensions: { [EXT_ID]: { exampleData: true } } }); + await client.close(); + }); + + it('stamps the extension into every request envelope on a 2026-07-28 connection', async () => { + const { clientTx, written } = await scriptedServer('modern'); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' }, extensions: [gateExtension([])] }); + await client.connect(clientTx); + await client.listTools(); + await flush(); + const toolsList = written.find(message => (message as { method?: string }).method === 'tools/list'); + const meta = paramsOf(toolsList as JSONRPCMessage)['_meta'] as Record; + expect(meta[CLIENT_CAPABILITIES_META_KEY]).toMatchObject({ extensions: { [EXT_ID]: { exampleData: true } } }); + await client.close(); + }); + + it('serves the handler the extension installed for a server-to-client request', async () => { + const { clientTx, serverTx, written } = await scriptedServer('legacy'); + const client = new Client({ name: 'c', version: '1' }, { extensions: [gateExtension([])] }); + await client.connect(clientTx); + await serverTx.send({ jsonrpc: '2.0', id: 'srv-1', method: 'gate/ping', params: {} }); + await flush(); + const response = written.find(message => 'id' in message && message.id === 'srv-1'); + expect((response as { result?: unknown }).result).toEqual({ pong: true }); + await client.close(); + }); +}); From df62dee840f62428b5597ba5f45a40d650957ade Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 17 Sep 2026 12:47:47 +0200 Subject: [PATCH 5/9] feat(core-internal): Protocol.acceptResultType for extension result kinds A client extension declares that results of a method may carry its own resultType (the Tasks extension answers tools/call with "task"). A raw response with that discriminator skips the era codec's closed vocabulary and is validated against the caller's explicit result schema as-is. Also replaces the word 'seam' with 'hook' in the text this stack added. --- .changeset/server-extensions.md | 2 +- docs/advanced/extensions.md | 9 +++- packages/client/src/client/extension.ts | 2 +- .../client/test/client/extensions.test.ts | 19 +++++++ packages/core-internal/src/shared/protocol.ts | 50 ++++++++++++++++++- packages/server/src/server/extension.ts | 2 +- 6 files changed, 78 insertions(+), 6 deletions(-) diff --git a/.changeset/server-extensions.md b/.changeset/server-extensions.md index de886a4bd4..762b29f529 100644 --- a/.changeset/server-extensions.md +++ b/.changeset/server-extensions.md @@ -4,6 +4,6 @@ '@modelcontextprotocol/server': minor --- -Server and client extensions. `ServerOptions.extensions` takes `ServerExtension` objects (`{ id, capability?, install(server) }`): each is advertised under `capabilities.extensions[id]` and installed at construction. Extensions register custom methods with `setRequestHandler(method, { params, result }, handler)` and intercept spec methods with the new `Protocol.overrideRequestHandler(method, (request, ctx, next) => …)`, which composes around the registered handler at dispatch time (so an override on `tools/call` applies even though `McpServer` registers that handler lazily) and returns a remover. `ClientOptions.extensions` takes the symmetric `ClientExtension` objects, advertised under the client's `capabilities.extensions[id]` (in `initialize` on a legacy connection, in every request's client-capabilities envelope on 2026-07-28) and installed with the `Client`. +Server and client extensions. `ServerOptions.extensions` takes `ServerExtension` objects (`{ id, capability?, install(server) }`): each is advertised under `capabilities.extensions[id]` and installed at construction. Extensions register custom methods with `setRequestHandler(method, { params, result }, handler)` and intercept spec methods with the new `Protocol.overrideRequestHandler(method, (request, ctx, next) => …)`, which composes around the registered handler at dispatch time (so an override on `tools/call` applies even though `McpServer` registers that handler lazily) and returns a remover. `ClientOptions.extensions` takes the symmetric `ClientExtension` objects, advertised under the client's `capabilities.extensions[id]` (in `initialize` on a legacy connection, in every request's client-capabilities envelope on 2026-07-28) and installed with the `Client`. `Protocol.acceptResultType(method, resultType)` declares an extension result kind for a method: a raw response carrying that `resultType` bypasses the era codec's closed vocabulary and is validated against the caller's explicit result schema as-is, which is how a client extension receives shapes such as the Tasks extension's `resultType: "task"` on `tools/call`. `McpServer` tool dispatch now re-throws `MissingRequiredClientCapabilityError` (`-32021`) as a JSON-RPC error instead of converting it into an `isError` tool result, matching the existing `UrlElicitationRequiredError` passthrough. diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index 2017d59346..6a034fd214 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -4,7 +4,7 @@ shape: how-to # Server extensions -A **server extension** packages protocol behaviour outside the core specification — an MCP extension such as `io.modelcontextprotocol/tasks`, or a vendor feature — as one object you pass to the server. The SDK advertises it, installs it, and gives it two seams: custom methods and overrides of spec methods. What the extension does behind those seams is its own business. +A **server extension** packages protocol behaviour outside the core specification — an MCP extension such as `io.modelcontextprotocol/tasks`, or a vendor feature — as one object you pass to the server. The SDK advertises it, installs it, and gives it two hooks: custom methods and overrides of spec methods. What the extension does behind those hooks is its own business. ## Write an extension @@ -58,6 +58,10 @@ const gateClient: ClientExtension = { id: GATE, install(client) { client.setRequestHandler('gate/ping', { params: z.looseObject({}) }, () => ({ pong: true })); + // Results of tools/call may carry the extension's own kind; the + // explicit-schema request() path then hands them to the caller's + // schema as-is instead of rejecting the unknown resultType. + client.acceptResultType('tools/call', 'gate'); } }; @@ -75,4 +79,5 @@ A thrown `ProtocolError` becomes the JSON-RPC error response. Inside a tool hand - `ServerExtension` is `{ id, capability?, install(server) }`; pass it in `ServerOptions.extensions`. `ClientExtension` mirrors it on `ClientOptions.extensions`. - `install` gets the low-level `Server`: `setRequestHandler` for custom methods, `overrideRequestHandler` to intercept spec methods. - Overrides compose at dispatch time and apply to handlers registered later. -- The SDK owns the seams and the capability advertisement, not the extension's state or execution. +- `acceptResultType(method, resultType)` lets a client extension receive a result kind outside `complete` / `input_required` through the explicit-schema `request()` path. +- The SDK owns the hooks and the capability advertisement, not the extension's state or execution. diff --git a/packages/client/src/client/extension.ts b/packages/client/src/client/extension.ts index 58793f3889..f12158f0cf 100644 --- a/packages/client/src/client/extension.ts +++ b/packages/client/src/client/extension.ts @@ -14,7 +14,7 @@ import type { Client } from './client'; * calls `install`, which is where the extension registers handlers for * server-to-client requests and notifications, or overrides the ones the * SDK installs (`client.overrideRequestHandler('elicitation/create', …)`). - * The SDK provides the seams; what an extension does behind them is its own. + * The SDK provides the hooks; what an extension does behind them is its own. */ export interface ClientExtension { /** diff --git a/packages/client/test/client/extensions.test.ts b/packages/client/test/client/extensions.test.ts index 309957779e..b092e6bf79 100644 --- a/packages/client/test/client/extensions.test.ts +++ b/packages/client/test/client/extensions.test.ts @@ -25,6 +25,7 @@ function gateExtension(log: string[]): ClientExtension { install(client) { log.push('installed'); client.setRequestHandler('gate/ping', { params: z.looseObject({}) }, () => ({ pong: true })); + client.acceptResultType('tools/call', 'task'); } }; } @@ -62,6 +63,12 @@ async function scriptedServer(era: 'modern' | 'legacy') { serverInfo: { name: 'scripted', version: '1.0.0' } } }); + } else if (request.method === 'tools/call') { + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + result: { resultType: 'task', taskId: 't-1', status: 'working', createdAt: 'now', lastUpdatedAt: 'now', ttlMs: null } + }); } else if (request.method === 'tools/list') { void serverTx.send({ jsonrpc: '2.0', @@ -113,6 +120,18 @@ describe('ClientOptions.extensions', () => { await client.close(); }); + it('receives an extension result kind the extension accepted, discriminator included', async () => { + const { clientTx } = await scriptedServer('modern'); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' }, extensions: [gateExtension([])] }); + await client.connect(clientTx); + const taskSchema = z.looseObject({ resultType: z.literal('task'), taskId: z.string(), status: z.string() }); + const result = await client.request({ method: 'tools/call', params: { name: 'slow', arguments: {} } }, taskSchema); + expect(result).toMatchObject({ resultType: 'task', taskId: 't-1', status: 'working' }); + // callTool validates against CallToolResultSchema, which a task handle does not satisfy. + await expect(client.callTool({ name: 'slow', arguments: {} })).rejects.toThrow(/Invalid result for tools\/call/); + await client.close(); + }); + it('serves the handler the extension installed for a server-to-client request', async () => { const { clientTx, serverTx, written } = await scriptedServer('legacy'); const client = new Client({ name: 'c', version: '1' }, { extensions: [gateExtension([])] }); diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 15149bf1ec..c0f811dede 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -570,6 +570,8 @@ export abstract class Protocol { ) => Result | Promise > > = new Map(); + /** Extension result kinds accepted per method (see `acceptResultType`). */ + private _acceptedResultTypes: Map> = new Map(); private _requestHandlerAbortControllers: Map = new Map(); private _notificationHandlers: Map Promise> = new Map(); private _responseHandlers: Map void> = new Map(); @@ -1525,6 +1527,21 @@ export abstract class Protocol { // `_onresponse`, so a throw out of the decode hop would // otherwise propagate into the transport's onmessage instead // of failing this request. + // Extension result kinds (see `acceptResultType`): a raw + // `resultType` the caller declared for this method bypasses + // the codec's closed vocabulary and reaches the caller's + // schema as-is, discriminator included. + if (this._isAcceptedResultType(request.method, response.result)) { + validateStandardSchema(resultSchema, response.result).then(parseResult => { + if (parseResult.success) { + resolve(parseResult.data); + } else { + reject(new SdkError(SdkErrorCode.InvalidResult, `Invalid result for ${request.method}: ${parseResult.error}`)); + } + }, reject); + return; + } + let decoded: ReturnType; try { decoded = codec.decodeResult(request.method, response.result); @@ -1775,7 +1792,7 @@ export abstract class Protocol { * `McpServer` registers on the first tool registration) still applies; * with no underlying handler and no fallback, `next` throws * `MethodNotFound`. Later overrides run outside earlier ones. This is the - * seam server extensions use to intercept spec methods. + * hook server extensions use to intercept spec methods. * * @returns A function that removes the override. */ @@ -1799,6 +1816,37 @@ export abstract class Protocol { }; } + /** + * Declares that results of `method` may carry `resultType` — a kind an + * extension defines beyond the spec's `complete` / `input_required` + * vocabulary (the Tasks extension answers `tools/call` with + * `resultType: "task"`). A raw response with that discriminator skips the + * era codec's decode and is validated against the caller's explicit + * result schema as-is, discriminator included. Only the explicit-schema + * `request(request, resultSchema)` path consults this; typed spec calls + * keep the closed vocabulary. This is the hook client extensions use to + * receive extension result shapes. + * + * @returns A function that withdraws the declaration. + */ + acceptResultType(method: string, resultType: string): () => void { + const accepted = this._acceptedResultTypes.get(method) ?? new Set(); + accepted.add(resultType); + this._acceptedResultTypes.set(method, accepted); + return () => { + const current = this._acceptedResultTypes.get(method); + current?.delete(resultType); + if (current?.size === 0) this._acceptedResultTypes.delete(method); + }; + } + + private _isAcceptedResultType(method: string, raw: unknown): boolean { + const accepted = this._acceptedResultTypes.get(method); + if (accepted === undefined || !isPlainObject(raw)) return false; + const resultType = raw['resultType']; + return typeof resultType === 'string' && accepted.has(resultType); + } + /** * The handler `_onrequest` dispatches to for `method`: the registered * handler (or the fallback), with any overrides composed around it. diff --git a/packages/server/src/server/extension.ts b/packages/server/src/server/extension.ts index b7bfb5061c..b906357948 100644 --- a/packages/server/src/server/extension.ts +++ b/packages/server/src/server/extension.ts @@ -14,7 +14,7 @@ import type { Server } from './server'; * (`server.setRequestHandler(method, { params, result }, handler)`), * overrides spec methods it needs to intercept * (`server.overrideRequestHandler('tools/call', …)`), and adds notification - * handlers. The SDK provides the seams; what an extension does behind them + * handlers. The SDK provides the hooks; what an extension does behind them * — how it stores state, where its work runs — is the extension's own. */ export interface ServerExtension { From 64a1c4a4999254f6e342640128c48ef5a1346935 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 17 Sep 2026 16:09:09 +0200 Subject: [PATCH 6/9] =?UTF-8?q?refactor(core-internal):=20overrideRequestH?= =?UTF-8?q?andler=20is=20use()=20=E2=80=94=20request=20middleware=20in=20r?= =?UTF-8?q?egistration=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setRequestHandler is the route handler; use(method, middleware) is the middleware around it, Koa-shaped. Middleware now composes in registration order (first installed outermost), matching Hono and Koa. No wildcard. --- .changeset/server-extensions.md | 2 +- docs/advanced/extensions.md | 26 ++++---- packages/client/src/client/extension.ts | 6 +- packages/core-internal/src/shared/protocol.ts | 61 +++++++++++-------- ...dler.test.ts => requestMiddleware.test.ts} | 46 +++++++------- packages/server/src/server/extension.ts | 6 +- .../server/test/server/extensions.test.ts | 6 +- 7 files changed, 81 insertions(+), 72 deletions(-) rename packages/core-internal/test/shared/{overrideRequestHandler.test.ts => requestMiddleware.test.ts} (71%) diff --git a/.changeset/server-extensions.md b/.changeset/server-extensions.md index 762b29f529..b8c9ebdff4 100644 --- a/.changeset/server-extensions.md +++ b/.changeset/server-extensions.md @@ -4,6 +4,6 @@ '@modelcontextprotocol/server': minor --- -Server and client extensions. `ServerOptions.extensions` takes `ServerExtension` objects (`{ id, capability?, install(server) }`): each is advertised under `capabilities.extensions[id]` and installed at construction. Extensions register custom methods with `setRequestHandler(method, { params, result }, handler)` and intercept spec methods with the new `Protocol.overrideRequestHandler(method, (request, ctx, next) => …)`, which composes around the registered handler at dispatch time (so an override on `tools/call` applies even though `McpServer` registers that handler lazily) and returns a remover. `ClientOptions.extensions` takes the symmetric `ClientExtension` objects, advertised under the client's `capabilities.extensions[id]` (in `initialize` on a legacy connection, in every request's client-capabilities envelope on 2026-07-28) and installed with the `Client`. `Protocol.acceptResultType(method, resultType)` declares an extension result kind for a method: a raw response carrying that `resultType` bypasses the era codec's closed vocabulary and is validated against the caller's explicit result schema as-is, which is how a client extension receives shapes such as the Tasks extension's `resultType: "task"` on `tools/call`. +Server and client extensions. `ServerOptions.extensions` takes `ServerExtension` objects (`{ id, install(server) }`): each is advertised under `capabilities.extensions[id]` and installed at construction; everything else an extension does, settings included, happens in `install`. Extensions register custom methods with `setRequestHandler(method, { params, result }, handler)` and intercept spec methods with the new `Protocol.use(method, (request, ctx, next) => …)` — middleware around the registered handler, composed at dispatch time in registration order (so middleware on `tools/call` applies even though `McpServer` registers that handler lazily) — which returns a remover. `ClientOptions.extensions` takes the symmetric `ClientExtension` objects, advertised under the client's `capabilities.extensions[id]` (in `initialize` on a legacy connection, in every request's client-capabilities envelope on 2026-07-28) and installed with the `Client`. `Protocol.acceptResultType(method, resultType)` declares an extension result kind for a method: a raw response carrying that `resultType` bypasses the era codec's closed vocabulary and is validated against the caller's explicit result schema as-is, which is how a client extension receives shapes such as the Tasks extension's `resultType: "task"` on `tools/call`. `McpServer` tool dispatch now re-throws `MissingRequiredClientCapabilityError` (`-32021`) as a JSON-RPC error instead of converting it into an `isError` tool result, matching the existing `UrlElicitationRequiredError` passthrough. diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index 6a034fd214..9ee1243560 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -4,11 +4,11 @@ shape: how-to # Server extensions -A **server extension** packages protocol behaviour outside the core specification — an MCP extension such as `io.modelcontextprotocol/tasks`, or a vendor feature — as one object you pass to the server. The SDK advertises it, installs it, and gives it two hooks: custom methods and overrides of spec methods. What the extension does behind those hooks is its own business. +A **server extension** packages protocol behaviour outside the core specification — an MCP extension such as `io.modelcontextprotocol/tasks`, or a vendor feature — as one object you pass to the server. The SDK advertises it, installs it, and gives it two hooks: custom methods and middleware on spec methods. What the extension does behind those hooks is its own business. ## Write an extension -An extension is an `id`, an optional settings object, and an `install` function that receives the low-level `Server`. +An extension is an `id` and an `install` function that receives the low-level `Server`. Everything it does to the protocol happens in `install`. ```ts import type { ServerExtension } from '@modelcontextprotocol/server'; @@ -19,14 +19,16 @@ const GATE = 'com.example/gate'; export const gate: ServerExtension = { id: GATE, - capability: { exampleData: true }, install(server) { + // Settings for the advertised capability, if the extension has any. + server.registerCapabilities({ extensions: { [GATE]: { exampleData: true } } }); + // A custom method, exactly as in Custom methods. server.setRequestHandler('gate/status', { params: z.looseObject({}) }, () => ({ armed: true })); - // An override of a spec method: runs before the registered handler, + // Middleware on a spec method: runs around the registered handler, // may answer, transform, or refuse. - server.overrideRequestHandler('tools/call', (request, ctx, next) => { + server.use('tools/call', (request, ctx, next) => { const declared = ctx.mcpReq.envelope?.[CLIENT_CAPABILITIES_META_KEY]?.extensions ?? {}; if (!(GATE in declared)) { throw new MissingRequiredClientCapabilityError({ requiredCapabilities: { extensions: { [GATE]: {} } } }, 'declare the gate'); @@ -39,7 +41,7 @@ export const gate: ServerExtension = { ## Install it -Pass extensions at construction. Each is advertised under `capabilities.extensions[id]` — legacy connections see it in the `initialize` result, 2026-07-28 connections in `server/discover` — and installed in order after the built-in handlers exist. +Pass extensions at construction. Each is advertised under `capabilities.extensions[id]` as `{}` — legacy connections see it in the `initialize` result, 2026-07-28 connections in `server/discover` — and then installed in order, after the built-in handlers exist. An extension with settings registers them in `install`, as above; the maps merge. ```ts const server = new McpServer({ name: 'gated', version: '1.0.0' }, { extensions: [gate] }); @@ -49,7 +51,7 @@ The same option exists on the low-level `Server`. ## Client extensions -The client half is symmetric: `ClientExtension` is `{ id, capability?, install(client) }`, passed in `ClientOptions.extensions`. The client advertises it under its own `capabilities.extensions[id]` — in `initialize` on a legacy connection, and in every request's `_meta` client-capabilities envelope on a 2026-07-28 connection, which is where a server extension reads it — and `install` receives the `Client` to register handlers for server-to-client requests and notifications, or override the ones the SDK installs. +The client half is symmetric: `ClientExtension` is `{ id, install(client) }`, passed in `ClientOptions.extensions`. The client advertises it under its own `capabilities.extensions[id]` — in `initialize` on a legacy connection, and in every request's `_meta` client-capabilities envelope on a 2026-07-28 connection, which is where a server extension reads it — and `install` receives the `Client` to register handlers for server-to-client requests and notifications, or wrap the ones the SDK installs with middleware. ```ts import type { ClientExtension } from '@modelcontextprotocol/client'; @@ -68,16 +70,16 @@ const gateClient: ClientExtension = { const client = new Client({ name: 'gated-client', version: '1.0.0' }, { extensions: [gateClient] }); ``` -## How overrides compose +## How middleware composes -`overrideRequestHandler(method, override)` wraps whatever handler serves `method` at dispatch time. That matters for `tools/call`, which `McpServer` registers on the first tool registration: an override installed at construction still applies. With no underlying handler, `next` throws `MethodNotFound`. Several overrides nest, the latest outermost. The returned function removes the override. +`setRequestHandler` is the route handler; `use(method, middleware)` is the middleware around it, Koa-shaped: `await next(request, ctx)` yields the result and the middleware returns what goes on the wire. It wraps whatever handler serves `method` at dispatch time. That matters for `tools/call`, which `McpServer` registers on the first tool registration: middleware installed at construction still applies. With no underlying handler, `next` throws `MethodNotFound`. Several middleware nest in registration order, the first installed outermost. The returned function removes the middleware. Method names are exact; there is no wildcard. A thrown `ProtocolError` becomes the JSON-RPC error response. Inside a tool handler, `McpServer` converts most throws into an `isError` tool result; the exceptions are protocol-level errors the client must see as errors — `UrlElicitationRequiredError` and `MissingRequiredClientCapabilityError` (`-32021`). ## Recap -- `ServerExtension` is `{ id, capability?, install(server) }`; pass it in `ServerOptions.extensions`. `ClientExtension` mirrors it on `ClientOptions.extensions`. -- `install` gets the low-level `Server`: `setRequestHandler` for custom methods, `overrideRequestHandler` to intercept spec methods. -- Overrides compose at dispatch time and apply to handlers registered later. +- `ServerExtension` is `{ id, install(server) }`; pass it in `ServerOptions.extensions`. `ClientExtension` mirrors it on `ClientOptions.extensions`. +- `install` gets the low-level `Server`: `setRequestHandler` for custom methods, `use` for middleware on spec methods, `registerCapabilities` for the extension's settings. +- Middleware composes at dispatch time, in registration order, and applies to handlers registered later. - `acceptResultType(method, resultType)` lets a client extension receive a result kind outside `complete` / `input_required` through the explicit-schema `request()` path. - The SDK owns the hooks and the capability advertisement, not the extension's state or execution. diff --git a/packages/client/src/client/extension.ts b/packages/client/src/client/extension.ts index f12158f0cf..e8570198fc 100644 --- a/packages/client/src/client/extension.ts +++ b/packages/client/src/client/extension.ts @@ -12,8 +12,8 @@ import type { Client } from './client'; * (in `initialize` on a legacy connection, in every request's * `_meta` client-capabilities envelope on a 2026-07-28 connection) and then * calls `install`, which is where the extension registers handlers for - * server-to-client requests and notifications, or overrides the ones the - * SDK installs (`client.overrideRequestHandler('elicitation/create', …)`). + * server-to-client requests and notifications, or wraps the ones the SDK + * installs with middleware (`client.use('elicitation/create', …)`). * The SDK provides the hooks; what an extension does behind them is its own. */ export interface ClientExtension { @@ -29,6 +29,6 @@ export interface ClientExtension { * no settings. */ readonly capability?: JSONObject; - /** Installs the extension's handlers and overrides onto the client. Called once, at construction. */ + /** Installs the extension's handlers and middleware onto the client. Called once, at construction. */ install(client: Client): void; } diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index c0f811dede..f9ca4ce689 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -559,8 +559,8 @@ export abstract class Protocol { private _transport?: Transport; private _requestMessageId = 0; private _requestHandlers: Map Promise> = new Map(); - /** Overrides installed by `overrideRequestHandler`, in installation order (see `_resolveRequestHandler`). */ - private _requestHandlerOverrides: Map< + /** Middleware installed by `use`, in registration order (see `_resolveRequestHandler`). */ + private _requestMiddleware: Map< string, Array< ( @@ -1783,36 +1783,40 @@ export abstract class Protocol { } /** - * Installs an override around the request handler for `method`. The - * override receives the parsed request, the context, and `next` — the - * handler it wraps — and may answer itself, transform what `next` - * returns, or throw a `ProtocolError` that becomes the JSON-RPC error - * response. Overrides compose at dispatch time, so an override installed - * before the underlying handler exists (e.g. `tools/call`, which - * `McpServer` registers on the first tool registration) still applies; - * with no underlying handler and no fallback, `next` throws - * `MethodNotFound`. Later overrides run outside earlier ones. This is the - * hook server extensions use to intercept spec methods. + * Installs middleware on the request handler for `method`. The middleware + * receives the parsed request, the context, and `next` — the handler it + * wraps — and may answer itself, transform what `next` returns, or throw + * a `ProtocolError` that becomes the JSON-RPC error response. + * `setRequestHandler` is the route handler; this is the middleware + * around it, Koa-shaped: `await next(request, ctx)` yields the result and + * the middleware returns what goes on the wire. * - * @returns A function that removes the override. + * Middleware composes at dispatch time, so middleware installed before + * the underlying handler exists (e.g. `tools/call`, which `McpServer` + * registers on the first tool registration) still applies; with no + * underlying handler and no fallback, `next` throws `MethodNotFound`. + * Middleware runs in registration order: the first installed is the + * outermost. This is the hook extensions use to intercept spec methods. + * + * @returns A function that removes the middleware. */ - overrideRequestHandler( + use( method: RequestMethod | string, - override: ( + middleware: ( request: JSONRPCRequest, ctx: ContextT, next: (request: JSONRPCRequest, ctx: ContextT) => Promise ) => Result | Promise ): () => void { - const overrides = this._requestHandlerOverrides.get(method) ?? []; - overrides.push(override); - this._requestHandlerOverrides.set(method, overrides); + const stack = this._requestMiddleware.get(method) ?? []; + stack.push(middleware); + this._requestMiddleware.set(method, stack); return () => { - const current = this._requestHandlerOverrides.get(method); + const current = this._requestMiddleware.get(method); if (current === undefined) return; - const index = current.indexOf(override); + const index = current.indexOf(middleware); if (index !== -1) current.splice(index, 1); - if (current.length === 0) this._requestHandlerOverrides.delete(method); + if (current.length === 0) this._requestMiddleware.delete(method); }; } @@ -1849,21 +1853,24 @@ export abstract class Protocol { /** * The handler `_onrequest` dispatches to for `method`: the registered - * handler (or the fallback), with any overrides composed around it. - * `undefined` when nothing is registered and nothing overrides. + * handler (or the fallback), with any middleware composed around it in + * registration order (first registered outermost). `undefined` when + * nothing is registered and no middleware is installed. */ private _resolveRequestHandler(method: string): ((request: JSONRPCRequest, ctx: ContextT) => Promise) | undefined { const base = this._requestHandlers.get(method) ?? this.fallbackRequestHandler; - const overrides = this._requestHandlerOverrides.get(method); - if (overrides === undefined || overrides.length === 0) return base; + const stack = this._requestMiddleware.get(method); + if (stack === undefined || stack.length === 0) return base; let composed: (request: JSONRPCRequest, ctx: ContextT) => Promise = base ?? (async () => { throw new ProtocolError(ProtocolErrorCode.MethodNotFound, 'Method not found'); }); - for (const override of overrides) { + // Wrap from the innermost (last registered) outwards so the first + // registered middleware ends up outermost. + for (const middleware of stack.toReversed()) { const next = composed; - composed = async (request, ctx) => override(request, ctx, next); + composed = async (request, ctx) => middleware(request, ctx, next); } return composed; } diff --git a/packages/core-internal/test/shared/overrideRequestHandler.test.ts b/packages/core-internal/test/shared/requestMiddleware.test.ts similarity index 71% rename from packages/core-internal/test/shared/overrideRequestHandler.test.ts rename to packages/core-internal/test/shared/requestMiddleware.test.ts index dc7101c4f2..08bf9dca9b 100644 --- a/packages/core-internal/test/shared/overrideRequestHandler.test.ts +++ b/packages/core-internal/test/shared/requestMiddleware.test.ts @@ -1,7 +1,7 @@ /** - * `Protocol.overrideRequestHandler`: overrides compose around the registered - * handler at dispatch time, may answer or throw themselves, apply to handlers - * registered later, and can be removed. + * `Protocol.use`: middleware composes around the registered handler at + * dispatch time, may answer or throw itself, applies to handlers registered + * later, runs in registration order, and can be removed. */ import { describe, expect, it } from 'vitest'; import * as z from 'zod/v4'; @@ -41,11 +41,11 @@ async function harness() { return { protocol, call }; } -describe('Protocol.overrideRequestHandler', () => { - it('wraps the registered handler; next reaches it and the override may transform the result', async () => { +describe('Protocol.use (request middleware)', () => { + it('wraps the registered handler; next reaches it and the middleware may transform the result', async () => { const { protocol, call } = await harness(); protocol.setRequestHandler('acme/op', { params: z.looseObject({}) }, () => ({ value: 1 }) as Result); - protocol.overrideRequestHandler('acme/op', async (request, ctx, next) => { + protocol.use('acme/op', async (request, ctx, next) => { const result = (await next(request, ctx)) as { value: number }; return { value: result.value + 1, wrapped: true } as Result; }); @@ -60,27 +60,27 @@ describe('Protocol.overrideRequestHandler', () => { handlerRan = true; return {} as Result; }); - protocol.overrideRequestHandler('acme/op', (request, ctx, next) => { + protocol.use('acme/op', (request, ctx, next) => { if ((request.params as { deny?: boolean }).deny) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'denied by override'); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'denied by middleware'); } return next(request, ctx); }); const denied = await call('acme/op', { deny: true }); expect((denied as JSONRPCErrorResponse).error).toMatchObject({ code: ProtocolErrorCode.InvalidParams, - message: 'denied by override' + message: 'denied by middleware' }); expect(handlerRan).toBe(false); await call('acme/op'); expect(handlerRan).toBe(true); }); - it('applies to a handler registered after the override was installed', async () => { + it('applies to a handler registered after the middleware was installed', async () => { const { protocol, call } = await harness(); const order: string[] = []; - protocol.overrideRequestHandler('acme/late', async (request, ctx, next) => { - order.push('override'); + protocol.use('acme/late', async (request, ctx, next) => { + order.push('middleware'); return next(request, ctx); }); protocol.setRequestHandler('acme/late', { params: z.looseObject({}) }, () => { @@ -88,36 +88,36 @@ describe('Protocol.overrideRequestHandler', () => { return {} as Result; }); await call('acme/late'); - expect(order).toEqual(['override', 'handler']); + expect(order).toEqual(['middleware', 'handler']); }); - it('next throws MethodNotFound when nothing underlies the override', async () => { + it('next throws MethodNotFound when nothing underlies the middleware', async () => { const { protocol, call } = await harness(); - protocol.overrideRequestHandler('acme/missing', (request, ctx, next) => next(request, ctx)); + protocol.use('acme/missing', (request, ctx, next) => next(request, ctx)); const response = await call('acme/missing'); expect((response as JSONRPCErrorResponse).error).toMatchObject({ code: ProtocolErrorCode.MethodNotFound }); }); - it('later overrides run outside earlier ones, and removal restores the handler', async () => { + it('runs in registration order (first registered outermost), and removal restores the handler', async () => { const { protocol, call } = await harness(); const order: string[] = []; protocol.setRequestHandler('acme/op', { params: z.looseObject({}) }, () => { order.push('handler'); return {} as Result; }); - const removeInner = protocol.overrideRequestHandler('acme/op', async (request, ctx, next) => { - order.push('inner'); + const removeFirst = protocol.use('acme/op', async (request, ctx, next) => { + order.push('first'); return next(request, ctx); }); - protocol.overrideRequestHandler('acme/op', async (request, ctx, next) => { - order.push('outer'); + protocol.use('acme/op', async (request, ctx, next) => { + order.push('second'); return next(request, ctx); }); await call('acme/op'); - expect(order).toEqual(['outer', 'inner', 'handler']); + expect(order).toEqual(['first', 'second', 'handler']); order.length = 0; - removeInner(); + removeFirst(); await call('acme/op'); - expect(order).toEqual(['outer', 'handler']); + expect(order).toEqual(['second', 'handler']); }); }); diff --git a/packages/server/src/server/extension.ts b/packages/server/src/server/extension.ts index b906357948..9b4a2b41e0 100644 --- a/packages/server/src/server/extension.ts +++ b/packages/server/src/server/extension.ts @@ -12,8 +12,8 @@ import type { Server } from './server'; * extension under `capabilities.extensions[id]` and then calls `install`, * which is where the extension registers its custom methods * (`server.setRequestHandler(method, { params, result }, handler)`), - * overrides spec methods it needs to intercept - * (`server.overrideRequestHandler('tools/call', …)`), and adds notification + * installs middleware on spec methods it needs to intercept + * (`server.use('tools/call', …)`), and adds notification * handlers. The SDK provides the hooks; what an extension does behind them * — how it stores state, where its work runs — is the extension's own. */ @@ -30,6 +30,6 @@ export interface ServerExtension { * no settings. */ readonly capability?: JSONObject; - /** Installs the extension's handlers and overrides onto the server. Called once, at construction. */ + /** Installs the extension's handlers and middleware onto the server. Called once, at construction. */ install(server: Server): void; } diff --git a/packages/server/test/server/extensions.test.ts b/packages/server/test/server/extensions.test.ts index 8ec100c88a..e6d096aea9 100644 --- a/packages/server/test/server/extensions.test.ts +++ b/packages/server/test/server/extensions.test.ts @@ -1,7 +1,7 @@ /** * `ServerOptions.extensions`: an extension is advertised under * `capabilities.extensions[id]` and installed at construction, where it can - * register custom methods and override spec methods such as `tools/call`. + * register custom methods and install middleware on spec methods such as `tools/call`. */ import type { JSONRPCRequest, MessageClassification } from '@modelcontextprotocol/core-internal'; import { @@ -56,7 +56,7 @@ function gateExtension(log: string[]): ServerExtension { install(server) { log.push('installed'); server.setRequestHandler('gate/status', { params: z.looseObject({}) }, () => ({ armed: true })); - server.overrideRequestHandler('tools/call', (request, ctx, next) => { + server.use('tools/call', (request, ctx, next) => { const envelope = ctx.mcpReq.envelope as Record> | undefined; const extensions = envelope?.[CLIENT_CAPABILITIES_META_KEY]?.['extensions'] as Record | undefined; if (extensions === undefined || !(EXT_ID in extensions)) { @@ -91,7 +91,7 @@ describe('ServerOptions.extensions', () => { expect(body['result']).toMatchObject({ armed: true }); }); - it('overrides tools/call registered later by McpServer: refuses without the capability, passes through with it', async () => { + it('middleware on tools/call registered later by McpServer: refuses without the capability, passes through with it', async () => { const mcp = new McpServer({ name: 's', version: '1' }, { extensions: [gateExtension([])] }); mcp.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ content: [{ type: 'text', text }] From bbbbf664730040d0f8b95b96e5641e72b0129667 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 17 Sep 2026 16:11:02 +0200 Subject: [PATCH 7/9] =?UTF-8?q?refactor(core-internal):=20overrideRequestH?= =?UTF-8?q?andler=20is=20use()=20=E2=80=94=20request=20middleware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setRequestHandler is the route handler; use(method, middleware) is the middleware around it, Koa-shaped. Middleware composes in one registration order across methods (first installed outermost), matching Hono and Koa, and use(middleware) / use('*', middleware) runs on every request so an extension can stamp its own _meta key on every result. --- .changeset/server-extensions.md | 2 +- docs/advanced/extensions.md | 11 ++- packages/core-internal/src/shared/protocol.ts | 74 +++++++++++-------- .../test/shared/requestMiddleware.test.ts | 34 +++++++++ 4 files changed, 87 insertions(+), 34 deletions(-) diff --git a/.changeset/server-extensions.md b/.changeset/server-extensions.md index b8c9ebdff4..d6556d0f70 100644 --- a/.changeset/server-extensions.md +++ b/.changeset/server-extensions.md @@ -4,6 +4,6 @@ '@modelcontextprotocol/server': minor --- -Server and client extensions. `ServerOptions.extensions` takes `ServerExtension` objects (`{ id, install(server) }`): each is advertised under `capabilities.extensions[id]` and installed at construction; everything else an extension does, settings included, happens in `install`. Extensions register custom methods with `setRequestHandler(method, { params, result }, handler)` and intercept spec methods with the new `Protocol.use(method, (request, ctx, next) => …)` — middleware around the registered handler, composed at dispatch time in registration order (so middleware on `tools/call` applies even though `McpServer` registers that handler lazily) — which returns a remover. `ClientOptions.extensions` takes the symmetric `ClientExtension` objects, advertised under the client's `capabilities.extensions[id]` (in `initialize` on a legacy connection, in every request's client-capabilities envelope on 2026-07-28) and installed with the `Client`. `Protocol.acceptResultType(method, resultType)` declares an extension result kind for a method: a raw response carrying that `resultType` bypasses the era codec's closed vocabulary and is validated against the caller's explicit result schema as-is, which is how a client extension receives shapes such as the Tasks extension's `resultType: "task"` on `tools/call`. +Server and client extensions. `ServerOptions.extensions` takes `ServerExtension` objects (`{ id, install(server) }`): each is advertised under `capabilities.extensions[id]` and installed at construction; everything else an extension does, settings included, happens in `install`. Extensions register custom methods with `setRequestHandler(method, { params, result }, handler)` and intercept spec methods with the new `Protocol.use(method, (request, ctx, next) => …)` — middleware around the registered handler, composed at dispatch time in registration order (so middleware on `tools/call` applies even though `McpServer` registers that handler lazily); `use(middleware)` or `use('*', middleware)` runs on every request. Both return a remover. `ClientOptions.extensions` takes the symmetric `ClientExtension` objects, advertised under the client's `capabilities.extensions[id]` (in `initialize` on a legacy connection, in every request's client-capabilities envelope on 2026-07-28) and installed with the `Client`. `Protocol.acceptResultType(method, resultType)` declares an extension result kind for a method: a raw response carrying that `resultType` bypasses the era codec's closed vocabulary and is validated against the caller's explicit result schema as-is, which is how a client extension receives shapes such as the Tasks extension's `resultType: "task"` on `tools/call`. `McpServer` tool dispatch now re-throws `MissingRequiredClientCapabilityError` (`-32021`) as a JSON-RPC error instead of converting it into an `isError` tool result, matching the existing `UrlElicitationRequiredError` passthrough. diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index 9ee1243560..57ab409edf 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -72,7 +72,16 @@ const client = new Client({ name: 'gated-client', version: '1.0.0' }, { extensio ## How middleware composes -`setRequestHandler` is the route handler; `use(method, middleware)` is the middleware around it, Koa-shaped: `await next(request, ctx)` yields the result and the middleware returns what goes on the wire. It wraps whatever handler serves `method` at dispatch time. That matters for `tools/call`, which `McpServer` registers on the first tool registration: middleware installed at construction still applies. With no underlying handler, `next` throws `MethodNotFound`. Several middleware nest in registration order, the first installed outermost. The returned function removes the middleware. Method names are exact; there is no wildcard. +`setRequestHandler` is the route handler; `use(method, middleware)` is the middleware around it, Koa-shaped: `await next(request, ctx)` yields the result and the middleware returns what goes on the wire. It wraps whatever handler serves `method` at dispatch time. That matters for `tools/call`, which `McpServer` registers on the first tool registration: middleware installed at construction still applies. With no underlying handler, `next` throws `MethodNotFound`. Several middleware nest in registration order, the first installed outermost. The returned function removes the middleware. `use(middleware)` — or `use('*', middleware)` — installs it on every request, in that same order, which is how an extension stamps a `_meta` key on every result: + +```ts +server.use(async (request, ctx, next) => { + const result = await next(request, ctx); + return { ...result, _meta: { ...result._meta, 'com.example/gate': { armed: true } } }; +}); +``` + +The encoder still stamps the SDK's reserved `_meta` keys and `resultType` on top; an extension adds its own namespaced keys, it does not replace those. A thrown `ProtocolError` becomes the JSON-RPC error response. Inside a tool handler, `McpServer` converts most throws into an `isError` tool result; the exceptions are protocol-level errors the client must see as errors — `UrlElicitationRequiredError` and `MissingRequiredClientCapabilityError` (`-32021`). diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index f9ca4ce689..e42272f89d 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -198,6 +198,17 @@ const RESERVED_ENVELOPE_META_KEYS: readonly string[] = [ */ const RETRY_PARAMS_KEYS = ['inputResponses', 'requestState'] as const; +/** + * Request middleware (see `Protocol.use`): receives the parsed request, the + * context, and `next` — the handler it wraps — and returns the result that + * goes on the wire. + */ +export type RequestMiddleware = ( + request: JSONRPCRequest, + ctx: ContextT, + next: (request: JSONRPCRequest, ctx: ContextT) => Promise +) => Result | Promise; + /** * Lift wire-only material out of an inbound message so handlers see exactly * the 2025-era shape, and surface it for the protocol layer (requests: via @@ -559,17 +570,12 @@ export abstract class Protocol { private _transport?: Transport; private _requestMessageId = 0; private _requestHandlers: Map Promise> = new Map(); - /** Middleware installed by `use`, in registration order (see `_resolveRequestHandler`). */ - private _requestMiddleware: Map< - string, - Array< - ( - request: JSONRPCRequest, - ctx: ContextT, - next: (request: JSONRPCRequest, ctx: ContextT) => Promise - ) => Result | Promise - > - > = new Map(); + /** + * Middleware installed by `use`, in registration order across every + * method (see `_resolveRequestHandler`). `method` is a method name or + * `'*'` for every request. + */ + private _requestMiddleware: Array<{ method: string; middleware: RequestMiddleware }> = []; /** Extension result kinds accepted per method (see `acceptResultType`). */ private _acceptedResultTypes: Map> = new Map(); private _requestHandlerAbortControllers: Map = new Map(); @@ -1796,27 +1802,30 @@ export abstract class Protocol { * registers on the first tool registration) still applies; with no * underlying handler and no fallback, `next` throws `MethodNotFound`. * Middleware runs in registration order: the first installed is the - * outermost. This is the hook extensions use to intercept spec methods. + * outermost. `use(middleware)` (or `use('*', middleware)`) installs it on + * every request — e.g. to stamp a `_meta` key on every result — and takes + * its place in the same order as per-method middleware. This is the hook + * extensions use to intercept spec methods. * * @returns A function that removes the middleware. */ + use(middleware: RequestMiddleware): () => void; + use(method: RequestMethod | '*' | string, middleware: RequestMiddleware): () => void; use( - method: RequestMethod | string, - middleware: ( - request: JSONRPCRequest, - ctx: ContextT, - next: (request: JSONRPCRequest, ctx: ContextT) => Promise - ) => Result | Promise + methodOrMiddleware: RequestMethod | '*' | string | RequestMiddleware, + maybeMiddleware?: RequestMiddleware ): () => void { - const stack = this._requestMiddleware.get(method) ?? []; - stack.push(middleware); - this._requestMiddleware.set(method, stack); + const entry = + typeof methodOrMiddleware === 'function' + ? { method: '*', middleware: methodOrMiddleware } + : { method: methodOrMiddleware, middleware: maybeMiddleware as RequestMiddleware }; + if (typeof entry.middleware !== 'function') { + throw new TypeError('use: middleware is required'); + } + this._requestMiddleware.push(entry); return () => { - const current = this._requestMiddleware.get(method); - if (current === undefined) return; - const index = current.indexOf(middleware); - if (index !== -1) current.splice(index, 1); - if (current.length === 0) this._requestMiddleware.delete(method); + const index = this._requestMiddleware.indexOf(entry); + if (index !== -1) this._requestMiddleware.splice(index, 1); }; } @@ -1853,14 +1862,15 @@ export abstract class Protocol { /** * The handler `_onrequest` dispatches to for `method`: the registered - * handler (or the fallback), with any middleware composed around it in - * registration order (first registered outermost). `undefined` when - * nothing is registered and no middleware is installed. + * handler (or the fallback), with any middleware for the method or for + * `'*'` composed around it in registration order (first registered + * outermost). `undefined` when nothing is registered and no middleware + * applies. */ private _resolveRequestHandler(method: string): ((request: JSONRPCRequest, ctx: ContextT) => Promise) | undefined { const base = this._requestHandlers.get(method) ?? this.fallbackRequestHandler; - const stack = this._requestMiddleware.get(method); - if (stack === undefined || stack.length === 0) return base; + const stack = this._requestMiddleware.filter(entry => entry.method === method || entry.method === '*'); + if (stack.length === 0) return base; let composed: (request: JSONRPCRequest, ctx: ContextT) => Promise = base ?? (async () => { @@ -1868,7 +1878,7 @@ export abstract class Protocol { }); // Wrap from the innermost (last registered) outwards so the first // registered middleware ends up outermost. - for (const middleware of stack.toReversed()) { + for (const { middleware } of stack.toReversed()) { const next = composed; composed = async (request, ctx) => middleware(request, ctx, next); } diff --git a/packages/core-internal/test/shared/requestMiddleware.test.ts b/packages/core-internal/test/shared/requestMiddleware.test.ts index 08bf9dca9b..c4d7bef53c 100644 --- a/packages/core-internal/test/shared/requestMiddleware.test.ts +++ b/packages/core-internal/test/shared/requestMiddleware.test.ts @@ -91,6 +91,40 @@ describe('Protocol.use (request middleware)', () => { expect(order).toEqual(['middleware', 'handler']); }); + it("runs '*' middleware on every request, in one registration order with per-method middleware", async () => { + const { protocol, call } = await harness(); + const order: string[] = []; + for (const method of ['acme/a', 'acme/b']) { + protocol.setRequestHandler(method, { params: z.looseObject({}) }, () => { + order.push(`handler:${method}`); + return { method } as Result; + }); + } + protocol.use(async (request, ctx, next) => { + order.push('every'); + const result = await next(request, ctx); + return { ...result, _meta: { 'acme/stamped': true } } as Result; + }); + const remove = protocol.use('acme/a', async (request, ctx, next) => { + order.push('only-a'); + return next(request, ctx); + }); + protocol.use('*', async (request, ctx, next) => { + order.push('every-2'); + return next(request, ctx); + }); + const a = await call('acme/a'); + expect((a as JSONRPCResultResponse).result).toEqual({ method: 'acme/a', _meta: { 'acme/stamped': true } }); + expect(order).toEqual(['every', 'only-a', 'every-2', 'handler:acme/a']); + order.length = 0; + await call('acme/b'); + expect(order).toEqual(['every', 'every-2', 'handler:acme/b']); + remove(); + order.length = 0; + await call('acme/a'); + expect(order).toEqual(['every', 'every-2', 'handler:acme/a']); + }); + it('next throws MethodNotFound when nothing underlies the middleware', async () => { const { protocol, call } = await harness(); protocol.use('acme/missing', (request, ctx, next) => next(request, ctx)); From 1dfb218eeb95c054e2646f90f3e1eef8e549c72a Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 17 Sep 2026 16:15:22 +0200 Subject: [PATCH 8/9] refactor: extensions are { id, install }; settings register inside install The optional capability settings field is gone from ServerExtension and ClientExtension. The constructor advertises {} under the extension id; an extension with settings calls registerCapabilities in install, where everything else it does to the protocol already happens. --- packages/client/src/client/client.ts | 2 +- packages/client/src/client/extension.ts | 12 +++--------- packages/client/test/client/extensions.test.ts | 4 ++-- packages/server/src/server/extension.ts | 11 ++--------- packages/server/src/server/server.ts | 2 +- packages/server/test/server/extensions.test.ts | 6 +++--- 6 files changed, 12 insertions(+), 25 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index bf303b1c1d..bb1b825878 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -668,7 +668,7 @@ export class Client extends Protocol { } for (const extension of options?.extensions ?? []) { - this.registerCapabilities({ extensions: { [extension.id]: extension.capability ?? {} } }); + this.registerCapabilities({ extensions: { [extension.id]: {} } }); extension.install(this); } } diff --git a/packages/client/src/client/extension.ts b/packages/client/src/client/extension.ts index e8570198fc..7e0f4c59b5 100644 --- a/packages/client/src/client/extension.ts +++ b/packages/client/src/client/extension.ts @@ -1,5 +1,3 @@ -import type { JSONObject } from '@modelcontextprotocol/core-internal'; - import type { Client } from './client'; /** @@ -9,6 +7,7 @@ import type { Client } from './client'; * * Pass extensions at construction — `new Client(info, { extensions: [ext] })`. * The client advertises each extension under `capabilities.extensions[id]` + * (as `{}`: supported, no settings) * (in `initialize` on a legacy connection, in every request's * `_meta` client-capabilities envelope on a 2026-07-28 connection) and then * calls `install`, which is where the extension registers handlers for @@ -20,15 +19,10 @@ export interface ClientExtension { /** * The extension identifier, prefix-qualified (`io.modelcontextprotocol/tasks`, * `com.example/feature-flags`). Advertised as the key under - * `capabilities.extensions`. + * `capabilities.extensions`. Settings for that key, if any, are the + * extension's to register in `install` via `registerCapabilities`. */ readonly id: string; - /** - * The extension's settings object, advertised as the value under - * `capabilities.extensions[id]`. `{}` (the default) means supported with - * no settings. - */ - readonly capability?: JSONObject; /** Installs the extension's handlers and middleware onto the client. Called once, at construction. */ install(client: Client): void; } diff --git a/packages/client/test/client/extensions.test.ts b/packages/client/test/client/extensions.test.ts index b092e6bf79..065af3d88c 100644 --- a/packages/client/test/client/extensions.test.ts +++ b/packages/client/test/client/extensions.test.ts @@ -21,9 +21,9 @@ const flush = () => new Promise(resolve => setTimeout(resolve, 20)); function gateExtension(log: string[]): ClientExtension { return { id: EXT_ID, - capability: { exampleData: true }, install(client) { log.push('installed'); + client.registerCapabilities({ extensions: { [EXT_ID]: { exampleData: true } } }); client.setRequestHandler('gate/ping', { params: z.looseObject({}) }, () => ({ pong: true })); client.acceptResultType('tools/call', 'task'); } @@ -90,7 +90,7 @@ describe('ClientOptions.extensions', () => { expect(log).toEqual(['installed']); }); - it('defaults the advertised settings to {}', async () => { + it('advertises {} when install sets no settings', async () => { const { clientTx, written } = await scriptedServer('legacy'); const client = new Client({ name: 'c', version: '1' }, { extensions: [{ id: 'com.example/plain', install: () => {} }] }); await client.connect(clientTx); diff --git a/packages/server/src/server/extension.ts b/packages/server/src/server/extension.ts index 9b4a2b41e0..c190bbb4ad 100644 --- a/packages/server/src/server/extension.ts +++ b/packages/server/src/server/extension.ts @@ -1,5 +1,3 @@ -import type { JSONObject } from '@modelcontextprotocol/core-internal'; - import type { Server } from './server'; /** @@ -21,15 +19,10 @@ export interface ServerExtension { /** * The extension identifier, prefix-qualified (`io.modelcontextprotocol/tasks`, * `com.example/feature-flags`). Advertised as the key under - * `capabilities.extensions`. + * `capabilities.extensions`. Settings for that key, if any, are the + * extension's to register in `install` via `registerCapabilities`. */ readonly id: string; - /** - * The extension's settings object, advertised as the value under - * `capabilities.extensions[id]`. `{}` (the default) means supported with - * no settings. - */ - readonly capability?: JSONObject; /** Installs the extension's handlers and middleware onto the server. Called once, at construction. */ install(server: Server): void; } diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 7188bf103b..49d5dbfc1a 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -360,7 +360,7 @@ export class Server extends Protocol { } for (const extension of options?.extensions ?? []) { - this.registerCapabilities({ extensions: { [extension.id]: extension.capability ?? {} } }); + this.registerCapabilities({ extensions: { [extension.id]: {} } }); extension.install(this); } } diff --git a/packages/server/test/server/extensions.test.ts b/packages/server/test/server/extensions.test.ts index e6d096aea9..9a388d6e73 100644 --- a/packages/server/test/server/extensions.test.ts +++ b/packages/server/test/server/extensions.test.ts @@ -52,9 +52,9 @@ async function exchange(server: Server, request: JSONRPCRequest): Promise ({ armed: true })); server.use('tools/call', (request, ctx, next) => { const envelope = ctx.mcpReq.envelope as Record> | undefined; @@ -72,14 +72,14 @@ function gateExtension(log: string[]): ServerExtension { } describe('ServerOptions.extensions', () => { - it('advertises the extension capability and installs it at construction', () => { + it('advertises the extension and lets install set its settings', () => { const log: string[] = []; const server = new Server({ name: 's', version: '1' }, { extensions: [gateExtension(log)] }); expect(log).toEqual(['installed']); expect(server.getCapabilities().extensions).toEqual({ [EXT_ID]: { exampleData: true } }); }); - it('defaults the advertised settings to {} and passes through McpServer', () => { + it('advertises {} when install sets no settings, and passes through McpServer', () => { const ext: ServerExtension = { id: 'com.example/plain', install: () => {} }; const mcp = new McpServer({ name: 's', version: '1' }, { extensions: [ext] }); expect(mcp.server.getCapabilities().extensions).toEqual({ 'com.example/plain': {} }); From af8889bf247c5ce67a248d74bad6b8a351f6c82d Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 17 Sep 2026 16:16:55 +0200 Subject: [PATCH 9/9] refactor(core-internal): use() takes an exact method name, no wildcard What every peer must know about an extension goes through registerCapabilities, not per-request middleware. --- .changeset/server-extensions.md | 2 +- docs/advanced/extensions.md | 11 +----- packages/core-internal/src/shared/protocol.ts | 37 ++++++------------- .../test/shared/requestMiddleware.test.ts | 22 +++++------ 4 files changed, 23 insertions(+), 49 deletions(-) diff --git a/.changeset/server-extensions.md b/.changeset/server-extensions.md index d6556d0f70..a5c7c939db 100644 --- a/.changeset/server-extensions.md +++ b/.changeset/server-extensions.md @@ -4,6 +4,6 @@ '@modelcontextprotocol/server': minor --- -Server and client extensions. `ServerOptions.extensions` takes `ServerExtension` objects (`{ id, install(server) }`): each is advertised under `capabilities.extensions[id]` and installed at construction; everything else an extension does, settings included, happens in `install`. Extensions register custom methods with `setRequestHandler(method, { params, result }, handler)` and intercept spec methods with the new `Protocol.use(method, (request, ctx, next) => …)` — middleware around the registered handler, composed at dispatch time in registration order (so middleware on `tools/call` applies even though `McpServer` registers that handler lazily); `use(middleware)` or `use('*', middleware)` runs on every request. Both return a remover. `ClientOptions.extensions` takes the symmetric `ClientExtension` objects, advertised under the client's `capabilities.extensions[id]` (in `initialize` on a legacy connection, in every request's client-capabilities envelope on 2026-07-28) and installed with the `Client`. `Protocol.acceptResultType(method, resultType)` declares an extension result kind for a method: a raw response carrying that `resultType` bypasses the era codec's closed vocabulary and is validated against the caller's explicit result schema as-is, which is how a client extension receives shapes such as the Tasks extension's `resultType: "task"` on `tools/call`. +Server and client extensions. `ServerOptions.extensions` takes `ServerExtension` objects (`{ id, install(server) }`): each is advertised under `capabilities.extensions[id]` and installed at construction; everything else an extension does, settings included, happens in `install`. Extensions register custom methods with `setRequestHandler(method, { params, result }, handler)` and intercept spec methods with the new `Protocol.use(method, (request, ctx, next) => …)` — middleware around the registered handler, composed at dispatch time in registration order (so middleware on `tools/call` applies even though `McpServer` registers that handler lazily); it returns a remover. `ClientOptions.extensions` takes the symmetric `ClientExtension` objects, advertised under the client's `capabilities.extensions[id]` (in `initialize` on a legacy connection, in every request's client-capabilities envelope on 2026-07-28) and installed with the `Client`. `Protocol.acceptResultType(method, resultType)` declares an extension result kind for a method: a raw response carrying that `resultType` bypasses the era codec's closed vocabulary and is validated against the caller's explicit result schema as-is, which is how a client extension receives shapes such as the Tasks extension's `resultType: "task"` on `tools/call`. `McpServer` tool dispatch now re-throws `MissingRequiredClientCapabilityError` (`-32021`) as a JSON-RPC error instead of converting it into an `isError` tool result, matching the existing `UrlElicitationRequiredError` passthrough. diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index 57ab409edf..14c4ceb039 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -72,16 +72,7 @@ const client = new Client({ name: 'gated-client', version: '1.0.0' }, { extensio ## How middleware composes -`setRequestHandler` is the route handler; `use(method, middleware)` is the middleware around it, Koa-shaped: `await next(request, ctx)` yields the result and the middleware returns what goes on the wire. It wraps whatever handler serves `method` at dispatch time. That matters for `tools/call`, which `McpServer` registers on the first tool registration: middleware installed at construction still applies. With no underlying handler, `next` throws `MethodNotFound`. Several middleware nest in registration order, the first installed outermost. The returned function removes the middleware. `use(middleware)` — or `use('*', middleware)` — installs it on every request, in that same order, which is how an extension stamps a `_meta` key on every result: - -```ts -server.use(async (request, ctx, next) => { - const result = await next(request, ctx); - return { ...result, _meta: { ...result._meta, 'com.example/gate': { armed: true } } }; -}); -``` - -The encoder still stamps the SDK's reserved `_meta` keys and `resultType` on top; an extension adds its own namespaced keys, it does not replace those. +`setRequestHandler` is the route handler; `use(method, middleware)` is the middleware around it, Koa-shaped: `await next(request, ctx)` yields the result and the middleware returns what goes on the wire. It wraps whatever handler serves `method` at dispatch time. That matters for `tools/call`, which `McpServer` registers on the first tool registration: middleware installed at construction still applies. With no underlying handler, `next` throws `MethodNotFound`. Several middleware nest in registration order, the first installed outermost. The returned function removes the middleware. Method names are exact; there is no wildcard. Something every peer must know about the extension belongs in its advertised capability (`registerCapabilities`), not in per-request `_meta`. Where a result does carry extension `_meta`, the middleware adds its own namespaced key and the encoder still stamps the SDK's reserved keys and `resultType` on top. A thrown `ProtocolError` becomes the JSON-RPC error response. Inside a tool handler, `McpServer` converts most throws into an `isError` tool result; the exceptions are protocol-level errors the client must see as errors — `UrlElicitationRequiredError` and `MissingRequiredClientCapabilityError` (`-32021`). diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index e42272f89d..59d7e7f95d 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -570,11 +570,7 @@ export abstract class Protocol { private _transport?: Transport; private _requestMessageId = 0; private _requestHandlers: Map Promise> = new Map(); - /** - * Middleware installed by `use`, in registration order across every - * method (see `_resolveRequestHandler`). `method` is a method name or - * `'*'` for every request. - */ + /** Middleware installed by `use`, in registration order across every method (see `_resolveRequestHandler`). */ private _requestMiddleware: Array<{ method: string; middleware: RequestMiddleware }> = []; /** Extension result kinds accepted per method (see `acceptResultType`). */ private _acceptedResultTypes: Map> = new Map(); @@ -1802,26 +1798,18 @@ export abstract class Protocol { * registers on the first tool registration) still applies; with no * underlying handler and no fallback, `next` throws `MethodNotFound`. * Middleware runs in registration order: the first installed is the - * outermost. `use(middleware)` (or `use('*', middleware)`) installs it on - * every request — e.g. to stamp a `_meta` key on every result — and takes - * its place in the same order as per-method middleware. This is the hook - * extensions use to intercept spec methods. + * outermost. Method names are exact — there is no wildcard; what an + * extension needs every peer to know goes through `registerCapabilities`, + * not per-request middleware. This is the hook extensions use to + * intercept spec methods. * * @returns A function that removes the middleware. */ - use(middleware: RequestMiddleware): () => void; - use(method: RequestMethod | '*' | string, middleware: RequestMiddleware): () => void; - use( - methodOrMiddleware: RequestMethod | '*' | string | RequestMiddleware, - maybeMiddleware?: RequestMiddleware - ): () => void { - const entry = - typeof methodOrMiddleware === 'function' - ? { method: '*', middleware: methodOrMiddleware } - : { method: methodOrMiddleware, middleware: maybeMiddleware as RequestMiddleware }; - if (typeof entry.middleware !== 'function') { + use(method: RequestMethod | string, middleware: RequestMiddleware): () => void { + if (typeof middleware !== 'function') { throw new TypeError('use: middleware is required'); } + const entry = { method, middleware }; this._requestMiddleware.push(entry); return () => { const index = this._requestMiddleware.indexOf(entry); @@ -1862,14 +1850,13 @@ export abstract class Protocol { /** * The handler `_onrequest` dispatches to for `method`: the registered - * handler (or the fallback), with any middleware for the method or for - * `'*'` composed around it in registration order (first registered - * outermost). `undefined` when nothing is registered and no middleware - * applies. + * handler (or the fallback), with any middleware for the method composed + * around it in registration order (first registered outermost). + * `undefined` when nothing is registered and no middleware applies. */ private _resolveRequestHandler(method: string): ((request: JSONRPCRequest, ctx: ContextT) => Promise) | undefined { const base = this._requestHandlers.get(method) ?? this.fallbackRequestHandler; - const stack = this._requestMiddleware.filter(entry => entry.method === method || entry.method === '*'); + const stack = this._requestMiddleware.filter(entry => entry.method === method); if (stack.length === 0) return base; let composed: (request: JSONRPCRequest, ctx: ContextT) => Promise = base ?? diff --git a/packages/core-internal/test/shared/requestMiddleware.test.ts b/packages/core-internal/test/shared/requestMiddleware.test.ts index c4d7bef53c..f3d803fc35 100644 --- a/packages/core-internal/test/shared/requestMiddleware.test.ts +++ b/packages/core-internal/test/shared/requestMiddleware.test.ts @@ -91,7 +91,7 @@ describe('Protocol.use (request middleware)', () => { expect(order).toEqual(['middleware', 'handler']); }); - it("runs '*' middleware on every request, in one registration order with per-method middleware", async () => { + it('keeps one registration order across methods and stamps _meta through next', async () => { const { protocol, call } = await harness(); const order: string[] = []; for (const method of ['acme/a', 'acme/b']) { @@ -100,29 +100,25 @@ describe('Protocol.use (request middleware)', () => { return { method } as Result; }); } - protocol.use(async (request, ctx, next) => { - order.push('every'); + protocol.use('acme/a', async (request, ctx, next) => { + order.push('a-1'); const result = await next(request, ctx); return { ...result, _meta: { 'acme/stamped': true } } as Result; }); - const remove = protocol.use('acme/a', async (request, ctx, next) => { - order.push('only-a'); + protocol.use('acme/b', async (request, ctx, next) => { + order.push('b-1'); return next(request, ctx); }); - protocol.use('*', async (request, ctx, next) => { - order.push('every-2'); + protocol.use('acme/a', async (request, ctx, next) => { + order.push('a-2'); return next(request, ctx); }); const a = await call('acme/a'); expect((a as JSONRPCResultResponse).result).toEqual({ method: 'acme/a', _meta: { 'acme/stamped': true } }); - expect(order).toEqual(['every', 'only-a', 'every-2', 'handler:acme/a']); + expect(order).toEqual(['a-1', 'a-2', 'handler:acme/a']); order.length = 0; await call('acme/b'); - expect(order).toEqual(['every', 'every-2', 'handler:acme/b']); - remove(); - order.length = 0; - await call('acme/a'); - expect(order).toEqual(['every', 'every-2', 'handler:acme/a']); + expect(order).toEqual(['b-1', 'handler:acme/b']); }); it('next throws MethodNotFound when nothing underlies the middleware', async () => {