diff --git a/.changeset/server-extensions.md b/.changeset/server-extensions.md new file mode 100644 index 0000000000..b69c9f0ce2 --- /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. 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, 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/.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..d57c4fded8 --- /dev/null +++ b/docs/advanced/extensions.md @@ -0,0 +1,148 @@ +--- +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. 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'; +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, 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 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: + +```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. + +## 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. `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` reject it with `UnsupportedResultType` on either protocol version. +- `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. + +Middleware works at the JSON-RPC level, before the handler's own steps: + +- 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. + +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. 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. + +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, 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/client.ts b/packages/client/src/client/client.ts index 096a8660e2..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, @@ -80,6 +81,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 +184,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 +695,8 @@ export class Client extends Protocol { if (options?.listChanged) { this._listChangedConfig = options.listChanged; } + + installClientExtensions(this, options?.extensions ?? [], options?.capabilities); } protected override buildContext(ctx: BaseContext, _transportInfo?: MessageExtraInfo): ClientContext { @@ -812,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 new file mode 100644 index 0000000000..729eb3c4ab --- /dev/null +++ b/packages/client/src/client/extension.ts @@ -0,0 +1,142 @@ +import type { + ClientCapabilities, + ClientContext, + InferHandlerResult, + JSONObject, + RequestMethod, + RequestMiddleware, + StandardSchemaV1 +} from '@modelcontextprotocol/core-internal'; +import { + installExtensions, + requireExtension, + setExtensionRequestHandler, + useAsExtension, + writeExtensionCapability +} 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. 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; +} + +/** + * 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 `{}`. The entry belongs to the extension: setting + * it anywhere else throws. + */ + 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, 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. + */ + require(extensionId: `${string}/${string}`): void; + /** + * 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 + * `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. Served on 2025-11-25 + * connections only: 2026-07-28 has no server-to-client request channel. + */ + 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; +} + +/** Package-internal: sets `extensions` up for the constructor (see `installExtensions`). */ +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`. */ +function contextFor(client: Client, id: string, installed: ReadonlySet): ClientExtensionContext { + return { + client, + capability(settings) { + writeExtensionCapability(client, id, settings, capabilities => client.registerCapabilities(capabilities)); + }, + require(extensionId) { + requireExtension('client', installed, id, extensionId); + }, + use(method, middleware) { + useAsExtension(client, 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..25b432d2c1 --- /dev/null +++ b/packages/client/test/client/extensions.test.ts @@ -0,0 +1,245 @@ +/** + * `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, + SdkErrorCode +} 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, + // 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({ + 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({}) }, () => ({})) + }); + 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( + { 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' }); + // 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(); + }); + + 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([])] }); + 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(); + }); + 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/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/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..c87ee74f05 --- /dev/null +++ b/packages/core-internal/src/shared/extensions.ts @@ -0,0 +1,190 @@ +/** + * 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 { 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, 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); +} + +/** 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); + 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'); + state.middleware.push({ method, middleware: middleware as unknown as RequestMiddleware }); +} + +/** + * 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, + 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 + * 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 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< + 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 (isSpecRequestMethod(name) && !isExtensionReusedRequestMethod(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..09ac9a0df6 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,31 @@ const RESERVED_ENVELOPE_META_KEYS: readonly string[] = [ */ const RETRY_PARAMS_KEYS = ['inputResponses', 'requestState'] as const; +// The no-handler MethodNotFound raised under middleware, and the responses carrying it. +const noHandlerErrors = new WeakSet(); +const noHandlerResponses = new WeakSet(); + +/** 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, + * 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. + * + * @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 +594,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(); @@ -729,7 +759,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 { @@ -792,6 +823,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 { + // Wait for asynchronous extension setups; 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 = () => { @@ -1019,7 +1053,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'); @@ -1049,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); @@ -1094,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) { @@ -1164,6 +1201,7 @@ export abstract class Protocol { ...(error['data'] !== undefined && { data: error['data'] }) } }; + if (noHandlerErrors.has(error)) noHandlerResponses.add(errorResponse); await capturedTransport?.send(errorResponse); } ) @@ -1287,7 +1325,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); @@ -1380,7 +1418,9 @@ export abstract class Protocol { codec: WireCodec, request: Request, resultSchema: T, - options?: RequestOptions + options?: RequestOptions, + // 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 ?? {}; // Flow start for non-complete result resolution: `maxTotalTimeout` @@ -1516,6 +1556,29 @@ export abstract class Protocol { return reject(response); } + // 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); + } 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 @@ -1560,7 +1623,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>); @@ -1777,6 +1841,128 @@ export abstract class Protocol { this._requestHandlers.set(method, this._wrapHandler(method, stored)); } + /** + * Installs middleware on the request handler for `method`. The middleware + * 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 + * 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: 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. + * + * 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`; + * 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. + * + * @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, 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. + * + * 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 `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; + return this._withMiddleware( + method, + base ?? + (async () => { + const error = new ProtocolError(ProtocolErrorCode.MethodNotFound, 'Method not found'); + noHandlerErrors.add(error); + throw error; + }) + ); + } + + /** The middleware for `method`, outermost first: extension middleware (`ctx.use`), then `use()`. */ + private _middlewareFor(method: string): RequestMiddleware[] { + 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._middlewareFor(method); + let composed = handler; + // 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); + } + 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 +2098,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..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'; 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/extension.ts b/packages/server/src/server/extension.ts new file mode 100644 index 0000000000..b06405a86b --- /dev/null +++ b/packages/server/src/server/extension.ts @@ -0,0 +1,128 @@ +import type { + InferHandlerResult, + JSONObject, + RequestMethod, + RequestMiddleware, + ServerContext, + StandardSchemaV1 +} from '@modelcontextprotocol/core-internal'; +import { + installExtensions, + requireExtension, + setExtensionRequestHandler, + useAsExtension, + writeExtensionCapability +} 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. 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; +} + +/** + * 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 `{}`. The entry belongs to the + * extension: setting it anywhere else throws. + */ + 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, 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. + */ + 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 with `mcp.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; +} + +/** Package-internal: sets `extensions` up for the constructor (see `installExtensions`). */ +export function installServerExtensions(mcp: McpServer, extensions: readonly ServerExtension[]): void { + 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`. */ +function contextFor(mcp: McpServer, id: string, installed: ReadonlySet): ServerExtensionContext { + const server = mcp.server; + return { + mcp, + capability(settings) { + writeExtensionCapability(server, id, settings, capabilities => server.registerCapabilities(capabilities)); + }, + require(extensionId) { + requireExtension('server', installed, id, extensionId); + }, + use(method, middleware) { + useAsExtension(server, 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/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/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/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 new file mode 100644 index 0000000000..ca508e3a15 --- /dev/null +++ b/packages/server/test/server/extensions.test.ts @@ -0,0 +1,308 @@ +/** + * `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 of any protocol version', () => { + const claims = (name: string): ServerExtension => ({ + id: 'com.example/core', + 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', () => { + 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(); + }); +}); + +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 }); + }); +}); 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(); + }); +});