From 92954d81ed5bf99827f5f063308c7489b8b04bd3 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 8 Oct 2026 14:34:11 -0700 Subject: [PATCH 01/10] feat: server and client extensions ({ id, setup(ctx) }), request middleware (use), acceptResultType --- .changeset/server-extensions.md | 11 + docs/.vitepress/nav.ts | 1 + docs/advanced/extensions.md | 125 ++++++++++ packages/client/src/client/client.ts | 16 ++ packages/client/src/client/extension.ts | 131 +++++++++++ packages/client/src/index.ts | 1 + .../client/test/client/extensions.test.ts | 187 +++++++++++++++ packages/core-internal/src/index.ts | 1 + .../core-internal/src/shared/extensions.ts | 61 +++++ packages/core-internal/src/shared/protocol.ts | 137 ++++++++++- .../test/shared/requestMiddleware.test.ts | 153 +++++++++++++ packages/server/src/index.ts | 1 + packages/server/src/server/extension.ts | 127 +++++++++++ packages/server/src/server/mcp.ts | 30 ++- .../server/test/server/extensions.test.ts | 213 ++++++++++++++++++ 15 files changed, 1189 insertions(+), 6 deletions(-) create mode 100644 .changeset/server-extensions.md create mode 100644 docs/advanced/extensions.md create mode 100644 packages/client/src/client/extension.ts create mode 100644 packages/client/test/client/extensions.test.ts create mode 100644 packages/core-internal/src/shared/extensions.ts create mode 100644 packages/core-internal/test/shared/requestMiddleware.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..0c6df9f8f1 --- /dev/null +++ b/.changeset/server-extensions.md @@ -0,0 +1,11 @@ +--- +'@modelcontextprotocol/core-internal': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +Server and client extensions (experimental: the `extensions` option, the extension shapes and their contexts, `use` and `acceptResultType` may change in a minor release). An extension is `{ id, setup(ctx) }`, passed in `McpServerOptions.extensions` or `ClientOptions.extensions`. `setup` runs at construction, in array order, and adds everything through its context: `ctx.capability(settings)` sets what is advertised under `capabilities.extensions[id]` (`{}` by default), `ctx.setRequestHandler(name, schemas, handler)` registers the handler for a custom request method and refuses core method names and methods that already have a handler, `ctx.use(method, middleware)` wraps a method's handler, and `ctx.require(extensionId)` declares that the extension needs another extension, failing construction when it isn't installed. The server context also carries `ctx.mcp`, the `McpServer`; the client context carries `ctx.client` and `ctx.acceptResultType`. A duplicate id fails construction before any extension is set up. + +`Protocol.use(method, (request, ctx, next) => …)` is 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. `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/.vitepress/nav.ts b/docs/.vitepress/nav.ts index 9c07c8c0ed..7239b750d1 100644 --- a/docs/.vitepress/nav.ts +++ b/docs/.vitepress/nav.ts @@ -68,6 +68,7 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [ items: [ { text: 'Low-level server', link: '/advanced/low-level-server' }, { text: 'Custom methods', link: '/advanced/custom-methods' }, + { text: '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..3786688513 --- /dev/null +++ b/docs/advanced/extensions.md @@ -0,0 +1,125 @@ +--- +shape: how-to +--- + +# Extensions + +::: warning Experimental +The `extensions` option, the `ServerExtension` and `ClientExtension` shapes, their contexts, `use` and `acceptResultType` are experimental and may change in a minor release. +::: + +An **extension** packages protocol behaviour outside the core specification, such as the `io.modelcontextprotocol/tasks` or `io.modelcontextprotocol/skills` extension or a vendor feature, as one object you pass to the server or the client. The SDK advertises it, sets it up, and checks what it depends on before serving. + +## Write a server extension + +An extension is an `id` and a `setup(ctx)` function. What is both required and declarative sits on the object, which today is just the `id`. Everything else happens in `setup`, which runs once while the server is constructed: optional declarations first (`ctx.capability`, `ctx.require`), then handlers, middleware and anything registered on `ctx.mcp`. Everything `setup` adds goes through `ctx`: + +| Member | What it does | +| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ctx.capability(settings)` | Sets what the server advertises under `capabilities.extensions[id]`. Without a call, the server advertises `{}`. | +| `ctx.require(extensionId)` | Declares that this extension needs another extension. Construction fails, naming both, when the server doesn't install it. Order in `extensions` doesn't matter. | +| `ctx.setRequestHandler(name, schemas, handler)` | Registers the handler for a custom request method, with the same arguments as `setRequestHandler`. Throws for a core method name, or for a method another handler already serves. | +| `ctx.use(method, middleware)` | Middleware around whatever handler serves `method`, core or custom. | +| `ctx.mcp` | The `McpServer`, for `registerTool`, `registerResource` and `ResourceTemplate`. | + +This extension adds a custom method, and refuses `tools/call` from clients that didn't declare it: + +```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, + setup(ctx) { + // Settings for the advertised capability, if the extension has any. + ctx.capability({ exampleData: true }); + + // A custom method: setRequestHandler, checked for name clashes. + ctx.setRequestHandler('gate/status', { params: z.looseObject({}) }, () => ({ armed: true })); + + // Middleware on a spec method: runs around the registered handler, + // may answer, transform, or refuse. + ctx.use('tools/call', (request, requestCtx, next) => { + const declared = requestCtx.mcpReq.envelope?.[CLIENT_CAPABILITIES_META_KEY]?.extensions ?? {}; + if (!(GATE in declared)) { + throw new MissingRequiredClientCapabilityError({ requiredCapabilities: { extensions: { [GATE]: {} } } }, 'declare the gate'); + } + return next(request, requestCtx); + }); + } +}; +``` + +An extension that serves resources or tools registers them on `ctx.mcp`, like any server: + +```ts +const changelog: ServerExtension = { + id: 'com.example/changelog', + setup(ctx) { + ctx.mcp.registerResource('changelog', 'changelog://latest', { mimeType: 'text/markdown' }, async uri => ({ + contents: [{ uri: uri.href, text: await readChangelog() }] + })); + } +}; +``` + +## Install it + +Pass extensions to `McpServer` at construction: + +```ts +const server = new McpServer({ name: 'gated', version: '1.0.0' }, { extensions: [gate, changelog] }); +``` + +The server then: + +1. Checks the ids. A duplicate fails construction before any extension is set up. +2. In array order, advertises each extension under `capabilities.extensions[id]` as `{}` and runs its `setup`. Legacy connections see the map in the `initialize` result, and 2026-07-28 connections see it in `server/discover`. Settings from `ctx.capability` replace the `{}`. + +A `setup` that throws fails the constructor, so a server with a partly set up extension never exists. That includes `ctx.require` for an extension the server doesn't install. The ids are known before any `setup` runs, so a required extension can be listed before or after the one that needs it. + +`ctx.require` is for an extension that can't work without another one. When the other one only adds something optional, check for what you need in code, such as in a handler or middleware at the moment it runs, and carry on without it. Extensions are an `McpServer` option; the low-level `Server` doesn't take them. + +## Client extensions + +The client takes the same shape, `{ id, setup(ctx) }`, in `ClientOptions.extensions`. The client advertises each extension 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. Its context has: + +- `ctx.capability(settings)`, `ctx.use(method, middleware)` and `ctx.require(extensionId)`, as on the server. +- `ctx.setRequestHandler(name, schemas, handler)` for custom server-to-client requests. +- `ctx.acceptResultType(method, resultType)`, so results of `method` may carry a `resultType` the extension defines. The explicit-schema `request()` path then hands them to the caller's schema as-is, instead of rejecting the unknown kind. +- `ctx.client`, the client being set up. + +```ts +import type { ClientExtension } from '@modelcontextprotocol/client'; + +const gateClient: ClientExtension = { + id: GATE, + setup(ctx) { + ctx.setRequestHandler('gate/ping', { params: z.looseObject({}) }, () => ({ pong: true })); + ctx.acceptResultType('tools/call', 'gate'); + } +}; + +const client = new Client({ name: 'gated-client', version: '1.0.0' }, { extensions: [gateClient] }); +``` + +## How middleware composes + +`ctx.setRequestHandler` registers the route handler. `ctx.use(method, middleware)` is the middleware around it, in the Koa style: `await next(request, requestCtx)` yields the result, and the middleware returns what goes on the wire. It wraps whatever handler serves `method` at dispatch time, including one another extension registered. 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 outermost. Extensions are set up in array order, so the first extension's middleware is outermost, and all of it runs outside middleware the application adds afterwards with `server.use`. Method names are exact, and there is no wildcard. + +An extension wraps the methods it doesn't own with `ctx.use`, and never registers them again; `ctx.setRequestHandler` refuses to. Something every peer must know about the extension belongs in its advertised capability (`ctx.capability`), 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 the protocol-level errors the client must see as errors: `UrlElicitationRequiredError` and `MissingRequiredClientCapabilityError` (`-32021`). + +## Recap + +- An extension is `{ id, setup(ctx) }`, passed in `McpServerOptions.extensions` or `ClientOptions.extensions`. +- `setup` registers everything through `ctx`: `capability`, `setRequestHandler`, `use`, `require`, plus `ctx.mcp` on the server and `acceptResultType` on the client. +- Extensions are set up in array order. Duplicate ids, and a `ctx.require` for an extension that isn't installed, fail construction. +- Middleware composes at dispatch time, first registered outermost, and applies to handlers registered later, including other extensions' handlers. +- The SDK owns the hooks, the checks 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 096a8660e2..d264b265ca 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -80,6 +80,8 @@ import { SUPPORTED_MODERN_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core-internal'; +import type { ClientExtension } from './extension'; +import { installClientExtensions } 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 +183,18 @@ export function getSupportedElicitationModes(capabilities: ClientCapabilities['e } export type ClientOptions = ProtocolOptions & { + /** + * Extensions to set up at construction, in order. Each is advertised under + * `capabilities.extensions[id]` as `{}`, then its `setup` runs with a + * `ClientExtensionContext`. A duplicate id fails construction before any + * extension is set up. + * + * Experimental: the shape of an extension may change in a minor release. + * + * @experimental + */ + extensions?: readonly ClientExtension[]; + /** * Capabilities to advertise as being supported by this client. */ @@ -680,6 +694,8 @@ export class Client extends Protocol { if (options?.listChanged) { this._listChangedConfig = options.listChanged; } + + installClientExtensions(this, options?.extensions ?? []); } 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..37ebf685cb --- /dev/null +++ b/packages/client/src/client/extension.ts @@ -0,0 +1,131 @@ +import type { + ClientContext, + InferHandlerResult, + JSONObject, + RequestMethod, + RequestMiddleware, + StandardSchemaV1 +} from '@modelcontextprotocol/core-internal'; +import { requireExtension, setExtensionRequestHandler, uniqueExtensionIds } from '@modelcontextprotocol/core-internal'; + +import type { Client } from './client'; + +/** + * A client extension: the client half of protocol behaviour outside the core + * specification, such as the `io.modelcontextprotocol/tasks` extension or a + * vendor feature, passed to the client at construction. + * + * ```ts + * const client = new Client(info, { extensions: [tasks] }); + * ``` + * + * What is both required and declarative sits on the object: the `id`. + * Everything else happens in `setup`, which runs once while the client is + * constructed: optional declarations first (`ctx.capability`, `ctx.require`), + * then handlers for server-to-client requests, middleware, and the result + * kinds it accepts. + * + * Experimental: the shape of an extension may change in a minor release. + * + * @experimental + */ +export interface ClientExtension { + /** + * The extension identifier, prefix-qualified (`io.modelcontextprotocol/tasks`, + * `com.example/feature-flags`). Advertised as the key under the client's + * `capabilities.extensions`, and unique per client. + */ + readonly id: string; + /** Adds the extension to the client. Called once, at construction, in `extensions` order. */ + setup(ctx: ClientExtensionContext): void; +} + +/** + * What a {@linkcode ClientExtension}'s `setup` receives. + * + * Experimental: this shape may change in a minor release. + * + * @experimental + */ +export interface ClientExtensionContext { + /** The client the extension is set up on. */ + readonly client: Client; + /** + * Sets what the client advertises under `capabilities.extensions[id]`: + * in `initialize` on a legacy connection, and in every request's + * client-capabilities envelope on a 2026-07-28 connection. Without a call, + * the client advertises `{}`. + */ + capability(settings: JSONObject): void; + /** + * Declares that this extension needs another extension, by id. Throws, + * naming both, when the client doesn't install it, which fails the + * constructor. The full list is known before any `setup` runs, so an + * extension listed after this one counts. For behaviour that is merely + * optional, check in code instead and carry on without it. + */ + require(extensionId: `${string}/${string}`): void; + /** Middleware around whatever handler serves a server-to-client request `method`. See `Protocol.use`. */ + use(method: RequestMethod | (string & {}), middleware: RequestMiddleware): void; + /** + * Registers the handler for a custom server-to-client request, like + * `Client.setRequestHandler` with schemas, but checked: throws when `name` + * is a core method (wrap those with `use` instead) or when a handler + * already serves it, such as another extension's. + */ + setRequestHandler

( + name: string, + schemas: { params: P; result?: R }, + handler: (params: StandardSchemaV1.InferOutput

, ctx: ClientContext) => InferHandlerResult | Promise> + ): void; + /** + * Lets results of `method` carry a `resultType` this extension defines, + * such as the Tasks extension's `"task"` on `tools/call`. See + * `Protocol.acceptResultType`. + */ + acceptResultType(method: string, resultType: string): void; +} + +/** + * Sets up `extensions` on `client`, in order. Ids are checked before anything + * is set up, so a duplicate leaves the client untouched. A `setup` that + * throws, including an unmet `ctx.require`, fails the `Client` constructor + * that called this. + * + * Package-internal: called by the `Client` constructor. + */ +export function installClientExtensions(client: Client, extensions: readonly ClientExtension[]): void { + const ids = uniqueExtensionIds(extensions); + for (const extension of extensions) { + if (typeof extension.setup !== 'function') { + throw new TypeError(`Extension "${String((extension as { id: unknown }).id)}" has no setup`); + } + } + + for (const extension of extensions) { + client.registerCapabilities({ extensions: { [extension.id]: {} } }); + extension.setup(contextFor(client, extension.id, ids)); + } +} + +/** The context one extension's `setup` receives; everything it registers is attributed to `id`. */ +function contextFor(client: Client, id: string, installed: ReadonlySet): ClientExtensionContext { + return { + client, + capability(settings) { + client.registerCapabilities({ extensions: { [id]: settings } }); + }, + require(extensionId) { + requireExtension('client', installed, id, extensionId); + }, + use(method, middleware) { + client.use(method, middleware); + }, + setRequestHandler(name, schemas, handler) { + setExtensionRequestHandler(client, id, name, schemas, handler); + }, + acceptResultType(method, resultType) { + client.acceptResultType(method, resultType); + } + }; +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 0b5b6e86ea..dc51fa8377 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -72,6 +72,7 @@ export { Client } from './client/client'; export { getSupportedElicitationModes } from './client/client'; export type { DiscoverAndRequestJwtAuthGrantOptions, JwtAuthGrantResult, RequestJwtAuthGrantOptions } from './client/crossAppAccess'; export { discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant, requestJwtAuthorizationGrant } from './client/crossAppAccess'; +export type { ClientExtension, ClientExtensionContext } from './client/extension'; // DPoP (RFC 9449 / SEP-1932) sender-constrained tokens: the signing session plus key-pair // primitives. Wire a DpopSession into OAuthClientProvider.dpop() for full OAuth+DPoP via `auth`/ // the transports' authProvider option, or use `withDpop` directly when you manage tokens yourself. diff --git a/packages/client/test/client/extensions.test.ts b/packages/client/test/client/extensions.test.ts new file mode 100644 index 0000000000..401da6e6c6 --- /dev/null +++ b/packages/client/test/client/extensions.test.ts @@ -0,0 +1,187 @@ +/** + * `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 set up at construction through a `ClientExtensionContext`, + * which registers handlers for server-to-client requests, middleware, + * capability settings, dependencies and accepted result kinds. + */ +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, + setup(ctx) { + log.push('set up'); + ctx.capability({ exampleData: true }); + ctx.setRequestHandler('gate/ping', { params: z.looseObject({}) }, () => ({ pong: true })); + ctx.acceptResultType('tools/call', 'task'); + } + }; +} + +const needs = (id: string, extensionId: `${string}/${string}`): ClientExtension => ({ id, setup: ctx => ctx.require(extensionId) }); + +/** 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/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', + 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('sets the extension up at construction, handing it the client', () => { + const log: string[] = []; + const seen: unknown[] = []; + const client = new Client( + { name: 'c', version: '1' }, + { extensions: [gateExtension(log), { id: 'com.example/probe', setup: ctx => void seen.push(ctx.client) }] } + ); + expect(log).toEqual(['set up']); + expect(seen).toEqual([client]); + }); + + it('refuses a custom method that is a core method, or one another extension already serves', () => { + const claims = (id: string, name: string): ClientExtension => ({ + id, + setup: ctx => ctx.setRequestHandler(name, { params: z.looseObject({}) }, () => ({})) + }); + expect(() => new Client({ name: 'c', version: '1' }, { extensions: [claims('com.example/a', 'tools/call')] })).toThrow( + 'Extension "com.example/a" cannot register "tools/call": it is a core method; wrap it with use() instead' + ); + expect( + () => + new Client( + { name: 'c', version: '1' }, + { extensions: [claims('com.example/a', 'x/ping'), claims('com.example/b', 'x/ping')] } + ) + ).toThrow('Extension "com.example/b" cannot register "x/ping": another handler already serves it'); + }); + + it('fails construction when a required extension is missing, and accepts it wherever it is listed', () => { + expect(() => new Client({ name: 'c', version: '1' }, { extensions: [needs('com.example/b', 'com.example/a')] })).toThrow( + 'Extension "com.example/b" requires the "com.example/a" extension, which this client does not install' + ); + const plain: ClientExtension = { id: 'com.example/a', setup: () => {} }; + expect( + () => new Client({ name: 'c', version: '1' }, { extensions: [needs('com.example/b', 'com.example/a'), plain] }) + ).not.toThrow(); + }); + + it('refuses a duplicate id before setting anything up', () => { + const log: string[] = []; + const make = () => new Client({ name: 'c', version: '1' }, { extensions: [gateExtension(log), gateExtension(log)] }); + expect(make).toThrow(`Extension "${EXT_ID}" is listed more than once`); + expect(log).toEqual([]); + }); + + it('advertises {} when setup sets no capability', async () => { + const { clientTx, written } = await scriptedServer('legacy'); + const client = new Client({ name: 'c', version: '1' }, { extensions: [{ id: 'com.example/plain', setup: () => {} }] }); + 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('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 registered 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(); + }); +}); diff --git a/packages/core-internal/src/index.ts b/packages/core-internal/src/index.ts index c93a604fa1..cd898ebf9c 100644 --- a/packages/core-internal/src/index.ts +++ b/packages/core-internal/src/index.ts @@ -6,6 +6,7 @@ export * from './shared/auth'; export * from './shared/authUtils'; export * from './shared/clientCapabilityRequirements'; export * from './shared/envelope'; +export * from './shared/extensions'; export * from './shared/inboundClassification'; export * from './shared/inputRequired'; export * from './shared/inputRequiredDriver'; diff --git a/packages/core-internal/src/shared/extensions.ts b/packages/core-internal/src/shared/extensions.ts new file mode 100644 index 0000000000..df4dbfeb19 --- /dev/null +++ b/packages/core-internal/src/shared/extensions.ts @@ -0,0 +1,61 @@ +/** + * The rules server and client extensions share: unique ids, dependencies on + * other extensions, and checked request handler registration. `McpServer` and + * `Client` set extensions up with these, so both sides refuse the same things + * with the same messages. + */ +import type { StandardSchemaV1 } from '../util/standardSchema'; +import { codecForVersion, MODERN_WIRE_REVISION } from '../wire/codec'; +import type { BaseContext, InferHandlerResult, Protocol } from './protocol'; + +/** + * Returns the extensions' ids, or throws when two share one. Runs before any + * extension is set up, so a duplicate leaves the server or client untouched, + * and every `setup` can see the full list. + */ +export function uniqueExtensionIds(extensions: readonly { readonly id: string }[]): ReadonlySet { + const ids = new Set(); + for (const { id } of extensions) { + if (ids.has(id)) throw new Error(`Extension "${id}" is listed more than once; pass each extension once`); + ids.add(id); + } + return ids; +} + +/** + * Throws, naming both extensions, when `extension` requires an extension + * that is not in `installed`. `installed` is the full list, known before any + * `setup` runs, so the order extensions are listed in doesn't matter. + */ +export function requireExtension(side: 'server' | 'client', installed: ReadonlySet, extension: string, required: string): void { + if (!installed.has(required)) { + throw new Error(`Extension "${extension}" requires the "${required}" extension, which this ${side} does not install`); + } +} + +/** + * `setRequestHandler` on the extension path. It refuses a 2026-07-28 core + * method name, which an extension wraps with `use` instead, and a method that + * already has a handler, such as another extension's. + */ +export function setExtensionRequestHandler< + ContextT extends BaseContext, + P extends StandardSchemaV1, + R extends StandardSchemaV1 | undefined = undefined +>( + protocol: Protocol, + extension: string, + name: string, + schemas: { params: P; result?: R }, + handler: (params: StandardSchemaV1.InferOutput

, ctx: ContextT) => InferHandlerResult | Promise> +): void { + if (codecForVersion(MODERN_WIRE_REVISION).hasRequestMethod(name)) { + throw new Error(`Extension "${extension}" cannot register "${name}": it is a core method; wrap it with use() instead`); + } + try { + protocol.assertCanSetRequestHandler(name); + } catch { + throw new Error(`Extension "${extension}" cannot register "${name}": another handler already serves it`); + } + protocol.setRequestHandler(name, schemas, handler); +} diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 9d60be8efe..f2fb4a86ef 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -205,6 +205,21 @@ 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. + * + * Experimental: this shape may change in a minor release. + * + * @experimental + */ +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 @@ -568,6 +583,10 @@ export abstract class Protocol { private _requestHandlers: Map Promise> = new Map(); /** Methods registered with an explicit schema; the era gate in `_onrequest` reads it for the Tasks extension names. */ private _customSchemaRequestMethods = new Set(); + /** 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(); private _requestHandlerAbortControllers: Map = new Map(); private _notificationHandlers: Map Promise> = new Map(); private _responseHandlers: Map void> = new Map(); @@ -1019,7 +1038,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'); @@ -1530,6 +1549,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); @@ -1777,6 +1811,102 @@ export abstract class Protocol { this._requestHandlers.set(method, this._wrapHandler(method, stored)); } + /** + * 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. + * + * 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. Method names are exact — there is no wildcard; what an + * extension needs every peer to know belongs in its advertised + * capability, not in per-request middleware. Extensions reach this + * through `ctx.use` in their `setup`. + * + * @returns A function that removes the middleware. + * + * Experimental: this method may change in a minor release. + * + * @experimental + */ + 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); + if (index !== -1) this._requestMiddleware.splice(index, 1); + }; + } + + /** + * 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. Client extensions reach this through + * `ctx.acceptResultType` in their `setup`. + * + * @returns A function that withdraws the declaration. + * + * Experimental: this method may change in a minor release. + * + * @experimental + */ + 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 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); + if (stack.length === 0) return base; + let composed: (request: JSONRPCRequest, ctx: ContextT) => Promise = + base ?? + (async () => { + throw new ProtocolError(ProtocolErrorCode.MethodNotFound, 'Method not found'); + }); + // 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) => middleware(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` @@ -1912,7 +2042,10 @@ export interface RequestHandlerSchemas< result?: R; } -type InferHandlerResult = R extends StandardSchemaV1 ? StandardSchemaV1.InferOutput : Result; +/** What a custom request handler returns: the output of its `result` schema, or any `Result` when it has none. */ +export type InferHandlerResult = R extends StandardSchemaV1 + ? StandardSchemaV1.InferOutput + : Result; function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); diff --git a/packages/core-internal/test/shared/requestMiddleware.test.ts b/packages/core-internal/test/shared/requestMiddleware.test.ts new file mode 100644 index 0000000000..f3d803fc35 --- /dev/null +++ b/packages/core-internal/test/shared/requestMiddleware.test.ts @@ -0,0 +1,153 @@ +/** + * `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'; + +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.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.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; + }); + 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.use('acme/op', (request, ctx, next) => { + if ((request.params as { deny?: boolean }).deny) { + 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 middleware' + }); + expect(handlerRan).toBe(false); + await call('acme/op'); + expect(handlerRan).toBe(true); + }); + + it('applies to a handler registered after the middleware was installed', async () => { + const { protocol, call } = await harness(); + const order: string[] = []; + protocol.use('acme/late', async (request, ctx, next) => { + order.push('middleware'); + return next(request, ctx); + }); + protocol.setRequestHandler('acme/late', { params: z.looseObject({}) }, () => { + order.push('handler'); + return {} as Result; + }); + await call('acme/late'); + expect(order).toEqual(['middleware', 'handler']); + }); + + 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']) { + protocol.setRequestHandler(method, { params: z.looseObject({}) }, () => { + order.push(`handler:${method}`); + return { method } as Result; + }); + } + 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; + }); + protocol.use('acme/b', async (request, ctx, next) => { + order.push('b-1'); + return next(request, ctx); + }); + 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(['a-1', 'a-2', 'handler:acme/a']); + order.length = 0; + await call('acme/b'); + expect(order).toEqual(['b-1', 'handler:acme/b']); + }); + + 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)); + const response = await call('acme/missing'); + expect((response as JSONRPCErrorResponse).error).toMatchObject({ code: ProtocolErrorCode.MethodNotFound }); + }); + + 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 removeFirst = protocol.use('acme/op', async (request, ctx, next) => { + order.push('first'); + return next(request, ctx); + }); + protocol.use('acme/op', async (request, ctx, next) => { + order.push('second'); + return next(request, ctx); + }); + await call('acme/op'); + expect(order).toEqual(['first', 'second', 'handler']); + order.length = 0; + removeFirst(); + await call('acme/op'); + expect(order).toEqual(['second', 'handler']); + }); +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 32605dd7e7..55a934f926 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -56,6 +56,7 @@ 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, ServerExtensionContext } from './server/extension'; export type { RequestStateCodec, RequestStateCodecOptions } from './server/requestStateCodec'; export { createRequestStateCodec } from './server/requestStateCodec'; export type { ServerOptions } from './server/server'; diff --git a/packages/server/src/server/extension.ts b/packages/server/src/server/extension.ts new file mode 100644 index 0000000000..fc57768a20 --- /dev/null +++ b/packages/server/src/server/extension.ts @@ -0,0 +1,127 @@ +import type { + InferHandlerResult, + JSONObject, + RequestMethod, + RequestMiddleware, + ServerContext, + StandardSchemaV1 +} from '@modelcontextprotocol/core-internal'; +import { requireExtension, setExtensionRequestHandler, uniqueExtensionIds } from '@modelcontextprotocol/core-internal'; + +import type { McpServer } from './mcp'; + +/** + * A server extension: protocol behaviour outside the core specification, + * such as the `io.modelcontextprotocol/tasks` extension or a vendor feature, + * passed to the server at construction. + * + * ```ts + * const server = new McpServer(info, { extensions: [tasks, skills] }); + * ``` + * + * What is both required and declarative sits on the object: the `id`. + * Everything else happens in `setup`, which runs once while the server is + * constructed: optional declarations first (`ctx.capability`, `ctx.require`), + * then custom request handlers, middleware on spec methods, and anything + * registered on the `McpServer` itself, such as resources and tools. + * + * Experimental: the shape of an extension may change in a minor release. + * + * @experimental + */ +export interface ServerExtension { + /** + * The extension identifier, prefix-qualified (`io.modelcontextprotocol/tasks`, + * `com.example/feature-flags`). Advertised as the key under + * `capabilities.extensions`, and unique per server. + */ + readonly id: string; + /** Adds the extension to the server. Called once, at construction, in `extensions` order. */ + setup(ctx: ServerExtensionContext): void; +} + +/** + * What a {@linkcode ServerExtension}'s `setup` receives. + * + * Experimental: this shape may change in a minor release. + * + * @experimental + */ +export interface ServerExtensionContext { + /** The server the extension is set up on: `registerTool`, `registerResource`, `ResourceTemplate`. */ + readonly mcp: McpServer; + /** + * Sets what the server advertises under `capabilities.extensions[id]`. + * Without a call, the server advertises `{}`. + */ + capability(settings: JSONObject): void; + /** + * Declares that this extension needs another extension, by id. Throws, + * naming both, when the server doesn't install it, which fails the + * constructor. The full list is known before any `setup` runs, so an + * extension listed after this one counts. For behaviour that is merely + * optional, check in code instead and carry on without it. + */ + require(extensionId: `${string}/${string}`): void; + /** + * Middleware around whatever handler serves `method`, including a core + * method or another extension's: it may answer, transform or refuse. Runs + * outside middleware the application adds later with `server.use`, and + * inside middleware from extensions earlier in `extensions`. See + * `Protocol.use` for how middleware composes. + */ + use(method: RequestMethod | (string & {}), middleware: RequestMiddleware): void; + /** + * Registers the handler for a custom request method, like + * `Server.setRequestHandler` with schemas, but checked: throws when `name` + * is a core method (wrap those with `use` instead) or when a handler + * already serves it, such as another extension's. + */ + setRequestHandler

( + name: string, + schemas: { params: P; result?: R }, + handler: (params: StandardSchemaV1.InferOutput

, ctx: ServerContext) => InferHandlerResult | Promise> + ): void; +} + +/** + * Sets up `extensions` on `mcp`, in order. Ids are checked before anything is + * set up, so a duplicate leaves the server untouched. A `setup` that throws, + * including an unmet `ctx.require`, fails the `McpServer` constructor that + * called this, so a server with a partly set up extension never exists. + * + * Package-internal: called by the `McpServer` constructor. + */ +export function installServerExtensions(mcp: McpServer, extensions: readonly ServerExtension[]): void { + const ids = uniqueExtensionIds(extensions); + for (const extension of extensions) { + if (typeof extension.setup !== 'function') { + throw new TypeError(`Extension "${String((extension as { id: unknown }).id)}" has no setup`); + } + } + + for (const extension of extensions) { + mcp.server.registerCapabilities({ extensions: { [extension.id]: {} } }); + extension.setup(contextFor(mcp, extension.id, ids)); + } +} + +/** The context one extension's `setup` receives; everything it registers is attributed to `id`. */ +function contextFor(mcp: McpServer, id: string, installed: ReadonlySet): ServerExtensionContext { + const server = mcp.server; + return { + mcp, + capability(settings) { + server.registerCapabilities({ extensions: { [id]: settings } }); + }, + require(extensionId) { + requireExtension('server', installed, id, extensionId); + }, + use(method, middleware) { + server.use(method, middleware); + }, + setRequestHandler(name, schemas, handler) { + setExtensionRequestHandler(server, id, name, schemas, handler); + } + }; +} diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index bfb3b798b0..e24ab82e58 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -47,6 +47,8 @@ import { import type * as z from 'zod/v4'; import { getCompleter, isCompletable } from './completable'; +import type { ServerExtension } from './extension'; +import { installServerExtensions } from './extension'; import type { ScopeChallengeHandler } from './scopeChallenge'; import { supportsScopeChallengeResolver } from './scopeChallenge'; import type { ServerOptions } from './server'; @@ -86,9 +88,21 @@ function resolveMaxToolInputElements(value: number | undefined): number | undefi } /** - * Options for {@linkcode McpServer}: everything {@linkcode ServerOptions} accepts, plus `maxToolInputElements`. + * Options for {@linkcode McpServer}: everything {@linkcode ServerOptions} accepts, plus `extensions` and `maxToolInputElements`. */ export type McpServerOptions = ServerOptions & { + /** + * Extensions to set up at construction, in order. Each is advertised under + * `capabilities.extensions[id]` as `{}`, then its `setup` runs with a + * `ServerExtensionContext`. A duplicate id fails construction before any + * extension is set up. + * + * Experimental: the shape of an extension may change in a minor release. + * + * @experimental + */ + extensions?: readonly ServerExtension[]; + /** * Largest combined number of array elements and object members a single `tools/call` `arguments` payload may contain. * A number of at least 1; unset or `Infinity` means no limit. @@ -150,7 +164,9 @@ export class McpServer { } constructor(serverInfo: Implementation, options?: McpServerOptions) { - this.server = new Server(serverInfo, options); + // Extensions are set up at the end of the constructor, where this McpServer exists to hand to them. + const { extensions, ...serverOptions } = options ?? {}; + this.server = new Server(serverInfo, serverOptions); this._maxToolInputElements = resolveMaxToolInputElements(options?.maxToolInputElements); // Per the MCP spec, a server that declares a primitive capability MUST respond to its @@ -167,6 +183,8 @@ export class McpServer { if (options?.capabilities?.prompts) { this.setPromptRequestHandlers(); } + + installServerExtensions(this, extensions ?? []); } /** @@ -314,8 +332,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/test/server/extensions.test.ts b/packages/server/test/server/extensions.test.ts new file mode 100644 index 0000000000..f0a1d1145c --- /dev/null +++ b/packages/server/test/server/extensions.test.ts @@ -0,0 +1,213 @@ +/** + * `McpServerOptions.extensions`: each extension is advertised under + * `capabilities.extensions[id]` and set up at construction through a + * `ServerExtensionContext`, which registers custom methods, middleware on spec + * methods, capability settings and dependencies. + */ +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 type { 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 info = { name: 's', version: '1' }; + +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. */ +const gate: ServerExtension = { + id: EXT_ID, + setup(ctx) { + ctx.capability({ exampleData: true }); + ctx.setRequestHandler('gate/status', { params: z.looseObject({}) }, () => ({ armed: true })); + ctx.use('tools/call', (request, requestCtx, next) => { + const envelope = requestCtx.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, requestCtx); + }); + } +}; + +/** An extension whose `setup` only records that it ran. */ +const plain = (id: string, log: string[] = []): ServerExtension => ({ id, setup: () => void log.push(id) }); + +/** An extension whose middleware on `tools/call` records when it runs. */ +const tracing = (id: string, log: string[]): ServerExtension => ({ + id, + setup(ctx) { + ctx.use('tools/call', async (request, requestCtx, next) => { + log.push(`${id} in`); + const result = await next(request, requestCtx); + log.push(`${id} out`); + return result; + }); + } +}); + +const echo = (mcp: McpServer) => + mcp.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] + })); + +describe('McpServerOptions.extensions', () => { + it('advertises {} for an extension that sets no capability, and the settings of one that does', () => { + const mcp = new McpServer(info, { extensions: [plain('com.example/plain'), gate] }); + expect(mcp.server.getCapabilities().extensions).toEqual({ 'com.example/plain': {}, [EXT_ID]: { exampleData: true } }); + }); + + it('runs setup in order, handing each extension the McpServer it is set up on', () => { + const seen: unknown[] = []; + const probe = (id: string): ServerExtension => ({ id, setup: ctx => void seen.push([id, ctx.mcp]) }); + const mcp = new McpServer(info, { extensions: [probe('com.example/a'), probe('com.example/b')] }); + expect(seen).toEqual([ + ['com.example/a', mcp], + ['com.example/b', mcp] + ]); + }); + + it('serves a custom method the extension registered', async () => { + const mcp = new McpServer(info, { extensions: [gate] }); + const body = await exchange(mcp.server, modernRequest('gate/status')); + expect(body['result']).toMatchObject({ armed: true }); + }); + + it('applies middleware to tools/call, which McpServer registers later: refuses without the capability, passes with it', async () => { + const mcp = new McpServer(info, { extensions: [gate] }); + echo(mcp); + + 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('nests middleware in extensions order, outside middleware the application adds afterwards', async () => { + const log: string[] = []; + const mcp = new McpServer(info, { extensions: [tracing('com.example/a', log), tracing('com.example/b', log)] }); + mcp.server.use('tools/call', async (request, requestCtx, next) => { + log.push('app'); + return next(request, requestCtx); + }); + echo(mcp); + await exchange(mcp.server, modernRequest('tools/call', { name: 'echo', arguments: { text: 'hi' } })); + expect(log).toEqual(['com.example/a in', 'com.example/b in', 'app', 'com.example/b out', 'com.example/a out']); + }); + + it('refuses a custom method that is a core method', () => { + const claimsCore: ServerExtension = { + id: 'com.example/core', + setup: ctx => ctx.setRequestHandler('tools/list', { params: z.looseObject({}) }, () => ({ tools: [] })) + }; + expect(() => new McpServer(info, { extensions: [claimsCore] })).toThrow( + 'Extension "com.example/core" cannot register "tools/list": it is a core method; wrap it with use() instead' + ); + }); + + it('refuses a custom method another extension already serves', () => { + const status = (id: string): ServerExtension => ({ + id, + setup: ctx => ctx.setRequestHandler('gate/status', { params: z.looseObject({}) }, () => ({ armed: true })) + }); + expect(() => new McpServer(info, { extensions: [status('com.example/a'), status('com.example/b')] })).toThrow( + 'Extension "com.example/b" cannot register "gate/status": another handler already serves it' + ); + }); + + it('lets an extension register a name 2026-07-28 removed from core, as the Tasks extension does with tasks/get', async () => { + const tasksLike: ServerExtension = { + id: 'com.example/tasks-like', + setup: ctx => + ctx.setRequestHandler('tasks/get', { params: z.looseObject({ taskId: z.string() }) }, ({ taskId }) => ({ taskId })) + }; + const mcp = new McpServer(info, { extensions: [tasksLike] }); + const body = await exchange(mcp.server, modernRequest('tasks/get', { taskId: 't1' })); + expect(body['result']).toMatchObject({ taskId: 't1' }); + }); + + it('refuses a duplicate id before setting anything up', () => { + const log: string[] = []; + expect(() => new McpServer(info, { extensions: [plain('com.example/a', log), plain('com.example/a', log)] })).toThrow( + 'Extension "com.example/a" is listed more than once' + ); + expect(log).toEqual([]); + }); + + it('refuses an object without setup', () => { + const shapeless = { id: 'com.example/empty' } as unknown as ServerExtension; + expect(() => new McpServer(info, { extensions: [shapeless] })).toThrow('Extension "com.example/empty" has no setup'); + }); + + it('a MissingRequiredClientCapabilityError thrown by a tool handler is a JSON-RPC error, not an isError result', async () => { + const mcp = new McpServer(info); + 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]: {} } } } }); + }); +}); + +describe('ServerExtensionContext.require', () => { + const needs = (id: string, extensionId: `${string}/${string}`): ServerExtension => ({ id, setup: ctx => ctx.require(extensionId) }); + + it('fails construction when the required extension is missing, naming both', () => { + expect(() => new McpServer(info, { extensions: [needs('com.example/search', 'com.example/index')] })).toThrow( + 'Extension "com.example/search" requires the "com.example/index" extension, which this server does not install' + ); + }); + + it('accepts the required extension wherever it is listed', () => { + expect( + () => new McpServer(info, { extensions: [needs('com.example/search', 'com.example/index'), plain('com.example/index')] }) + ).not.toThrow(); + expect( + () => new McpServer(info, { extensions: [plain('com.example/index'), needs('com.example/search', 'com.example/index')] }) + ).not.toThrow(); + }); +}); From c69d9fca5453bf9b4229854425c6765fe036cb09 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 12:25:32 +0000 Subject: [PATCH 02/10] fix(core): compose use() middleware around embedded input requests On a 2026-07-28 connection the client fulfils elicitation/create, sampling/createMessage and roots/list embedded in input_required results through _getRequestHandler, which read the stored handler directly. Middleware an extension installed with ctx.use on those methods never ran there, though it is the only path for them on that era. Compose the same middleware stack around that handler. --- .../test/client/inputRequiredEngine.test.ts | 34 +++++++++++++++++++ packages/core-internal/src/shared/protocol.ts | 25 ++++++++++---- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/client/test/client/inputRequiredEngine.test.ts b/packages/client/test/client/inputRequiredEngine.test.ts index e048ea6bc2..14af5fc2c4 100644 --- a/packages/client/test/client/inputRequiredEngine.test.ts +++ b/packages/client/test/client/inputRequiredEngine.test.ts @@ -319,6 +319,40 @@ describe('auto-fulfilment (default on)', () => { await client.close(); }); + + it('runs use() middleware around the handler that fulfils an embedded input request', async () => { + const { clientTx, toolCalls } = await scriptedModernServer((_request, call) => + call === 1 ? { resultType: 'input_required', inputRequests: { login: ELICIT_ENTRY }, requestState: 's' } : COMPLETE_RESULT + ); + const log: string[] = []; + const client = makeClient({ + extensions: [ + { + id: 'com.example/audit', + setup: ctx => + ctx.use('elicitation/create', async (request, requestCtx, next) => { + log.push('in'); + const result = await next(request, requestCtx); + log.push('out'); + return { ...result, content: { name: 'rewritten' } }; + }) + } + ] + }); + client.setRequestHandler('elicitation/create', async () => { + log.push('handler'); + return { action: 'accept', content: { name: 'octocat' } } satisfies ElicitResult; + }); + await client.connect(clientTx); + + await client.callTool({ name: 'deploy', arguments: {} }); + expect(log).toEqual(['in', 'handler', 'out']); + expect((toolCalls[1]!.params as Record).inputResponses).toEqual({ + login: { action: 'accept', content: { name: 'rewritten' } } + }); + + await client.close(); + }); }); describe('manual mode', () => { diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index f2fb4a86ef..c54ef50ec3 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -748,7 +748,8 @@ export abstract class Protocol { * input request). */ protected _getRequestHandler(method: string): ((request: JSONRPCRequest, ctx: ContextT) => Promise) | undefined { - return this._requestHandlers.get(method); + const handler = this._requestHandlers.get(method); + return handler === undefined ? undefined : this._withMiddleware(method, handler); } private async _oncancel(notification: CancelledNotification): Promise { @@ -1891,13 +1892,23 @@ export abstract class Protocol { */ 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); - if (stack.length === 0) return base; - let composed: (request: JSONRPCRequest, ctx: ContextT) => Promise = + if (!this._requestMiddleware.some(entry => entry.method === method)) return base; + return this._withMiddleware( + method, base ?? - (async () => { - throw new ProtocolError(ProtocolErrorCode.MethodNotFound, 'Method not found'); - }); + (async () => { + throw new ProtocolError(ProtocolErrorCode.MethodNotFound, 'Method not found'); + }) + ); + } + + /** `handler` with the middleware for `method` composed around it, first registered outermost. */ + private _withMiddleware( + method: string, + handler: (request: JSONRPCRequest, ctx: ContextT) => Promise + ): (request: JSONRPCRequest, ctx: ContextT) => Promise { + const stack = this._requestMiddleware.filter(entry => entry.method === method); + let composed = handler; // Wrap from the innermost (last registered) outwards so the first // registered middleware ends up outermost. for (const { middleware } of stack.toReversed()) { From ce855ef05881fecd20d426f426e9ea5590a030af Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 12:26:22 +0000 Subject: [PATCH 03/10] fix: refuse core method names of every protocol version on the extension path ctx.setRequestHandler only checked the 2026-07-28 method registry, which holds no client-served methods and drops several 2025-11-25 ones. A client extension could register elicitation/create, sampling/createMessage or roots/list, and a server extension logging/setLevel or resources/subscribe, though the JSDoc and docs say a core name throws. Check both eras, keeping the tasks/get and tasks/cancel names SEP-2663 reuses for the Tasks extension. --- packages/client/test/client/extensions.test.ts | 8 +++++--- packages/core-internal/src/shared/extensions.ts | 9 +++++---- packages/server/test/server/extensions.test.ts | 16 +++++++++------- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/client/test/client/extensions.test.ts b/packages/client/test/client/extensions.test.ts index 401da6e6c6..0395e43738 100644 --- a/packages/client/test/client/extensions.test.ts +++ b/packages/client/test/client/extensions.test.ts @@ -103,9 +103,11 @@ describe('ClientOptions.extensions', () => { id, setup: ctx => ctx.setRequestHandler(name, { params: z.looseObject({}) }, () => ({})) }); - expect(() => new Client({ name: 'c', version: '1' }, { extensions: [claims('com.example/a', 'tools/call')] })).toThrow( - 'Extension "com.example/a" cannot register "tools/call": it is a core method; wrap it with use() instead' - ); + for (const name of ['tools/call', 'elicitation/create', 'sampling/createMessage', 'roots/list']) { + expect(() => new Client({ name: 'c', version: '1' }, { extensions: [claims('com.example/a', name)] })).toThrow( + `Extension "com.example/a" cannot register "${name}": it is a core method; wrap it with use() instead` + ); + } expect( () => new Client( diff --git a/packages/core-internal/src/shared/extensions.ts b/packages/core-internal/src/shared/extensions.ts index df4dbfeb19..47e8ca4158 100644 --- a/packages/core-internal/src/shared/extensions.ts +++ b/packages/core-internal/src/shared/extensions.ts @@ -5,7 +5,7 @@ * with the same messages. */ import type { StandardSchemaV1 } from '../util/standardSchema'; -import { codecForVersion, MODERN_WIRE_REVISION } from '../wire/codec'; +import { isExtensionReusedRequestMethod, isSpecRequestMethod } from '../wire/codec'; import type { BaseContext, InferHandlerResult, Protocol } from './protocol'; /** @@ -34,8 +34,9 @@ export function requireExtension(side: 'server' | 'client', installed: ReadonlyS } /** - * `setRequestHandler` on the extension path. It refuses a 2026-07-28 core - * method name, which an extension wraps with `use` instead, and a method that + * `setRequestHandler` on the extension path. It refuses a core method name + * of any protocol version, which an extension wraps with `use` instead (except + * the names SEP-2663 reuses for the Tasks extension), and a method that * already has a handler, such as another extension's. */ export function setExtensionRequestHandler< @@ -49,7 +50,7 @@ export function setExtensionRequestHandler< schemas: { params: P; result?: R }, handler: (params: StandardSchemaV1.InferOutput

, ctx: ContextT) => InferHandlerResult | Promise> ): void { - if (codecForVersion(MODERN_WIRE_REVISION).hasRequestMethod(name)) { + if (isSpecRequestMethod(name) && !isExtensionReusedRequestMethod(name)) { throw new Error(`Extension "${extension}" cannot register "${name}": it is a core method; wrap it with use() instead`); } try { diff --git a/packages/server/test/server/extensions.test.ts b/packages/server/test/server/extensions.test.ts index f0a1d1145c..7e9ffdbbc0 100644 --- a/packages/server/test/server/extensions.test.ts +++ b/packages/server/test/server/extensions.test.ts @@ -139,14 +139,16 @@ describe('McpServerOptions.extensions', () => { expect(log).toEqual(['com.example/a in', 'com.example/b in', 'app', 'com.example/b out', 'com.example/a out']); }); - it('refuses a custom method that is a core method', () => { - const claimsCore: ServerExtension = { + it('refuses a custom method that is a core method of any protocol version', () => { + const claims = (name: string): ServerExtension => ({ id: 'com.example/core', - setup: ctx => ctx.setRequestHandler('tools/list', { params: z.looseObject({}) }, () => ({ tools: [] })) - }; - expect(() => new McpServer(info, { extensions: [claimsCore] })).toThrow( - 'Extension "com.example/core" cannot register "tools/list": it is a core method; wrap it with use() instead' - ); + setup: ctx => ctx.setRequestHandler(name, { params: z.looseObject({}) }, () => ({})) + }); + for (const name of ['tools/list', 'logging/setLevel', 'resources/subscribe', 'tasks/list']) { + expect(() => new McpServer(info, { extensions: [claims(name)] })).toThrow( + `Extension "com.example/core" cannot register "${name}": it is a core method; wrap it with use() instead` + ); + } }); it('refuses a custom method another extension already serves', () => { From 366a014b7eb684e5da7cfe55fed78134059ae665 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 12:27:14 +0000 Subject: [PATCH 04/10] docs: correct extension and middleware docs to match the code - use() middleware receives the request as it arrived, params not yet validated, not a parsed request. - Typed calls such as callTool are also routed past the codec by acceptResultType, and reject the kind as an invalid result. - Application middleware goes on mcp.server.use; McpServer has no use(). - Client custom request handlers are served on 2025-11-25 connections only. - The gate example refuses every 2025-11-25 request as written. - Move the acceptResultType comment off the codec decode comment, and the ServerExtension export off the requestStateCodec comment. --- docs/advanced/extensions.md | 10 ++--- packages/client/src/client/extension.ts | 3 +- packages/core-internal/src/shared/protocol.ts | 44 +++++++++---------- packages/server/src/index.ts | 2 +- packages/server/src/server/extension.ts | 2 +- 5 files changed, 30 insertions(+), 31 deletions(-) diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index 3786688513..953e714bde 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -22,7 +22,7 @@ An extension is an `id` and a `setup(ctx)` function. What is both required and d | `ctx.use(method, middleware)` | Middleware around whatever handler serves `method`, core or custom. | | `ctx.mcp` | The `McpServer`, for `registerTool`, `registerResource` and `ResourceTemplate`. | -This extension adds a custom method, and refuses `tools/call` from clients that didn't declare it: +This extension adds a custom method, and refuses `tools/call` from clients that didn't declare it. It reads the 2026-07-28 per-request envelope, so as written it refuses every 2025-11-25 request: ```ts import type { ServerExtension } from '@modelcontextprotocol/server'; @@ -88,8 +88,8 @@ A `setup` that throws fails the constructor, so a server with a partly set up ex The client takes the same shape, `{ id, setup(ctx) }`, in `ClientOptions.extensions`. The client advertises each extension 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. Its context has: - `ctx.capability(settings)`, `ctx.use(method, middleware)` and `ctx.require(extensionId)`, as on the server. -- `ctx.setRequestHandler(name, schemas, handler)` for custom server-to-client requests. -- `ctx.acceptResultType(method, resultType)`, so results of `method` may carry a `resultType` the extension defines. The explicit-schema `request()` path then hands them to the caller's schema as-is, instead of rejecting the unknown kind. +- `ctx.setRequestHandler(name, schemas, handler)` for custom server-to-client requests. These are served on 2025-11-25 connections only: 2026-07-28 has no server-to-client request channel. +- `ctx.acceptResultType(method, resultType)`, so results of `method` may carry a `resultType` the extension defines. The explicit-schema `request()` path then hands them to the caller's schema as-is, instead of rejecting the unknown kind; typed calls such as `callTool` still reject them as an invalid result. - `ctx.client`, the client being set up. ```ts @@ -108,9 +108,9 @@ const client = new Client({ name: 'gated-client', version: '1.0.0' }, { extensio ## How middleware composes -`ctx.setRequestHandler` registers the route handler. `ctx.use(method, middleware)` is the middleware around it, in the Koa style: `await next(request, requestCtx)` yields the result, and the middleware returns what goes on the wire. It wraps whatever handler serves `method` at dispatch time, including one another extension registered. 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`. +`ctx.setRequestHandler` registers the route handler. `ctx.use(method, middleware)` is the middleware around it, in the Koa style. It receives the request as it arrived, before the handler validates its params: `await next(request, requestCtx)` yields the result, and the middleware returns what goes on the wire. It wraps whatever handler serves `method` at dispatch time, including one another extension registered. 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 outermost. Extensions are set up in array order, so the first extension's middleware is outermost, and all of it runs outside middleware the application adds afterwards with `server.use`. Method names are exact, and there is no wildcard. +Several middleware nest in registration order, the first outermost. Extensions are set up in array order, so the first extension's middleware is outermost, and all of it runs outside middleware the application adds afterwards with `server.server.use`. Method names are exact, and there is no wildcard. An extension wraps the methods it doesn't own with `ctx.use`, and never registers them again; `ctx.setRequestHandler` refuses to. Something every peer must know about the extension belongs in its advertised capability (`ctx.capability`), 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. diff --git a/packages/client/src/client/extension.ts b/packages/client/src/client/extension.ts index 37ebf685cb..a5030d528d 100644 --- a/packages/client/src/client/extension.ts +++ b/packages/client/src/client/extension.ts @@ -71,7 +71,8 @@ export interface ClientExtensionContext { * Registers the handler for a custom server-to-client request, like * `Client.setRequestHandler` with schemas, but checked: throws when `name` * is a core method (wrap those with `use` instead) or when a handler - * already serves it, such as another extension's. + * already serves it, such as another extension's. Served on 2025-11-25 + * connections only: 2026-07-28 has no server-to-client request channel. */ setRequestHandler

( name: string, diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index c54ef50ec3..460f2bd0ea 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -206,9 +206,9 @@ 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. + * Request middleware (see `Protocol.use`): receives the JSON-RPC request as + * received (its params not yet validated), the context, and `next` — the + * handler it wraps — and returns the result that goes on the wire. * * Experimental: this shape may change in a minor release. * @@ -1536,6 +1536,18 @@ export abstract class Protocol { return reject(response); } + // Extension result kinds (see `acceptResultType`) skip the codec and reach the caller's schema as-is. + 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; + } + // Codec decode hop — the structural V-1 home. The era codec // owns the raw-first resultType postures (Q1-SD3): // - 2026 era: REQUIRED discriminator; absent → typed error @@ -1550,21 +1562,6 @@ 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); @@ -1814,8 +1811,9 @@ export abstract class Protocol { /** * 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 + * receives the JSON-RPC request as received (its params not yet + * validated), the context, and `next` — the handler it wraps, which + * validates them — 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 @@ -1855,9 +1853,9 @@ export abstract class Protocol { * 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. Client extensions reach this through + * result schema as-is, discriminator included. Typed spec calls such as + * `callTool` validate it against the spec result schema, which rejects it + * with `InvalidResult`. Client extensions reach this through * `ctx.acceptResultType` in their `setup`. * * @returns A function that withdraws the declaration. diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 55a934f926..f42c3777af 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -54,9 +54,9 @@ export type { OriginValidationResult } from './server/middleware/originValidatio export { localhostAllowedOrigins, originValidationResponse, validateOriginHeader } from './server/middleware/originValidation'; export type { PerRequestHTTPServerTransportOptions, PerRequestMessageExtra, PerRequestResponseMode } from './server/perRequestTransport'; export { PerRequestHTTPServerTransport } from './server/perRequestTransport'; +export type { ServerExtension, ServerExtensionContext } from './server/extension'; // Opt-in HMAC sealing for the multi-round-trip requestState (SEP-2322): the // convenience codec consumers drop into ServerOptions.requestState.verify. -export type { ServerExtension, ServerExtensionContext } from './server/extension'; export type { RequestStateCodec, RequestStateCodecOptions } from './server/requestStateCodec'; export { createRequestStateCodec } from './server/requestStateCodec'; export type { ServerOptions } from './server/server'; diff --git a/packages/server/src/server/extension.ts b/packages/server/src/server/extension.ts index fc57768a20..19c609cac3 100644 --- a/packages/server/src/server/extension.ts +++ b/packages/server/src/server/extension.ts @@ -66,7 +66,7 @@ export interface ServerExtensionContext { /** * Middleware around whatever handler serves `method`, including a core * method or another extension's: it may answer, transform or refuse. Runs - * outside middleware the application adds later with `server.use`, and + * outside middleware the application adds later with `mcp.server.use`, and * inside middleware from extensions earlier in `extensions`. See * `Protocol.use` for how middleware composes. */ From 91add71bd26d7ac2bb5d6ed4fc2c35447dda2d20 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 12:32:40 +0000 Subject: [PATCH 05/10] fix(core): consult acceptResultType on the explicit-schema request() path only The check sat in the request funnel every typed spec call shares, so once a client extension accepted resultType "task" on tools/call, a plain client.callTool also skipped the codec. McpServer adds content: [] to a task handle, so the handle then parsed as an empty CallToolResult and the task was lost to the caller. Gate the bypass on the explicit-schema overload (and its retry legs), as the JSDoc already said; typed calls answer UnsupportedResultType again. Restores that JSDoc and corrects the guide. --- docs/advanced/extensions.md | 2 +- .../client/test/client/extensions.test.ts | 22 +++++++++++++++---- packages/core-internal/src/shared/protocol.ts | 17 ++++++++------ 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index 953e714bde..8cac2edb3b 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -89,7 +89,7 @@ The client takes the same shape, `{ id, setup(ctx) }`, in `ClientOptions.extensi - `ctx.capability(settings)`, `ctx.use(method, middleware)` and `ctx.require(extensionId)`, as on the server. - `ctx.setRequestHandler(name, schemas, handler)` for custom server-to-client requests. These are served on 2025-11-25 connections only: 2026-07-28 has no server-to-client request channel. -- `ctx.acceptResultType(method, resultType)`, so results of `method` may carry a `resultType` the extension defines. The explicit-schema `request()` path then hands them to the caller's schema as-is, instead of rejecting the unknown kind; typed calls such as `callTool` still reject them as an invalid result. +- `ctx.acceptResultType(method, resultType)`, so results of `method` may carry a `resultType` the extension defines. The explicit-schema `request()` path then hands them to the caller's schema as-is, instead of rejecting the unknown kind; typed calls such as `callTool` keep rejecting the unknown kind. - `ctx.client`, the client being set up. ```ts diff --git a/packages/client/test/client/extensions.test.ts b/packages/client/test/client/extensions.test.ts index 0395e43738..a0c44a90bc 100644 --- a/packages/client/test/client/extensions.test.ts +++ b/packages/client/test/client/extensions.test.ts @@ -7,7 +7,12 @@ * capability settings, dependencies and accepted result kinds. */ import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal'; -import { CLIENT_CAPABILITIES_META_KEY, InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal'; +import { + CLIENT_CAPABILITIES_META_KEY, + InMemoryTransport, + LATEST_PROTOCOL_VERSION, + SdkErrorCode +} from '@modelcontextprotocol/core-internal'; import { describe, expect, it } from 'vitest'; import * as z from 'zod/v4'; @@ -70,7 +75,16 @@ async function scriptedServer(era: 'modern' | 'legacy') { void serverTx.send({ jsonrpc: '2.0', id: request.id, - result: { resultType: 'task', taskId: 't-1', status: 'working', createdAt: 'now', lastUpdatedAt: 'now', ttlMs: null } + // McpServer adds `content: []` to a task handle, so it also parses as a CallToolResult. + result: { + resultType: 'task', + content: [], + taskId: 't-1', + status: 'working', + createdAt: 'now', + lastUpdatedAt: 'now', + ttlMs: null + } }); } else if (request.method === 'tools/list') { void serverTx.send({ @@ -171,8 +185,8 @@ describe('ClientOptions.extensions', () => { 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/); + // Typed calls keep the closed vocabulary: callTool refuses the kind rather than return the handle as a tool result. + await expect(client.callTool({ name: 'slow', arguments: {} })).rejects.toMatchObject({ code: SdkErrorCode.UnsupportedResultType }); await client.close(); }); diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 460f2bd0ea..663a31ec5a 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -1307,7 +1307,7 @@ export abstract class Protocol { if (isStandardSchema(schemaOrOptions)) { // Only the Tasks extension names skip the era gate here; every other spec method stays gated. if (!isExtensionReusedRequestMethod(request.method)) this._assertOutboundRequestInEra(codec, request.method); - return this._requestWithSchemaViaCodec(codec, request, schemaOrOptions, maybeOptions); + return this._requestWithSchemaViaCodec(codec, request, schemaOrOptions, maybeOptions, true); } this._assertOutboundRequestInEra(codec, request.method); const validate = codecResultValidator(codec, request.method); @@ -1400,7 +1400,9 @@ export abstract class Protocol { codec: WireCodec, request: Request, resultSchema: T, - options?: RequestOptions + options?: RequestOptions, + // Only the explicit-schema `request()` path consults `acceptResultType`. + acceptExtensionResultTypes = false ): Promise> { const { relatedRequestId, resumptionToken, onresumptiontoken, headers } = options ?? {}; // Flow start for non-complete result resolution: `maxTotalTimeout` @@ -1537,7 +1539,7 @@ export abstract class Protocol { } // Extension result kinds (see `acceptResultType`) skip the codec and reach the caller's schema as-is. - if (this._isAcceptedResultType(request.method, response.result)) { + if (acceptExtensionResultTypes && this._isAcceptedResultType(request.method, response.result)) { validateStandardSchema(resultSchema, response.result).then(parseResult => { if (parseResult.success) { resolve(parseResult.data); @@ -1592,7 +1594,8 @@ export abstract class Protocol { codec, params === undefined ? { method: request.method } : { method: request.method, params }, resultSchema, - legOptions + legOptions, + acceptExtensionResultTypes ) }; return resolve(this._resolveNonCompleteResult(decoded, flow) as Promise>); @@ -1853,9 +1856,9 @@ export abstract class Protocol { * 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. Typed spec calls such as - * `callTool` validate it against the spec result schema, which rejects it - * with `InvalidResult`. Client extensions reach this through + * result schema as-is, discriminator included. Only the explicit-schema + * `request(request, resultSchema)` path consults this; typed spec calls + * such as `callTool` keep the closed vocabulary. Client extensions reach this through * `ctx.acceptResultType` in their `setup`. * * @returns A function that withdraws the declaration. From a7375d19fdc2c6b5a6e7f1536c9329bd87371fef Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 13:35:56 +0000 Subject: [PATCH 06/10] style(server): sort the ServerExtension export under its own comment The export sat under the requestStateCodec comment, then under the OAuth metadata comment once sorted; it now has its own. --- packages/server/src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index f42c3777af..be34ceda13 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -42,6 +42,8 @@ export type { BearerAuthOptions, OAuthTokenVerifier, VerifyBearerTokenOptions } export { bearerAuthChallengeResponse, requireBearerAuth, verifyBearerToken } from './server/middleware/bearerAuth'; export type { HostHeaderValidationResult } from './server/middleware/hostHeaderValidation'; export { hostHeaderValidationResponse, localhostAllowedHostnames, validateHostHeader } from './server/middleware/hostHeaderValidation'; +// Server extensions (experimental): `McpServerOptions.extensions`. +export type { ServerExtension, ServerExtensionContext } from './server/extension'; // OAuth discovery documents (RFC 9728 / RFC 8414) for web-standard hosts; the // Express metadata router in @modelcontextprotocol/express adapts the same core. export type { AuthMetadataOptions } from './server/middleware/oauthMetadata'; @@ -54,7 +56,6 @@ export type { OriginValidationResult } from './server/middleware/originValidatio export { localhostAllowedOrigins, originValidationResponse, validateOriginHeader } from './server/middleware/originValidation'; export type { PerRequestHTTPServerTransportOptions, PerRequestMessageExtra, PerRequestResponseMode } from './server/perRequestTransport'; export { PerRequestHTTPServerTransport } from './server/perRequestTransport'; -export type { ServerExtension, ServerExtensionContext } from './server/extension'; // Opt-in HMAC sealing for the multi-round-trip requestState (SEP-2322): the // convenience codec consumers drop into ServerOptions.requestState.verify. export type { RequestStateCodec, RequestStateCodecOptions } from './server/requestStateCodec'; From 35c76840f6d77d7f901df0e0f9081afc2cc91b49 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 13:35:56 +0000 Subject: [PATCH 07/10] feat: async extension setup, extension-owned capability entries, 404 through middleware - setup may return a promise. The setups after it wait for it, so they still run in array order, and connect (Protocol, and Server before its one-connection check) waits for all of them and rejects with the error if one fails. Nothing is processed with an extension partly set up, and a failure that nobody connects for is not an unhandled rejection. - Extension middleware (ctx.use) is kept in its own tier, composed outside middleware the application adds with use(), so an async setup that registers after the application cannot end up inside it. - An extension's capabilities.extensions entry belongs to it: an entry for an installed id in the capabilities option fails construction, and registerCapabilities refuses it later. It was silently replaced by {}. - Middleware on a method with no handler: when the MethodNotFound that next throws is passed through, the per-request HTTP transport answers 404, as for a method with no handler and as Hono answers notFound through route middleware. A middleware answer keeps its own status. - Documented that middleware sees the request before params validation and before the requestState verify hook, and that an answer without next skips result validation. Both installers now share installExtensions. --- .changeset/server-extensions.md | 4 +- docs/advanced/extensions.md | 37 ++++- packages/client/src/client/client.ts | 4 +- packages/client/src/client/extension.ts | 62 +++++--- .../client/test/client/extensions.test.ts | 34 +++++ .../core-internal/src/shared/extensions.ts | 135 +++++++++++++++++- packages/core-internal/src/shared/protocol.ts | 76 +++++++--- packages/server/src/server/extension.ts | 51 ++++--- .../server/src/server/perRequestTransport.ts | 6 +- packages/server/src/server/server.ts | 6 + .../server/test/server/extensions.test.ts | 93 ++++++++++++ 11 files changed, 433 insertions(+), 75 deletions(-) diff --git a/.changeset/server-extensions.md b/.changeset/server-extensions.md index 0c6df9f8f1..af82c06d65 100644 --- a/.changeset/server-extensions.md +++ b/.changeset/server-extensions.md @@ -4,8 +4,8 @@ '@modelcontextprotocol/server': minor --- -Server and client extensions (experimental: the `extensions` option, the extension shapes and their contexts, `use` and `acceptResultType` may change in a minor release). An extension is `{ id, setup(ctx) }`, passed in `McpServerOptions.extensions` or `ClientOptions.extensions`. `setup` runs at construction, in array order, and adds everything through its context: `ctx.capability(settings)` sets what is advertised under `capabilities.extensions[id]` (`{}` by default), `ctx.setRequestHandler(name, schemas, handler)` registers the handler for a custom request method and refuses core method names and methods that already have a handler, `ctx.use(method, middleware)` wraps a method's handler, and `ctx.require(extensionId)` declares that the extension needs another extension, failing construction when it isn't installed. The server context also carries `ctx.mcp`, the `McpServer`; the client context carries `ctx.client` and `ctx.acceptResultType`. A duplicate id fails construction before any extension is set up. +Server and client extensions (experimental: the `extensions` option, the extension shapes and their contexts, `use` and `acceptResultType` may change in a minor release). An extension is `{ id, setup(ctx) }`, passed in `McpServerOptions.extensions` or `ClientOptions.extensions`. `setup` runs at construction, in array order, and adds everything through its context. It may be async: the setups after it wait for it, and `connect` waits for all of them and rejects if one fails. Its context provides: `ctx.capability(settings)` sets what is advertised under `capabilities.extensions[id]` (`{}` by default), `ctx.setRequestHandler(name, schemas, handler)` registers the handler for a custom request method and refuses core method names and methods that already have a handler, `ctx.use(method, middleware)` wraps a method's handler, and `ctx.require(extensionId)` declares that the extension needs another extension, failing construction when it isn't installed. The server context also carries `ctx.mcp`, the `McpServer`; the client context carries `ctx.client` and `ctx.acceptResultType`. A duplicate id, or an entry for an installed id in `capabilities.extensions`, fails construction before any extension is set up; `registerCapabilities` also refuses an installed id's entry. -`Protocol.use(method, (request, ctx, next) => …)` is 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. `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`. +`Protocol.use(method, (request, ctx, next) => …)` is middleware around the registered handler, composed at dispatch time in registration order, inside middleware extensions add with `ctx.use`, so middleware on `tools/call` applies even though `McpServer` registers that handler lazily; it returns a remover. Middleware sees the request before params validation and the `requestState.verify` hook. On a method with no handler, `next` throws `MethodNotFound`, which a per-request HTTP exchange answers with 404 when the middleware passes it through. `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 8cac2edb3b..4f4db8c32b 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -76,10 +76,25 @@ const server = new McpServer({ name: 'gated', version: '1.0.0' }, { extensions: The server then: -1. Checks the ids. A duplicate fails construction before any extension is set up. +1. Checks the ids. A duplicate fails construction before any extension is set up, and so does an entry for an installed id in `capabilities.extensions`: an extension's entry belongs to it, so set its settings with `ctx.capability`. `registerCapabilities` refuses that entry later too. 2. In array order, advertises each extension under `capabilities.extensions[id]` as `{}` and runs its `setup`. Legacy connections see the map in the `initialize` result, and 2026-07-28 connections see it in `server/discover`. Settings from `ctx.capability` replace the `{}`. -A `setup` that throws fails the constructor, so a server with a partly set up extension never exists. That includes `ctx.require` for an extension the server doesn't install. The ids are known before any `setup` runs, so a required extension can be listed before or after the one that needs it. +A `setup` that throws fails the constructor, so a server with a partly set up extension never exists. That includes `ctx.require` for an extension the server doesn't install. + +`setup` may be async, to load what the extension serves: + +```ts +const catalog: ServerExtension = { + id: 'com.example/catalog', + async setup(ctx) { + const entries = await loadCatalog(); + ctx.capability({ size: entries.length }); + ctx.setRequestHandler('catalog/list', { params: z.looseObject({}) }, () => ({ entries })); + } +}; +``` + +The setups after an async one wait for it, so they still run in array order. `connect` waits for every setup to finish before the server processes anything, and rejects with the error if one fails. The ids are known before any `setup` runs, so a required extension can be listed before or after the one that needs it. `ctx.require` is for an extension that can't work without another one. When the other one only adds something optional, check for what you need in code, such as in a handler or middleware at the moment it runs, and carry on without it. Extensions are an `McpServer` option; the low-level `Server` doesn't take them. @@ -87,7 +102,7 @@ A `setup` that throws fails the constructor, so a server with a partly set up ex The client takes the same shape, `{ id, setup(ctx) }`, in `ClientOptions.extensions`. The client advertises each extension 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. Its context has: -- `ctx.capability(settings)`, `ctx.use(method, middleware)` and `ctx.require(extensionId)`, as on the server. +- `ctx.capability(settings)`, `ctx.use(method, middleware)` and `ctx.require(extensionId)`, as on the server. `setup` may be async here too; `connect` waits for it before sending anything. - `ctx.setRequestHandler(name, schemas, handler)` for custom server-to-client requests. These are served on 2025-11-25 connections only: 2026-07-28 has no server-to-client request channel. - `ctx.acceptResultType(method, resultType)`, so results of `method` may carry a `resultType` the extension defines. The explicit-schema `request()` path then hands them to the caller's schema as-is, instead of rejecting the unknown kind; typed calls such as `callTool` keep rejecting the unknown kind. - `ctx.client`, the client being set up. @@ -108,9 +123,17 @@ const client = new Client({ name: 'gated-client', version: '1.0.0' }, { extensio ## How middleware composes -`ctx.setRequestHandler` registers the route handler. `ctx.use(method, middleware)` is the middleware around it, in the Koa style. It receives the request as it arrived, before the handler validates its params: `await next(request, requestCtx)` yields the result, and the middleware returns what goes on the wire. It wraps whatever handler serves `method` at dispatch time, including one another extension registered. 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`. +`ctx.setRequestHandler` registers the route handler. `ctx.use(method, middleware)` is the middleware around it, in the Koa style: `await next(request, requestCtx)` yields the result, and the middleware returns what goes on the wire. It wraps whatever handler serves `method` at dispatch time, including one another extension registered. That matters for `tools/call`, which `McpServer` registers on the first tool registration: middleware installed at construction still applies. + +Middleware works at the JSON-RPC level, before the handler's own steps: + +- It receives the request as it arrived. Params are not validated yet. +- `requestCtx.mcpReq.requestState()` is the raw wire value. The `requestState.verify` hook runs inside the handler, so don't trust that state in middleware. +- A result the middleware returns without calling `next` skips the handler's result validation. + +With no underlying handler, `next` throws `MethodNotFound`. If the middleware lets it through, the request is answered as an unknown method (HTTP 404 on a per-request HTTP exchange), the way a web framework answers 404 through route middleware. If the middleware answers instead, its answer goes out. Over 2026-07-28, a request that lacks the `_meta` envelope is refused with `-32602` before middleware runs, even for a method only middleware serves. -Several middleware nest in registration order, the first outermost. Extensions are set up in array order, so the first extension's middleware is outermost, and all of it runs outside middleware the application adds afterwards with `server.server.use`. Method names are exact, and there is no wildcard. +Several middleware nest in registration order, the first outermost. Extension middleware runs outside middleware the application adds with `server.server.use`, whenever either was added, and extensions' middleware nests in array order, the first extension's outermost. Method names are exact, and there is no wildcard. An extension wraps the methods it doesn't own with `ctx.use`, and never registers them again; `ctx.setRequestHandler` refuses to. Something every peer must know about the extension belongs in its advertised capability (`ctx.capability`), 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. @@ -120,6 +143,6 @@ A thrown `ProtocolError` becomes the JSON-RPC error response. Inside a tool hand - An extension is `{ id, setup(ctx) }`, passed in `McpServerOptions.extensions` or `ClientOptions.extensions`. - `setup` registers everything through `ctx`: `capability`, `setRequestHandler`, `use`, `require`, plus `ctx.mcp` on the server and `acceptResultType` on the client. -- Extensions are set up in array order. Duplicate ids, and a `ctx.require` for an extension that isn't installed, fail construction. -- Middleware composes at dispatch time, first registered outermost, and applies to handlers registered later, including other extensions' handlers. +- Extensions are set up in array order, and `setup` may be async; `connect` waits for it. Duplicate ids, a `capabilities.extensions` entry for an installed id, and a `ctx.require` for an extension that isn't installed fail construction. +- Middleware composes at dispatch time, extensions' outside the application's, and applies to handlers registered later, including other extensions' handlers. It sees the request before validation. - The SDK owns the hooks, the checks 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 d264b265ca..2e4a26b186 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -53,6 +53,7 @@ import type { XMcpHeaderScanResult } from '@modelcontextprotocol/core-internal'; import { + assertExtensionCapabilitiesUnowned, buildMcpParamHeaders, codecForVersion, DEFAULT_REQUEST_TIMEOUT_MSEC, @@ -695,7 +696,7 @@ export class Client extends Protocol { this._listChangedConfig = options.listChanged; } - installClientExtensions(this, options?.extensions ?? []); + installClientExtensions(this, options?.extensions ?? [], options?.capabilities); } protected override buildContext(ctx: BaseContext, _transportInfo?: MessageExtraInfo): ClientContext { @@ -828,6 +829,7 @@ export class Client extends Protocol { if (this.transport) { throw new Error('Cannot register capabilities after connecting to transport'); } + assertExtensionCapabilitiesUnowned(this, capabilities); this._capabilities = mergeCapabilities(this._capabilities, capabilities); } diff --git a/packages/client/src/client/extension.ts b/packages/client/src/client/extension.ts index a5030d528d..7b80d23e68 100644 --- a/packages/client/src/client/extension.ts +++ b/packages/client/src/client/extension.ts @@ -1,4 +1,5 @@ import type { + ClientCapabilities, ClientContext, InferHandlerResult, JSONObject, @@ -6,7 +7,13 @@ import type { RequestMiddleware, StandardSchemaV1 } from '@modelcontextprotocol/core-internal'; -import { requireExtension, setExtensionRequestHandler, uniqueExtensionIds } from '@modelcontextprotocol/core-internal'; +import { + installExtensions, + requireExtension, + setExtensionRequestHandler, + useAsExtension, + writeExtensionCapability +} from '@modelcontextprotocol/core-internal'; import type { Client } from './client'; @@ -36,8 +43,12 @@ export interface ClientExtension { * `capabilities.extensions`, and unique per client. */ readonly id: string; - /** Adds the extension to the client. Called once, at construction, in `extensions` order. */ - setup(ctx: ClientExtensionContext): void; + /** + * Adds the extension to the client. Called once, at construction, in + * `extensions` order. It may be async: the setups after it wait for it, + * and `connect` waits for all of them and rejects if one fails. + */ + setup(ctx: ClientExtensionContext): void | Promise; } /** @@ -54,7 +65,8 @@ export interface ClientExtensionContext { * Sets what the client advertises under `capabilities.extensions[id]`: * in `initialize` on a legacy connection, and in every request's * client-capabilities envelope on a 2026-07-28 connection. Without a call, - * the client advertises `{}`. + * the client advertises `{}`. The entry belongs to the extension: setting + * it anywhere else throws. */ capability(settings: JSONObject): void; /** @@ -65,7 +77,12 @@ export interface ClientExtensionContext { * optional, check in code instead and carry on without it. */ require(extensionId: `${string}/${string}`): void; - /** Middleware around whatever handler serves a server-to-client request `method`. See `Protocol.use`. */ + /** + * Middleware around whatever handler serves a server-to-client request + * `method`. Runs outside middleware the application adds with + * `client.use`, and inside middleware from extensions earlier in + * `extensions`. See `Protocol.use`. + */ use(method: RequestMethod | (string & {}), middleware: RequestMiddleware): void; /** * Registers the handler for a custom server-to-client request, like @@ -88,25 +105,24 @@ export interface ClientExtensionContext { } /** - * Sets up `extensions` on `client`, in order. Ids are checked before anything - * is set up, so a duplicate leaves the client untouched. A `setup` that - * throws, including an unmet `ctx.require`, fails the `Client` constructor - * that called this. + * Sets up `extensions` on `client`, in order (see `installExtensions`). A + * `setup` that throws, including an unmet `ctx.require`, fails the `Client` + * constructor; one that rejects fails `connect`. * * Package-internal: called by the `Client` constructor. */ -export function installClientExtensions(client: Client, extensions: readonly ClientExtension[]): void { - const ids = uniqueExtensionIds(extensions); - for (const extension of extensions) { - if (typeof extension.setup !== 'function') { - throw new TypeError(`Extension "${String((extension as { id: unknown }).id)}" has no setup`); - } - } - - for (const extension of extensions) { - client.registerCapabilities({ extensions: { [extension.id]: {} } }); - extension.setup(contextFor(client, extension.id, ids)); - } +export function installClientExtensions( + client: Client, + extensions: readonly ClientExtension[], + capabilities: ClientCapabilities | undefined +): void { + installExtensions( + client, + extensions, + capabilities, + capabilities => client.registerCapabilities(capabilities), + (id, installed) => contextFor(client, id, installed) + ); } /** The context one extension's `setup` receives; everything it registers is attributed to `id`. */ @@ -114,13 +130,13 @@ function contextFor(client: Client, id: string, installed: ReadonlySet): return { client, capability(settings) { - client.registerCapabilities({ extensions: { [id]: settings } }); + writeExtensionCapability(client, id, settings, capabilities => client.registerCapabilities(capabilities)); }, require(extensionId) { requireExtension('client', installed, id, extensionId); }, use(method, middleware) { - client.use(method, middleware); + useAsExtension(client, method, middleware); }, setRequestHandler(name, schemas, handler) { setExtensionRequestHandler(client, id, name, schemas, handler); diff --git a/packages/client/test/client/extensions.test.ts b/packages/client/test/client/extensions.test.ts index a0c44a90bc..7f24480eff 100644 --- a/packages/client/test/client/extensions.test.ts +++ b/packages/client/test/client/extensions.test.ts @@ -200,4 +200,38 @@ describe('ClientOptions.extensions', () => { expect((response as { result?: unknown }).result).toEqual({ pong: true }); await client.close(); }); + it('waits in connect for an async setup, so the first request already carries its capability', async () => { + const { clientTx, written } = await scriptedServer('legacy'); + const slow: ClientExtension = { + id: 'com.example/slow', + async setup(ctx) { + await new Promise(resolve => setTimeout(resolve, 5)); + ctx.capability({ loaded: true }); + } + }; + const client = new Client({ name: 'c', version: '1' }, { extensions: [slow] }); + 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/slow': { loaded: true } } + }); + await client.close(); + }); + + it('fails connect when an async setup rejects, before anything is sent', async () => { + const { clientTx, written } = await scriptedServer('legacy'); + const broken: ClientExtension = { id: 'com.example/broken', setup: async () => Promise.reject(new Error('no key')) }; + const client = new Client({ name: 'c', version: '1' }, { extensions: [broken] }); + await expect(client.connect(clientTx)).rejects.toThrow('no key'); + expect(written).toEqual([]); + }); + + it('refuses capability entries for an installed id set anywhere but the extension', () => { + expect( + () => + new Client({ name: 'c', version: '1' }, { capabilities: { extensions: { [EXT_ID]: {} } }, extensions: [gateExtension([])] }) + ).toThrow(`Extension "${EXT_ID}" is also set in capabilities.extensions`); + const client = new Client({ name: 'c', version: '1' }, { extensions: [gateExtension([])] }); + expect(() => client.registerCapabilities({ extensions: { [EXT_ID]: {} } })).toThrow(/belongs to the installed/); + }); }); diff --git a/packages/core-internal/src/shared/extensions.ts b/packages/core-internal/src/shared/extensions.ts index 47e8ca4158..ddc5e63e1d 100644 --- a/packages/core-internal/src/shared/extensions.ts +++ b/packages/core-internal/src/shared/extensions.ts @@ -4,9 +4,142 @@ * `Client` set extensions up with these, so both sides refuse the same things * with the same messages. */ +import type { JSONObject } from '../types/spec.types.2026-07-28'; import type { StandardSchemaV1 } from '../util/standardSchema'; import { isExtensionReusedRequestMethod, isSpecRequestMethod } from '../wire/codec'; -import type { BaseContext, InferHandlerResult, Protocol } from './protocol'; +import type { BaseContext, InferHandlerResult, Protocol, RequestMiddleware } from './protocol'; + +/** What installed extensions add to one server or client, kept off its public surface. */ +interface ExtensionState { + /** Ids of the installed extensions; their capability entries belong to them. */ + readonly owned: ReadonlySet; + /** Middleware from `ctx.use`, in setup order; it runs outside middleware added with `use()`. */ + readonly middleware: Array<{ method: string; middleware: RequestMiddleware }>; + /** Pending while a `setup` is unfinished, and kept when one fails; `connect` waits for it. */ + ready?: Promise; + /** The extension whose capability entry is being written. */ + writing?: string; +} + +const states = new WeakMap(); + +/** The extension state of `protocol`, when it installed any extensions. */ +export function extensionStateOf(protocol: object): Readonly | undefined { + return states.get(protocol); +} + +/** + * Throws when `capabilities` sets an entry under `extensions` that belongs to + * an installed extension, unless that extension is writing it. Called by + * `registerCapabilities`, so the entry has one owner. + */ +export function assertExtensionCapabilitiesUnowned(protocol: object, capabilities: { extensions?: object }): void { + const state = states.get(protocol); + if (state === undefined || capabilities.extensions === undefined) return; + for (const id of Object.keys(capabilities.extensions)) { + if (state.owned.has(id) && state.writing !== id) { + throw new Error( + `capabilities.extensions["${id}"] belongs to the installed "${id}" extension; set it from the extension with ctx.capability` + ); + } + } +} + +/** Writes `settings` as the capability entry of extension `id`. */ +export function writeExtensionCapability( + protocol: object, + id: string, + settings: JSONObject, + register: (capabilities: { extensions: Record }) => void +): void { + const state = states.get(protocol); + if (state !== undefined) state.writing = id; + try { + register({ extensions: { [id]: settings } }); + } finally { + if (state !== undefined) state.writing = undefined; + } +} + +/** `ctx.use`: middleware in the extension tier, outside middleware the application adds with `use()`. */ +export function useAsExtension( + protocol: Protocol, + method: string, + middleware: RequestMiddleware +): void { + if (typeof middleware !== 'function') throw new TypeError('use: middleware is required'); + const state = states.get(protocol); + if (state === undefined) throw new Error('use: this server or client installed no extensions'); + // The protocol composes it with its own context type (see `Protocol._middlewareFor`). + state.middleware.push({ method, middleware: middleware as unknown as RequestMiddleware }); +} + +/** + * Installs `extensions` on `protocol`, in order. Before any `setup` runs it + * checks that ids are unique, that each extension has a `setup`, and that no + * entry under `capabilities.extensions` already exists for an installed id. + * Each extension is advertised as `{}`, then its `setup` runs. A `setup` that + * throws fails the caller (the constructor). A `setup` that returns a promise + * defers the ones after it until it settles, and `connect` waits for all of + * them, rejecting if one fails, so no message is processed with an extension + * partly set up. + */ +export function installExtensions }, C>( + protocol: object, + extensions: readonly E[], + existing: { extensions?: object } | undefined, + register: (capabilities: { extensions: Record }) => void, + contextFor: (id: string, installed: ReadonlySet) => C +): void { + const ids = uniqueExtensionIds(extensions); + for (const extension of extensions) { + if (typeof extension.setup !== 'function') { + throw new TypeError(`Extension "${String((extension as { id: unknown }).id)}" has no setup`); + } + } + for (const id of ids) { + if (existing?.extensions !== undefined && id in existing.extensions) { + throw new Error( + `Extension "${id}" is also set in capabilities.extensions; drop that entry and set its settings from the extension with ctx.capability` + ); + } + } + if (ids.size === 0) return; + + const state: ExtensionState = { owned: ids, middleware: [] }; + states.set(protocol, state); + const setUp = (extension: E): void | PromiseLike => { + writeExtensionCapability(protocol, extension.id, {}, register); + return extension.setup(contextFor(extension.id, ids)); + }; + let pending: Promise | undefined; + for (const extension of extensions) { + if (pending !== undefined) { + pending = pending.then(() => setUp(extension)); + continue; + } + const result = setUp(extension); + if (isThenable(result)) pending = Promise.resolve(result); + } + if (pending !== undefined) { + state.ready = pending; + // `connect` reports a failure; nothing else may treat it as unhandled. + pending.then( + () => { + state.ready = undefined; + }, + () => {} + ); + } +} + +function isThenable(value: unknown): value is PromiseLike { + return ( + (typeof value === 'object' || typeof value === 'function') && + value !== null && + typeof (value as { then?: unknown }).then === 'function' + ); +} /** * Returns the extensions' ids, or throws when two share one. Runs before any diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 663a31ec5a..24222a3d0b 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -58,6 +58,7 @@ import { isSpecRequestMethod, MODERN_WIRE_REVISION } from '../wire/codec'; +import { extensionStateOf } from './extensions'; import { manualInputRequiredValue, partitionInputResponses } from './inputRequiredEngine'; import type { Transport, TransportSendOptions } from './transport'; @@ -205,6 +206,22 @@ const RESERVED_ENVELOPE_META_KEYS: readonly string[] = [ */ const RETRY_PARAMS_KEYS = ['inputResponses', 'requestState'] as const; +// "Method not found" raised under middleware for a method with no handler, and the error responses carrying it. +const noHandlerErrors = new WeakSet(); +const noHandlerResponses = new WeakSet(); + +/** + * Whether `message` answers a request for a method with no handler, raised + * after middleware passed it through. Transports that map pre-dispatch + * rejections to an HTTP status (404 for `-32601`) treat it as one, as a web + * framework answers 404 through route middleware. + * + * Package-internal. + */ +export function isNoHandlerResponse(message: object): boolean { + return noHandlerResponses.has(message); +} + /** * Request middleware (see `Protocol.use`): receives the JSON-RPC request as * received (its params not yet validated), the context, and `next` — the @@ -812,6 +829,9 @@ export abstract class Protocol { * The caller assumes ownership of the {@linkcode Transport}, replacing any callbacks that have already been set, and expects that it is the only user of the {@linkcode Transport} instance going forward. */ async connect(transport: Transport): Promise { + // Asynchronous extension setups finish before anything is processed; a failed one fails the connect. + const extensionsReady = extensionStateOf(this)?.ready; + if (extensionsReady !== undefined) await extensionsReady; this._transport = transport; const _onclose = this.transport?.onclose; this._transport.onclose = () => { @@ -1184,6 +1204,7 @@ export abstract class Protocol { ...(error['data'] !== undefined && { data: error['data'] }) } }; + if (noHandlerErrors.has(error)) noHandlerResponses.add(errorResponse); await capturedTransport?.send(errorResponse); } ) @@ -1814,23 +1835,30 @@ export abstract class Protocol { /** * Installs middleware on the request handler for `method`. The middleware - * receives the JSON-RPC request as received (its params not yet - * validated), the context, and `next` — the handler it wraps, which - * validates them — 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. + * receives the JSON-RPC request as received, 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. + * + * Middleware sits at the JSON-RPC level, before the handler's own + * steps: params are not validated yet, `ctx.mcpReq.requestState()` is + * the raw wire value (the server's `requestState.verify` hook has not run), + * and a result the middleware returns without calling `next` skips the + * handler's result validation. * * 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. Method names are exact — there is no wildcard; what an + * registers on the first tool registration) still applies. With no + * underlying handler and no fallback, `next` throws `MethodNotFound`; + * passed through, it is answered as an unknown method (HTTP 404 on a + * per-request HTTP exchange), as a web framework answers 404 through route + * middleware. Middleware runs in registration order, the first installed + * outermost, and all of it inside middleware extensions add with + * `ctx.use`. Method names are exact — there is no wildcard; what an * extension needs every peer to know belongs in its advertised - * capability, not in per-request middleware. Extensions reach this - * through `ctx.use` in their `setup`. + * capability, not in per-request middleware. * * @returns A function that removes the middleware. * @@ -1893,26 +1921,38 @@ export abstract class Protocol { */ private _resolveRequestHandler(method: string): ((request: JSONRPCRequest, ctx: ContextT) => Promise) | undefined { const base = this._requestHandlers.get(method) ?? this.fallbackRequestHandler; - if (!this._requestMiddleware.some(entry => entry.method === method)) return base; + if (this._middlewareFor(method).length === 0) return base; return this._withMiddleware( method, base ?? (async () => { - throw new ProtocolError(ProtocolErrorCode.MethodNotFound, 'Method not found'); + const error = new ProtocolError(ProtocolErrorCode.MethodNotFound, 'Method not found'); + noHandlerErrors.add(error); + throw error; }) ); } - /** `handler` with the middleware for `method` composed around it, first registered outermost. */ + /** The middleware for `method`, outermost first: extension middleware (`ctx.use`), then `use()`. */ + private _middlewareFor(method: string): RequestMiddleware[] { + // The installer records each side's middleware with its own context type. + const extension = (extensionStateOf(this)?.middleware ?? []) as unknown as Array<{ + method: string; + middleware: RequestMiddleware; + }>; + return [...extension, ...this._requestMiddleware].filter(entry => entry.method === method).map(entry => entry.middleware); + } + + /** `handler` with the middleware for `method` composed around it, outermost first. */ private _withMiddleware( method: string, handler: (request: JSONRPCRequest, ctx: ContextT) => Promise ): (request: JSONRPCRequest, ctx: ContextT) => Promise { - const stack = this._requestMiddleware.filter(entry => entry.method === method); + const stack = this._middlewareFor(method); let composed = handler; // 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/server/src/server/extension.ts b/packages/server/src/server/extension.ts index 19c609cac3..81ed03203d 100644 --- a/packages/server/src/server/extension.ts +++ b/packages/server/src/server/extension.ts @@ -6,7 +6,13 @@ import type { ServerContext, StandardSchemaV1 } from '@modelcontextprotocol/core-internal'; -import { requireExtension, setExtensionRequestHandler, uniqueExtensionIds } from '@modelcontextprotocol/core-internal'; +import { + installExtensions, + requireExtension, + setExtensionRequestHandler, + useAsExtension, + writeExtensionCapability +} from '@modelcontextprotocol/core-internal'; import type { McpServer } from './mcp'; @@ -36,8 +42,12 @@ export interface ServerExtension { * `capabilities.extensions`, and unique per server. */ readonly id: string; - /** Adds the extension to the server. Called once, at construction, in `extensions` order. */ - setup(ctx: ServerExtensionContext): void; + /** + * Adds the extension to the server. Called once, at construction, in + * `extensions` order. It may be async: the setups after it wait for it, + * and `connect` waits for all of them and rejects if one fails. + */ + setup(ctx: ServerExtensionContext): void | Promise; } /** @@ -52,7 +62,8 @@ export interface ServerExtensionContext { readonly mcp: McpServer; /** * Sets what the server advertises under `capabilities.extensions[id]`. - * Without a call, the server advertises `{}`. + * Without a call, the server advertises `{}`. The entry belongs to the + * extension: setting it anywhere else throws. */ capability(settings: JSONObject): void; /** @@ -66,7 +77,7 @@ export interface ServerExtensionContext { /** * Middleware around whatever handler serves `method`, including a core * method or another extension's: it may answer, transform or refuse. Runs - * outside middleware the application adds later with `mcp.server.use`, and + * outside middleware the application adds with `mcp.server.use`, and * inside middleware from extensions earlier in `extensions`. See * `Protocol.use` for how middleware composes. */ @@ -85,25 +96,21 @@ export interface ServerExtensionContext { } /** - * Sets up `extensions` on `mcp`, in order. Ids are checked before anything is - * set up, so a duplicate leaves the server untouched. A `setup` that throws, - * including an unmet `ctx.require`, fails the `McpServer` constructor that - * called this, so a server with a partly set up extension never exists. + * Sets up `extensions` on `mcp`, in order (see `installExtensions`). A + * `setup` that throws, including an unmet `ctx.require`, fails the + * `McpServer` constructor; one that rejects fails `connect`. * * Package-internal: called by the `McpServer` constructor. */ export function installServerExtensions(mcp: McpServer, extensions: readonly ServerExtension[]): void { - const ids = uniqueExtensionIds(extensions); - for (const extension of extensions) { - if (typeof extension.setup !== 'function') { - throw new TypeError(`Extension "${String((extension as { id: unknown }).id)}" has no setup`); - } - } - - for (const extension of extensions) { - mcp.server.registerCapabilities({ extensions: { [extension.id]: {} } }); - extension.setup(contextFor(mcp, extension.id, ids)); - } + const server = mcp.server; + installExtensions( + server, + extensions, + server.getCapabilities(), + capabilities => server.registerCapabilities(capabilities), + (id, installed) => contextFor(mcp, id, installed) + ); } /** The context one extension's `setup` receives; everything it registers is attributed to `id`. */ @@ -112,13 +119,13 @@ function contextFor(mcp: McpServer, id: string, installed: ReadonlySet): return { mcp, capability(settings) { - server.registerCapabilities({ extensions: { [id]: settings } }); + writeExtensionCapability(server, id, settings, capabilities => server.registerCapabilities(capabilities)); }, require(extensionId) { requireExtension('server', installed, id, extensionId); }, use(method, middleware) { - server.use(method, middleware); + useAsExtension(server, method, middleware); }, setRequestHandler(name, schemas, handler) { setExtensionRequestHandler(server, id, name, schemas, handler); diff --git a/packages/server/src/server/perRequestTransport.ts b/packages/server/src/server/perRequestTransport.ts index 971697d2e1..bd1a04f9da 100644 --- a/packages/server/src/server/perRequestTransport.ts +++ b/packages/server/src/server/perRequestTransport.ts @@ -52,6 +52,7 @@ import { isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, + isNoHandlerResponse, LADDER_ERROR_HTTP_STATUS, ProtocolErrorCode, SdkError, @@ -296,7 +297,10 @@ export class PerRequestHTTPServerTransport implements Transport { // response mode. const errorCode = isJSONRPCErrorResponse(message) ? message.error.code : undefined; const ladderStatus = - errorCode !== undefined && (this._dispatchWindowOpen || errorCode === ProtocolErrorCode.MissingRequiredClientCapability) + errorCode !== undefined && + (this._dispatchWindowOpen || + errorCode === ProtocolErrorCode.MissingRequiredClientCapability || + isNoHandlerResponse(message)) ? LADDER_ERROR_HTTP_STATUS[errorCode] : undefined; if (ladderStatus !== undefined && this._sse === undefined) { diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 441857f646..9e14eb5e95 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -39,10 +39,12 @@ import type { Transport } from '@modelcontextprotocol/core-internal'; import { + assertExtensionCapabilitiesUnowned, assertValidCacheHint, attachCacheHintFallback, CLIENT_CAPABILITIES_META_KEY, codecForVersion, + extensionStateOf, isInputRequiredResult, isModernProtocolVersion, LATEST_PROTOCOL_VERSION, @@ -355,6 +357,9 @@ export class Server extends Protocol { /** Attaches to the given transport; rejects while this instance is connected to another one. */ override async connect(transport: Transport): Promise { + // Waited for here, before the one-connection check, so that check and the attach below stay synchronous. + const extensionsReady = extensionStateOf(this)?.ready; + if (extensionsReady !== undefined) await extensionsReady; if (this.transport !== undefined) { throw new SdkError( SdkErrorCode.AlreadyConnected, @@ -478,6 +483,7 @@ export class Server extends Protocol { if (this.transport) { throw new SdkError(SdkErrorCode.AlreadyConnected, 'Cannot register capabilities after connecting to transport'); } + assertExtensionCapabilitiesUnowned(this, capabilities); const hadLogging = !!this._capabilities.logging; this._capabilities = mergeCapabilities(this._capabilities, capabilities); if (!hadLogging && this._capabilities.logging) { diff --git a/packages/server/test/server/extensions.test.ts b/packages/server/test/server/extensions.test.ts index 7e9ffdbbc0..ca508e3a15 100644 --- a/packages/server/test/server/extensions.test.ts +++ b/packages/server/test/server/extensions.test.ts @@ -213,3 +213,96 @@ describe('ServerExtensionContext.require', () => { ).not.toThrow(); }); }); + +describe('asynchronous setup', () => { + const tick = () => new Promise(resolve => setTimeout(resolve, 5)); + + it('defers the setups after it, and connect waits for all of them before serving', async () => { + const log: string[] = []; + const slow: ServerExtension = { + id: 'com.example/slow', + async setup(ctx) { + await tick(); + ctx.capability({ loaded: true }); + ctx.setRequestHandler('slow/ping', { params: z.looseObject({}) }, () => ({ pong: true })); + log.push('slow'); + } + }; + const mcp = new McpServer(info, { extensions: [slow, plain('com.example/after', log)] }); + expect(log).toEqual([]); + const body = await exchange(mcp.server, modernRequest('slow/ping')); + expect(body['result']).toMatchObject({ pong: true }); + expect(log).toEqual(['slow', 'com.example/after']); + expect(mcp.server.getCapabilities().extensions).toEqual({ 'com.example/slow': { loaded: true }, 'com.example/after': {} }); + }); + + it('fails connect with the setup error, so nothing is served with the extension partly set up', async () => { + const broken: ServerExtension = { + id: 'com.example/broken', + async setup() { + await tick(); + throw new Error('index unavailable'); + } + }; + const mcp = new McpServer(info, { extensions: [broken] }); + await expect(exchange(mcp.server, modernRequest('tools/list'))).rejects.toThrow('index unavailable'); + }); + + it('keeps extension middleware outside application middleware added while setup was pending', async () => { + const log: string[] = []; + const late: ServerExtension = { + id: 'com.example/late', + async setup(ctx) { + await tick(); + tracing('com.example/late', log).setup(ctx); + } + }; + const mcp = new McpServer(info, { extensions: [late] }); + mcp.server.use('tools/call', async (request, requestCtx, next) => { + log.push('app'); + return next(request, requestCtx); + }); + echo(mcp); + await exchange(mcp.server, modernRequest('tools/call', { name: 'echo', arguments: { text: 'hi' } })); + expect(log).toEqual(['com.example/late in', 'app', 'com.example/late out']); + }); +}); + +describe('capability entries of installed extensions', () => { + it('refuses an entry for the same id in capabilities.extensions at construction', () => { + expect( + () => new McpServer(info, { capabilities: { extensions: { [EXT_ID]: { exampleData: false } } }, extensions: [gate] }) + ).toThrow(`Extension "${EXT_ID}" is also set in capabilities.extensions`); + }); + + it('refuses registerCapabilities for an installed id, and leaves other entries alone', () => { + const mcp = new McpServer(info, { extensions: [gate] }); + expect(() => mcp.server.registerCapabilities({ extensions: { [EXT_ID]: {} } })).toThrow( + `capabilities.extensions["${EXT_ID}"] belongs to the installed "${EXT_ID}" extension` + ); + mcp.server.registerCapabilities({ extensions: { 'com.example/other': {} } }); + expect(mcp.server.getCapabilities().extensions).toEqual({ [EXT_ID]: { exampleData: true }, 'com.example/other': {} }); + }); +}); + +describe('middleware on a method with no handler', () => { + const peek = (answer: boolean): ServerExtension => ({ + id: 'com.example/peek', + setup: ctx => ctx.use('peek/status', (request, requestCtx, next) => (answer ? { peeked: true } : next(request, requestCtx))) + }); + + it('answers HTTP 404 when the middleware passes the not-found through, and the middleware answer otherwise', async () => { + const post = async (extension: ServerExtension) => { + const mcp = new McpServer(info, { extensions: [extension] }); + setNegotiatedProtocolVersion(mcp.server, MODERN_REVISION); + const response = await invoke(mcp.server, modernRequest('peek/status'), { classification: MODERN }); + return { status: response.status, body: (await response.json()) as Record }; + }; + const notFound = await post(peek(false)); + expect(notFound.status).toBe(404); + expect(notFound.body['error']).toMatchObject({ code: -32_601 }); + const answered = await post(peek(true)); + expect(answered.status).toBe(200); + expect(answered.body['result']).toMatchObject({ peeked: true }); + }); +}); From 985f367dc95ee774aaa70a2bc297a3a188d6b16a Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 13:58:38 +0000 Subject: [PATCH 08/10] fix(core): refuse accepted result kinds on typed calls on every era; brief comments - With acceptResultType in force, a typed call such as callTool on a 2025-11-25 connection had the codec strip resultType and returned a task handle as an empty tool result. Typed calls now reject an accepted kind with UnsupportedResultType on both eras. - ctx.mcpReq.send with an explicit schema now accepts extension result kinds like request() with one. - The middleware docs say the request arrives with wire-only material lifted onto ctx.mcpReq, not as it was received. - Cut the new comments to one line where they are not public API. --- docs/advanced/extensions.md | 4 +- packages/client/src/client/extension.ts | 8 +-- .../client/test/client/extensions.test.ts | 8 +++ .../core-internal/src/shared/extensions.ts | 17 +---- packages/core-internal/src/shared/protocol.ts | 67 ++++++++++--------- packages/server/src/server/extension.ts | 8 +-- 6 files changed, 50 insertions(+), 62 deletions(-) diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index 4f4db8c32b..7e887c793a 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -104,7 +104,7 @@ The client takes the same shape, `{ id, setup(ctx) }`, in `ClientOptions.extensi - `ctx.capability(settings)`, `ctx.use(method, middleware)` and `ctx.require(extensionId)`, as on the server. `setup` may be async here too; `connect` waits for it before sending anything. - `ctx.setRequestHandler(name, schemas, handler)` for custom server-to-client requests. These are served on 2025-11-25 connections only: 2026-07-28 has no server-to-client request channel. -- `ctx.acceptResultType(method, resultType)`, so results of `method` may carry a `resultType` the extension defines. The explicit-schema `request()` path then hands them to the caller's schema as-is, instead of rejecting the unknown kind; typed calls such as `callTool` keep rejecting the unknown kind. +- `ctx.acceptResultType(method, resultType)`, so results of `method` may carry a `resultType` the extension defines. The explicit-schema `request()` path then hands them to the caller's schema as-is, instead of rejecting the unknown kind; typed calls such as `callTool` reject it with `UnsupportedResultType` on either protocol version. - `ctx.client`, the client being set up. ```ts @@ -127,7 +127,7 @@ const client = new Client({ name: 'gated-client', version: '1.0.0' }, { extensio Middleware works at the JSON-RPC level, before the handler's own steps: -- It receives the request as it arrived. Params are not validated yet. +- It receives the request with wire-only material (the reserved `_meta` envelope keys, `inputResponses`, `requestState`) already lifted onto `requestCtx.mcpReq`. Params are not validated yet. - `requestCtx.mcpReq.requestState()` is the raw wire value. The `requestState.verify` hook runs inside the handler, so don't trust that state in middleware. - A result the middleware returns without calling `next` skips the handler's result validation. diff --git a/packages/client/src/client/extension.ts b/packages/client/src/client/extension.ts index 7b80d23e68..7568f6b93f 100644 --- a/packages/client/src/client/extension.ts +++ b/packages/client/src/client/extension.ts @@ -104,13 +104,7 @@ export interface ClientExtensionContext { acceptResultType(method: string, resultType: string): void; } -/** - * Sets up `extensions` on `client`, in order (see `installExtensions`). A - * `setup` that throws, including an unmet `ctx.require`, fails the `Client` - * constructor; one that rejects fails `connect`. - * - * Package-internal: called by the `Client` constructor. - */ +/** Package-internal: sets `extensions` up for the constructor (see `installExtensions`). */ export function installClientExtensions( client: Client, extensions: readonly ClientExtension[], diff --git a/packages/client/test/client/extensions.test.ts b/packages/client/test/client/extensions.test.ts index 7f24480eff..25b432d2c1 100644 --- a/packages/client/test/client/extensions.test.ts +++ b/packages/client/test/client/extensions.test.ts @@ -190,6 +190,14 @@ describe('ClientOptions.extensions', () => { await client.close(); }); + it('refuses an accepted result kind on typed calls over 2025-11-25 too', async () => { + const { clientTx } = await scriptedServer('legacy'); + const client = new Client({ name: 'c', version: '1' }, { extensions: [gateExtension([])] }); + await client.connect(clientTx); + await expect(client.callTool({ name: 'slow', arguments: {} })).rejects.toMatchObject({ code: SdkErrorCode.UnsupportedResultType }); + await client.close(); + }); + it('serves the handler the extension registered 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/extensions.ts b/packages/core-internal/src/shared/extensions.ts index ddc5e63e1d..0bf8701887 100644 --- a/packages/core-internal/src/shared/extensions.ts +++ b/packages/core-internal/src/shared/extensions.ts @@ -28,11 +28,7 @@ export function extensionStateOf(protocol: object): Readonly | u return states.get(protocol); } -/** - * Throws when `capabilities` sets an entry under `extensions` that belongs to - * an installed extension, unless that extension is writing it. Called by - * `registerCapabilities`, so the entry has one owner. - */ +/** Throws when `capabilities.extensions` sets an installed extension's entry from outside that extension. */ export function assertExtensionCapabilitiesUnowned(protocol: object, capabilities: { extensions?: object }): void { const state = states.get(protocol); if (state === undefined || capabilities.extensions === undefined) return; @@ -70,19 +66,12 @@ export function useAsExtension( if (typeof middleware !== 'function') throw new TypeError('use: middleware is required'); const state = states.get(protocol); if (state === undefined) throw new Error('use: this server or client installed no extensions'); - // The protocol composes it with its own context type (see `Protocol._middlewareFor`). state.middleware.push({ method, middleware: middleware as unknown as RequestMiddleware }); } /** - * Installs `extensions` on `protocol`, in order. Before any `setup` runs it - * checks that ids are unique, that each extension has a `setup`, and that no - * entry under `capabilities.extensions` already exists for an installed id. - * Each extension is advertised as `{}`, then its `setup` runs. A `setup` that - * throws fails the caller (the constructor). A `setup` that returns a promise - * defers the ones after it until it settles, and `connect` waits for all of - * them, rejecting if one fails, so no message is processed with an extension - * partly set up. + * Installs `extensions` in order, checking ids, `setup` and capability entries + * first; an async `setup` defers the ones after it, and `connect` waits for them. */ export function installExtensions }, C>( protocol: object, diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 24222a3d0b..b1d70e6d8b 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -206,26 +206,20 @@ const RESERVED_ENVELOPE_META_KEYS: readonly string[] = [ */ const RETRY_PARAMS_KEYS = ['inputResponses', 'requestState'] as const; -// "Method not found" raised under middleware for a method with no handler, and the error responses carrying it. +// The no-handler MethodNotFound raised under middleware, and the responses carrying it. const noHandlerErrors = new WeakSet(); const noHandlerResponses = new WeakSet(); -/** - * Whether `message` answers a request for a method with no handler, raised - * after middleware passed it through. Transports that map pre-dispatch - * rejections to an HTTP status (404 for `-32601`) treat it as one, as a web - * framework answers 404 through route middleware. - * - * Package-internal. - */ +/** Whether `message` answers a method with no handler that middleware passed through; package-internal. */ export function isNoHandlerResponse(message: object): boolean { return noHandlerResponses.has(message); } /** - * Request middleware (see `Protocol.use`): receives the JSON-RPC request as - * received (its params not yet validated), the context, and `next` — the - * handler it wraps — and returns the result that goes on the wire. + * Request middleware (see `Protocol.use`): receives the JSON-RPC request, + * with wire-only material lifted onto `ctx.mcpReq` and params not yet + * validated, the context, and `next` — the handler it wraps — and returns + * the result that goes on the wire. * * Experimental: this shape may change in a minor release. * @@ -829,7 +823,7 @@ export abstract class Protocol { * The caller assumes ownership of the {@linkcode Transport}, replacing any callbacks that have already been set, and expects that it is the only user of the {@linkcode Transport} instance going forward. */ async connect(transport: Transport): Promise { - // Asynchronous extension setups finish before anything is processed; a failed one fails the connect. + // Wait for asynchronous extension setups; a failed one fails the connect. const extensionsReady = extensionStateOf(this)?.ready; if (extensionsReady !== undefined) await extensionsReady; this._transport = transport; @@ -1089,11 +1083,14 @@ export abstract class Protocol { ...options, relatedRequestId: request.id }); - const sendRequest = (r: Request, resultSchema: U, options?: RequestOptions) => - this._requestWithSchemaViaCodec(this._resolveOutboundCodec(r.method), r, resultSchema, { - ...options, - relatedRequestId: request.id - }); + const sendRequest = (r: Request, resultSchema: U, options?: RequestOptions, explicitSchema = false) => + this._requestWithSchemaViaCodec( + this._resolveOutboundCodec(r.method), + r, + resultSchema, + { ...options, relatedRequestId: request.id }, + explicitSchema + ); const abortController = new AbortController(); this._requestHandlerAbortControllers.set(request.id, abortController); @@ -1134,7 +1131,7 @@ export abstract class Protocol { const sendCodec = this._resolveOutboundCodec(r.method); this._assertOutboundRequestInEra(sendCodec, r.method); if (isStandardSchema(schemaOrOptions)) { - return sendRequest(r, schemaOrOptions, maybeOptions); + return sendRequest(r, schemaOrOptions, maybeOptions, true); } const validate = codecResultValidator(sendCodec, r.method); if (validate === undefined) { @@ -1559,8 +1556,19 @@ export abstract class Protocol { return reject(response); } - // Extension result kinds (see `acceptResultType`) skip the codec and reach the caller's schema as-is. - if (acceptExtensionResultTypes && this._isAcceptedResultType(request.method, response.result)) { + // Extension result kinds (see `acceptResultType`) reach an explicit schema as-is; typed calls refuse them on every era. + if (this._isAcceptedResultType(request.method, response.result)) { + if (!acceptExtensionResultTypes) { + const resultType = (response.result as { resultType: string }).resultType; + reject( + new SdkError( + SdkErrorCode.UnsupportedResultType, + `Unsupported result type '${resultType}' for ${request.method}: pass a result schema to request() to receive it`, + { resultType, method: request.method } + ) + ); + return; + } validateStandardSchema(resultSchema, response.result).then(parseResult => { if (parseResult.success) { resolve(parseResult.data); @@ -1835,7 +1843,7 @@ export abstract class Protocol { /** * Installs middleware on the request handler for `method`. The middleware - * receives the JSON-RPC request as received, the context, and `next` — + * receives the JSON-RPC 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 @@ -1843,7 +1851,9 @@ export abstract class Protocol { * result and the middleware returns what goes on the wire. * * Middleware sits at the JSON-RPC level, before the handler's own - * steps: params are not validated yet, `ctx.mcpReq.requestState()` is + * steps: wire-only material (the reserved `_meta` envelope keys, + * `inputResponses`, `requestState`) is already lifted onto `ctx.mcpReq`, + * params are not validated yet, `ctx.mcpReq.requestState()` is * the raw wire value (the server's `requestState.verify` hook has not run), * and a result the middleware returns without calling `next` skips the * handler's result validation. @@ -1913,12 +1923,7 @@ export abstract class Protocol { return typeof resultType === 'string' && accepted.has(resultType); } - /** - * The handler `_onrequest` dispatches to for `method`: the registered - * 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. - */ + /** The handler `_onrequest` dispatches `method` to, middleware included; `undefined` when there is none. */ private _resolveRequestHandler(method: string): ((request: JSONRPCRequest, ctx: ContextT) => Promise) | undefined { const base = this._requestHandlers.get(method) ?? this.fallbackRequestHandler; if (this._middlewareFor(method).length === 0) return base; @@ -1935,7 +1940,6 @@ export abstract class Protocol { /** The middleware for `method`, outermost first: extension middleware (`ctx.use`), then `use()`. */ private _middlewareFor(method: string): RequestMiddleware[] { - // The installer records each side's middleware with its own context type. const extension = (extensionStateOf(this)?.middleware ?? []) as unknown as Array<{ method: string; middleware: RequestMiddleware; @@ -1950,8 +1954,7 @@ export abstract class Protocol { ): (request: JSONRPCRequest, ctx: ContextT) => Promise { const stack = this._middlewareFor(method); let composed = handler; - // Wrap from the innermost (last registered) outwards so the first - // registered middleware ends up outermost. + // Wrap innermost first, so the first middleware ends up outermost. for (const middleware of stack.toReversed()) { const next = composed; composed = async (request, ctx) => middleware(request, ctx, next); diff --git a/packages/server/src/server/extension.ts b/packages/server/src/server/extension.ts index 81ed03203d..134058f19d 100644 --- a/packages/server/src/server/extension.ts +++ b/packages/server/src/server/extension.ts @@ -95,13 +95,7 @@ export interface ServerExtensionContext { ): void; } -/** - * Sets up `extensions` on `mcp`, in order (see `installExtensions`). A - * `setup` that throws, including an unmet `ctx.require`, fails the - * `McpServer` constructor; one that rejects fails `connect`. - * - * Package-internal: called by the `McpServer` constructor. - */ +/** Package-internal: sets `extensions` up for the constructor (see `installExtensions`). */ export function installServerExtensions(mcp: McpServer, extensions: readonly ServerExtension[]): void { const server = mcp.server; installExtensions( From 65517ab1d48edc55e483524aa0fb136969362422 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 14:32:56 +0000 Subject: [PATCH 09/10] fix(server): wait for async extension setups before reading a factory's instance createMcpHandler's modern route and serveStdio read the instance the factory returned before connecting it: the tool input schema for the Mcp-Param-* check, the tool's scope challenge, and the capabilities that narrow subscriptions/listen filters. Only connect waited for async setups, so a tool an async setup registered skipped the scope preflight and was called with a token lacking the scope. Both now wait right after the factory returns; a failed setup fails the request or connection. --- .../core-internal/src/shared/extensions.ts | 6 ++++ .../server/src/server/createMcpHandler.ts | 3 ++ packages/server/src/server/serveStdio.ts | 3 ++ .../test/server/scopeChallengeModern.test.ts | 29 +++++++++++++++++++ 4 files changed, 41 insertions(+) diff --git a/packages/core-internal/src/shared/extensions.ts b/packages/core-internal/src/shared/extensions.ts index 0bf8701887..c87ee74f05 100644 --- a/packages/core-internal/src/shared/extensions.ts +++ b/packages/core-internal/src/shared/extensions.ts @@ -28,6 +28,12 @@ export function extensionStateOf(protocol: object): Readonly | u return states.get(protocol); } +/** Settles once every extension `setup` on `protocol` has finished; rejects if one failed. */ +export async function extensionsSettled(protocol: object): Promise { + const ready = states.get(protocol)?.ready; + if (ready !== undefined) await ready; +} + /** Throws when `capabilities.extensions` sets an installed extension's entry from outside that extension. */ export function assertExtensionCapabilitiesUnowned(protocol: object, capabilities: { extensions?: object }): void { const state = states.get(protocol); diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 4ef836c636..077cc47e1e 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -41,6 +41,7 @@ import { classifyInboundRequest, CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, + extensionsSettled, httpStatusForErrorCode, isJsonContentType, mediaTypeEssence, @@ -794,6 +795,8 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa requestInfo: request }); const server = product instanceof McpServer ? product.server : product; + // The checks below read the instance, so async extension setups must have finished. + await extensionsSettled(server); assertNotServing(server); // Entry-handled `subscriptions/listen`: the router owns ack-first / diff --git a/packages/server/src/server/serveStdio.ts b/packages/server/src/server/serveStdio.ts index 1cabf8ed9b..39e5fd6ce6 100644 --- a/packages/server/src/server/serveStdio.ts +++ b/packages/server/src/server/serveStdio.ts @@ -60,6 +60,7 @@ import type { import { carriesValidModernEnvelopeClaim, envelopeClaimVersion, + extensionsSettled, hasEnvelopeClaim, isJSONRPCErrorResponse, isJSONRPCNotification, @@ -511,6 +512,8 @@ export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions const connectInstance = async (era: 'legacy' | 'modern', revision?: string): Promise => { const product = await factory({ era }); const server = product instanceof McpServer ? product.server : product; + // The binding below reads the instance, so async extension setups must have finished. + await extensionsSettled(server); if (era === 'modern') { // Era-write at instance binding, then modern-only handler // installation — the same helpers the HTTP entry's modern path diff --git a/packages/server/test/server/scopeChallengeModern.test.ts b/packages/server/test/server/scopeChallengeModern.test.ts index 9ef717735d..d21d1e49fe 100644 --- a/packages/server/test/server/scopeChallengeModern.test.ts +++ b/packages/server/test/server/scopeChallengeModern.test.ts @@ -270,3 +270,32 @@ describe('scope challenge resource_metadata derivation', () => { expect(response.headers.get('WWW-Authenticate')).not.toContain('resource_metadata'); }); }); + +describe('createMcpHandler with an async extension setup', () => { + it('runs the scope preflight on a tool the setup registered after an await', async () => { + const onCall = vi.fn(); + const handler = createMcpHandler( + () => + new McpServer( + { name: 'modern-scope', version: '1.0.0' }, + { + extensions: [ + { + id: 'com.example/admin', + async setup(ctx) { + await new Promise(resolve => setTimeout(resolve, 5)); + ctx.mcp.registerTool('operate', { scopeChallenge: requireScopes('admin') }, async () => { + onCall(); + return { content: [{ type: 'text', text: 'ok' }] }; + }); + } + } + ] + } + ) + ); + const response = await handler.fetch(call('operate', {}), { authInfo: auth(['repo:read']) }); + expect(response.status).toBe(403); + expect(onCall).not.toHaveBeenCalled(); + }); +}); From 9f97b9d346b8f4a426f2a24656be127c9ea1f0b7 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 14:32:56 +0000 Subject: [PATCH 10/10] docs: say when a setup failure fails connect, and what typed calls do with accepted kinds - A ctx.require or other throw from an async setup, or from a setup after one, rejects connect rather than failing the constructor. - Typed calls reject an acceptResultType kind with UnsupportedResultType on either protocol version; the JSDoc, an inline comment and the changeset still said they ignore the declaration. --- .changeset/server-extensions.md | 4 ++-- docs/advanced/extensions.md | 4 ++-- packages/client/src/client/extension.ts | 2 +- packages/core-internal/src/shared/protocol.ts | 9 +++++---- packages/server/src/server/extension.ts | 2 +- 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.changeset/server-extensions.md b/.changeset/server-extensions.md index af82c06d65..b69c9f0ce2 100644 --- a/.changeset/server-extensions.md +++ b/.changeset/server-extensions.md @@ -4,8 +4,8 @@ '@modelcontextprotocol/server': minor --- -Server and client extensions (experimental: the `extensions` option, the extension shapes and their contexts, `use` and `acceptResultType` may change in a minor release). An extension is `{ id, setup(ctx) }`, passed in `McpServerOptions.extensions` or `ClientOptions.extensions`. `setup` runs at construction, in array order, and adds everything through its context. It may be async: the setups after it wait for it, and `connect` waits for all of them and rejects if one fails. Its context provides: `ctx.capability(settings)` sets what is advertised under `capabilities.extensions[id]` (`{}` by default), `ctx.setRequestHandler(name, schemas, handler)` registers the handler for a custom request method and refuses core method names and methods that already have a handler, `ctx.use(method, middleware)` wraps a method's handler, and `ctx.require(extensionId)` declares that the extension needs another extension, failing construction when it isn't installed. The server context also carries `ctx.mcp`, the `McpServer`; the client context carries `ctx.client` and `ctx.acceptResultType`. A duplicate id, or an entry for an installed id in `capabilities.extensions`, fails construction before any extension is set up; `registerCapabilities` also refuses an installed id's entry. +Server and client extensions (experimental: the `extensions` option, the extension shapes and their contexts, `use` and `acceptResultType` may change in a minor release). An extension is `{ id, setup(ctx) }`, passed in `McpServerOptions.extensions` or `ClientOptions.extensions`. `setup` runs at construction, in array order, and adds everything through its context. It may be async: the setups after it wait for it, and `connect` waits for all of them and rejects if one fails. Its context provides: `ctx.capability(settings)` sets what is advertised under `capabilities.extensions[id]` (`{}` by default), `ctx.setRequestHandler(name, schemas, handler)` registers the handler for a custom request method and refuses core method names and methods that already have a handler, `ctx.use(method, middleware)` wraps a method's handler, and `ctx.require(extensionId)` declares that the extension needs another extension, failing construction when it isn't installed (or `connect`, from or after an async `setup`). The server context also carries `ctx.mcp`, the `McpServer`; the client context carries `ctx.client` and `ctx.acceptResultType`. A duplicate id, or an entry for an installed id in `capabilities.extensions`, fails construction before any extension is set up; `registerCapabilities` also refuses an installed id's entry. -`Protocol.use(method, (request, ctx, next) => …)` is middleware around the registered handler, composed at dispatch time in registration order, inside middleware extensions add with `ctx.use`, so middleware on `tools/call` applies even though `McpServer` registers that handler lazily; it returns a remover. Middleware sees the request before params validation and the `requestState.verify` hook. On a method with no handler, `next` throws `MethodNotFound`, which a per-request HTTP exchange answers with 404 when the middleware passes it through. `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`. +`Protocol.use(method, (request, ctx, next) => …)` is middleware around the registered handler, composed at dispatch time in registration order, inside middleware extensions add with `ctx.use`, so middleware on `tools/call` applies even though `McpServer` registers that handler lazily; it returns a remover. Middleware sees the request before params validation and the `requestState.verify` hook. On a method with no handler, `next` throws `MethodNotFound`, which a per-request HTTP exchange answers with 404 when the middleware passes it through. `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, while typed calls such as `callTool` reject it with `UnsupportedResultType` on either protocol version; that 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 7e887c793a..d57c4fded8 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -79,7 +79,7 @@ The server then: 1. Checks the ids. A duplicate fails construction before any extension is set up, and so does an entry for an installed id in `capabilities.extensions`: an extension's entry belongs to it, so set its settings with `ctx.capability`. `registerCapabilities` refuses that entry later too. 2. In array order, advertises each extension under `capabilities.extensions[id]` as `{}` and runs its `setup`. Legacy connections see the map in the `initialize` result, and 2026-07-28 connections see it in `server/discover`. Settings from `ctx.capability` replace the `{}`. -A `setup` that throws fails the constructor, so a server with a partly set up extension never exists. That includes `ctx.require` for an extension the server doesn't install. +A `setup` that throws fails the constructor, so a server with a partly set up extension never exists. That includes `ctx.require` for an extension the server doesn't install. A `setup` that runs after an `await`, or after an async `setup` listed before it, fails `connect` instead, with the same error. `setup` may be async, to load what the extension serves: @@ -143,6 +143,6 @@ A thrown `ProtocolError` becomes the JSON-RPC error response. Inside a tool hand - An extension is `{ id, setup(ctx) }`, passed in `McpServerOptions.extensions` or `ClientOptions.extensions`. - `setup` registers everything through `ctx`: `capability`, `setRequestHandler`, `use`, `require`, plus `ctx.mcp` on the server and `acceptResultType` on the client. -- Extensions are set up in array order, and `setup` may be async; `connect` waits for it. Duplicate ids, a `capabilities.extensions` entry for an installed id, and a `ctx.require` for an extension that isn't installed fail construction. +- Extensions are set up in array order, and `setup` may be async; `connect` waits for it. Duplicate ids, a `capabilities.extensions` entry for an installed id, and a `ctx.require` for an extension that isn't installed fail construction, or `connect` once an async `setup` is involved. - Middleware composes at dispatch time, extensions' outside the application's, and applies to handlers registered later, including other extensions' handlers. It sees the request before validation. - The SDK owns the hooks, the checks 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 7568f6b93f..729eb3c4ab 100644 --- a/packages/client/src/client/extension.ts +++ b/packages/client/src/client/extension.ts @@ -72,7 +72,7 @@ export interface ClientExtensionContext { /** * Declares that this extension needs another extension, by id. Throws, * naming both, when the client doesn't install it, which fails the - * constructor. The full list is known before any `setup` runs, so an + * constructor, or `connect` from or after an async `setup`. The full list is known before any `setup` runs, so an * extension listed after this one counts. For behaviour that is merely * optional, check in code instead and carry on without it. */ diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index b1d70e6d8b..09ac9a0df6 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -1419,7 +1419,7 @@ export abstract class Protocol { request: Request, resultSchema: T, options?: RequestOptions, - // Only the explicit-schema `request()` path consults `acceptResultType`. + // Whether an `acceptResultType` kind reaches the caller's schema (explicit-schema calls) or is refused (typed calls). acceptExtensionResultTypes = false ): Promise> { const { relatedRequestId, resumptionToken, onresumptiontoken, headers } = options ?? {}; @@ -1894,9 +1894,10 @@ export abstract class Protocol { * 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 - * such as `callTool` keep the closed vocabulary. Client extensions reach this through + * result schema as-is, discriminator included, on the explicit-schema + * `request(request, resultSchema)` path. Typed spec calls such as + * `callTool` reject it with `UnsupportedResultType` on either protocol + * version. Client extensions reach this through * `ctx.acceptResultType` in their `setup`. * * @returns A function that withdraws the declaration. diff --git a/packages/server/src/server/extension.ts b/packages/server/src/server/extension.ts index 134058f19d..b06405a86b 100644 --- a/packages/server/src/server/extension.ts +++ b/packages/server/src/server/extension.ts @@ -69,7 +69,7 @@ export interface ServerExtensionContext { /** * Declares that this extension needs another extension, by id. Throws, * naming both, when the server doesn't install it, which fails the - * constructor. The full list is known before any `setup` runs, so an + * constructor, or `connect` from or after an async `setup`. The full list is known before any `setup` runs, so an * extension listed after this one counts. For behaviour that is merely * optional, check in code instead and carry on without it. */