Repository navigation
feat: server and client extensions ({ id, setup(ctx) }), request middleware (use), acceptResultType #2820
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: server and client extensions ({ id, setup(ctx) }), request middleware (use), acceptResultType #2820
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| --- | ||
| '@modelcontextprotocol/core-internal': minor | ||
| '@modelcontextprotocol/client': minor | ||
| '@modelcontextprotocol/server': minor | ||
| --- | ||
|
|
||
| Server and client extensions (experimental: the `extensions` option, the extension shapes and their contexts, `use` and `acceptResultType` may change in a minor release). An extension is `{ id, setup(ctx) }`, passed in `McpServerOptions.extensions` or `ClientOptions.extensions`. `setup` runs at construction, in array order, and adds everything through its context: `ctx.capability(settings)` sets what is advertised under `capabilities.extensions[id]` (`{}` by default), `ctx.setRequestHandler(name, schemas, handler)` registers the handler for a custom request method and refuses core method names and methods that already have a handler, `ctx.use(method, middleware)` wraps a method's handler, and `ctx.require(extensionId)` declares that the extension needs another extension, failing construction when it isn't installed. The server context also carries `ctx.mcp`, the `McpServer`; the client context carries `ctx.client` and `ctx.acceptResultType`. A duplicate id fails construction before any extension is set up. | ||
|
|
||
| `Protocol.use(method, (request, ctx, next) => …)` is middleware around the registered handler, composed at dispatch time in registration order, so middleware on `tools/call` applies even though `McpServer` registers that handler lazily; it returns a remover. `Protocol.acceptResultType(method, resultType)` declares an extension result kind for a method: a raw response carrying that `resultType` bypasses the era codec's closed vocabulary and is validated against the caller's explicit result schema as-is, which is how a client extension receives shapes such as the Tasks extension's `resultType: "task"` on `tools/call`. | ||
|
|
||
| `McpServer` tool dispatch now re-throws `MissingRequiredClientCapabilityError` (`-32021`) as a JSON-RPC error instead of converting it into an `isError` tool result, matching the existing `UrlElicitationRequiredError` passthrough. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| --- | ||
| shape: how-to | ||
| --- | ||
|
|
||
| # Extensions | ||
|
|
||
| ::: warning Experimental | ||
| The `extensions` option, the `ServerExtension` and `ClientExtension` shapes, their contexts, `use` and `acceptResultType` are experimental and may change in a minor release. | ||
| ::: | ||
|
|
||
| An **extension** packages protocol behaviour outside the core specification, such as the `io.modelcontextprotocol/tasks` or `io.modelcontextprotocol/skills` extension or a vendor feature, as one object you pass to the server or the client. The SDK advertises it, sets it up, and checks what it depends on before serving. | ||
|
|
||
| ## Write a server extension | ||
|
|
||
| An extension is an `id` and a `setup(ctx)` function. What is both required and declarative sits on the object, which today is just the `id`. Everything else happens in `setup`, which runs once while the server is constructed: optional declarations first (`ctx.capability`, `ctx.require`), then handlers, middleware and anything registered on `ctx.mcp`. Everything `setup` adds goes through `ctx`: | ||
|
|
||
| | Member | What it does | | ||
| | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| | `ctx.capability(settings)` | Sets what the server advertises under `capabilities.extensions[id]`. Without a call, the server advertises `{}`. | | ||
| | `ctx.require(extensionId)` | Declares that this extension needs another extension. Construction fails, naming both, when the server doesn't install it. Order in `extensions` doesn't matter. | | ||
| | `ctx.setRequestHandler(name, schemas, handler)` | Registers the handler for a custom request method, with the same arguments as `setRequestHandler`. Throws for a core method name, or for a method another handler already serves. | | ||
| | `ctx.use(method, middleware)` | Middleware around whatever handler serves `method`, core or custom. | | ||
| | `ctx.mcp` | The `McpServer`, for `registerTool`, `registerResource` and `ResourceTemplate`. | | ||
|
|
||
| This extension adds a custom method, and refuses `tools/call` from clients that didn't declare it: | ||
|
|
||
| ```ts | ||
| import type { ServerExtension } from '@modelcontextprotocol/server'; | ||
| import { MissingRequiredClientCapabilityError, CLIENT_CAPABILITIES_META_KEY } from '@modelcontextprotocol/server'; | ||
| import * as z from 'zod/v4'; | ||
|
|
||
| const GATE = 'com.example/gate'; | ||
|
|
||
| export const gate: ServerExtension = { | ||
| id: GATE, | ||
| setup(ctx) { | ||
| // Settings for the advertised capability, if the extension has any. | ||
| ctx.capability({ exampleData: true }); | ||
|
|
||
| // A custom method: setRequestHandler, checked for name clashes. | ||
| ctx.setRequestHandler('gate/status', { params: z.looseObject({}) }, () => ({ armed: true })); | ||
|
|
||
| // Middleware on a spec method: runs around the registered handler, | ||
| // may answer, transform, or refuse. | ||
| ctx.use('tools/call', (request, requestCtx, next) => { | ||
| const declared = requestCtx.mcpReq.envelope?.[CLIENT_CAPABILITIES_META_KEY]?.extensions ?? {}; | ||
| if (!(GATE in declared)) { | ||
| throw new MissingRequiredClientCapabilityError({ requiredCapabilities: { extensions: { [GATE]: {} } } }, 'declare the gate'); | ||
| } | ||
| return next(request, requestCtx); | ||
| }); | ||
| } | ||
| }; | ||
| ``` | ||
|
|
||
| An extension that serves resources or tools registers them on `ctx.mcp`, like any server: | ||
|
|
||
| ```ts | ||
| const changelog: ServerExtension = { | ||
| id: 'com.example/changelog', | ||
| setup(ctx) { | ||
| ctx.mcp.registerResource('changelog', 'changelog://latest', { mimeType: 'text/markdown' }, async uri => ({ | ||
| contents: [{ uri: uri.href, text: await readChangelog() }] | ||
| })); | ||
| } | ||
| }; | ||
| ``` | ||
|
|
||
| ## Install it | ||
|
|
||
| Pass extensions to `McpServer` at construction: | ||
|
|
||
| ```ts | ||
| const server = new McpServer({ name: 'gated', version: '1.0.0' }, { extensions: [gate, changelog] }); | ||
| ``` | ||
|
|
||
| The server then: | ||
|
|
||
| 1. Checks the ids. A duplicate fails construction before any extension is set up. | ||
| 2. In array order, advertises each extension under `capabilities.extensions[id]` as `{}` and runs its `setup`. Legacy connections see the map in the `initialize` result, and 2026-07-28 connections see it in `server/discover`. Settings from `ctx.capability` replace the `{}`. | ||
|
|
||
| A `setup` that throws fails the constructor, so a server with a partly set up extension never exists. That includes `ctx.require` for an extension the server doesn't install. The ids are known before any `setup` runs, so a required extension can be listed before or after the one that needs it. | ||
|
|
||
| `ctx.require` is for an extension that can't work without another one. When the other one only adds something optional, check for what you need in code, such as in a handler or middleware at the moment it runs, and carry on without it. Extensions are an `McpServer` option; the low-level `Server` doesn't take them. | ||
|
|
||
| ## Client extensions | ||
|
|
||
| The client takes the same shape, `{ id, setup(ctx) }`, in `ClientOptions.extensions`. The client advertises each extension under its own `capabilities.extensions[id]`: in `initialize` on a legacy connection, and in every request's `_meta` client-capabilities envelope on a 2026-07-28 connection, which is where a server extension reads it. Its context has: | ||
|
|
||
| - `ctx.capability(settings)`, `ctx.use(method, middleware)` and `ctx.require(extensionId)`, as on the server. | ||
| - `ctx.setRequestHandler(name, schemas, handler)` for custom server-to-client requests. | ||
| - `ctx.acceptResultType(method, resultType)`, so results of `method` may carry a `resultType` the extension defines. The explicit-schema `request()` path then hands them to the caller's schema as-is, instead of rejecting the unknown kind. | ||
| - `ctx.client`, the client being set up. | ||
|
|
||
| ```ts | ||
| import type { ClientExtension } from '@modelcontextprotocol/client'; | ||
|
|
||
| const gateClient: ClientExtension = { | ||
| id: GATE, | ||
| setup(ctx) { | ||
| ctx.setRequestHandler('gate/ping', { params: z.looseObject({}) }, () => ({ pong: true })); | ||
| ctx.acceptResultType('tools/call', 'gate'); | ||
| } | ||
| }; | ||
|
|
||
| const client = new Client({ name: 'gated-client', version: '1.0.0' }, { extensions: [gateClient] }); | ||
| ``` | ||
|
|
||
| ## How middleware composes | ||
|
|
||
| `ctx.setRequestHandler` registers the route handler. `ctx.use(method, middleware)` is the middleware around it, in the Koa style: `await next(request, requestCtx)` yields the result, and the middleware returns what goes on the wire. It wraps whatever handler serves `method` at dispatch time, including one another extension registered. That matters for `tools/call`, which `McpServer` registers on the first tool registration: middleware installed at construction still applies. With no underlying handler, `next` throws `MethodNotFound`. | ||
|
|
||
| Several middleware nest in registration order, the first outermost. Extensions are set up in array order, so the first extension's middleware is outermost, and all of it runs outside middleware the application adds afterwards with `server.use`. Method names are exact, and there is no wildcard. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit (optional): readers who follow the guide and call Why this was flaggedA reader builds Verification: nit — triggered whenever a reader follows the guide literally and calls Finding ID: |
||
|
|
||
| An extension wraps the methods it doesn't own with `ctx.use`, and never registers them again; `ctx.setRequestHandler` refuses to. Something every peer must know about the extension belongs in its advertised capability (`ctx.capability`), not in per-request `_meta`. Where a result does carry extension `_meta`, the middleware adds its own namespaced key, and the encoder still stamps the SDK's reserved keys and `resultType` on top. | ||
|
|
||
| A thrown `ProtocolError` becomes the JSON-RPC error response. Inside a tool handler, `McpServer` converts most throws into an `isError` tool result. The exceptions are the protocol-level errors the client must see as errors: `UrlElicitationRequiredError` and `MissingRequiredClientCapabilityError` (`-32021`). | ||
|
|
||
| ## Recap | ||
|
|
||
| - An extension is `{ id, setup(ctx) }`, passed in `McpServerOptions.extensions` or `ClientOptions.extensions`. | ||
| - `setup` registers everything through `ctx`: `capability`, `setRequestHandler`, `use`, `require`, plus `ctx.mcp` on the server and `acceptResultType` on the client. | ||
| - Extensions are set up in array order. Duplicate ids, and a `ctx.require` for an extension that isn't installed, fail construction. | ||
| - Middleware composes at dispatch time, first registered outermost, and applies to handlers registered later, including other extensions' handlers. | ||
| - The SDK owns the hooks, the checks and the capability advertisement, not the extension's state or execution. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| import type { | ||
| ClientContext, | ||
| InferHandlerResult, | ||
| JSONObject, | ||
| RequestMethod, | ||
| RequestMiddleware, | ||
| StandardSchemaV1 | ||
| } from '@modelcontextprotocol/core-internal'; | ||
| import { requireExtension, setExtensionRequestHandler, uniqueExtensionIds } from '@modelcontextprotocol/core-internal'; | ||
|
|
||
| import type { Client } from './client'; | ||
|
|
||
| /** | ||
| * A client extension: the client half of protocol behaviour outside the core | ||
| * specification, such as the `io.modelcontextprotocol/tasks` extension or a | ||
| * vendor feature, passed to the client at construction. | ||
| * | ||
| * ```ts | ||
| * const client = new Client(info, { extensions: [tasks] }); | ||
| * ``` | ||
| * | ||
| * What is both required and declarative sits on the object: the `id`. | ||
| * Everything else happens in `setup`, which runs once while the client is | ||
| * constructed: optional declarations first (`ctx.capability`, `ctx.require`), | ||
| * then handlers for server-to-client requests, middleware, and the result | ||
| * kinds it accepts. | ||
| * | ||
| * Experimental: the shape of an extension may change in a minor release. | ||
| * | ||
| * @experimental | ||
| */ | ||
| export interface ClientExtension { | ||
| /** | ||
| * The extension identifier, prefix-qualified (`io.modelcontextprotocol/tasks`, | ||
| * `com.example/feature-flags`). Advertised as the key under the client's | ||
| * `capabilities.extensions`, and unique per client. | ||
| */ | ||
| readonly id: string; | ||
| /** Adds the extension to the client. Called once, at construction, in `extensions` order. */ | ||
| setup(ctx: ClientExtensionContext): void; | ||
| } | ||
|
|
||
| /** | ||
| * What a {@linkcode ClientExtension}'s `setup` receives. | ||
| * | ||
| * Experimental: this shape may change in a minor release. | ||
| * | ||
| * @experimental | ||
| */ | ||
| export interface ClientExtensionContext { | ||
| /** The client the extension is set up on. */ | ||
| readonly client: Client; | ||
| /** | ||
| * Sets what the client advertises under `capabilities.extensions[id]`: | ||
| * in `initialize` on a legacy connection, and in every request's | ||
| * client-capabilities envelope on a 2026-07-28 connection. Without a call, | ||
| * the client advertises `{}`. | ||
| */ | ||
| capability(settings: JSONObject): void; | ||
| /** | ||
| * Declares that this extension needs another extension, by id. Throws, | ||
| * naming both, when the client doesn't install it, which fails the | ||
| * constructor. The full list is known before any `setup` runs, so an | ||
| * extension listed after this one counts. For behaviour that is merely | ||
| * optional, check in code instead and carry on without it. | ||
| */ | ||
| require(extensionId: `${string}/${string}`): void; | ||
| /** Middleware around whatever handler serves a server-to-client request `method`. See `Protocol.use`. */ | ||
| use(method: RequestMethod | (string & {}), middleware: RequestMiddleware<ClientContext>): void; | ||
| /** | ||
| * Registers the handler for a custom server-to-client request, like | ||
| * `Client.setRequestHandler` with schemas, but checked: throws when `name` | ||
| * is a core method (wrap those with `use` instead) or when a handler | ||
| * already serves it, such as another extension's. | ||
| */ | ||
| setRequestHandler<P extends StandardSchemaV1, R extends StandardSchemaV1 | undefined = undefined>( | ||
| name: string, | ||
| schemas: { params: P; result?: R }, | ||
| handler: (params: StandardSchemaV1.InferOutput<P>, ctx: ClientContext) => InferHandlerResult<R> | Promise<InferHandlerResult<R>> | ||
| ): void; | ||
| /** | ||
| * Lets results of `method` carry a `resultType` this extension defines, | ||
| * such as the Tasks extension's `"task"` on `tools/call`. See | ||
| * `Protocol.acceptResultType`. | ||
| */ | ||
| acceptResultType(method: string, resultType: string): void; | ||
| } | ||
|
|
||
| /** | ||
| * Sets up `extensions` on `client`, in order. Ids are checked before anything | ||
| * is set up, so a duplicate leaves the client untouched. A `setup` that | ||
| * throws, including an unmet `ctx.require`, fails the `Client` constructor | ||
| * that called this. | ||
| * | ||
| * Package-internal: called by the `Client` constructor. | ||
| */ | ||
| export function installClientExtensions(client: Client, extensions: readonly ClientExtension[]): void { | ||
| const ids = uniqueExtensionIds(extensions); | ||
| for (const extension of extensions) { | ||
| if (typeof extension.setup !== 'function') { | ||
| throw new TypeError(`Extension "${String((extension as { id: unknown }).id)}" has no setup`); | ||
| } | ||
| } | ||
|
|
||
| for (const extension of extensions) { | ||
| client.registerCapabilities({ extensions: { [extension.id]: {} } }); | ||
| extension.setup(contextFor(client, extension.id, ids)); | ||
| } | ||
| } | ||
|
|
||
| /** The context one extension's `setup` receives; everything it registers is attributed to `id`. */ | ||
| function contextFor(client: Client, id: string, installed: ReadonlySet<string>): ClientExtensionContext { | ||
| return { | ||
| client, | ||
| capability(settings) { | ||
| client.registerCapabilities({ extensions: { [id]: settings } }); | ||
| }, | ||
| require(extensionId) { | ||
| requireExtension('client', installed, id, extensionId); | ||
| }, | ||
| use(method, middleware) { | ||
| client.use(method, middleware); | ||
| }, | ||
| setRequestHandler(name, schemas, handler) { | ||
| setExtensionRequestHandler(client, id, name, schemas, handler); | ||
| }, | ||
| acceptResultType(method, resultType) { | ||
| client.acceptResultType(method, resultType); | ||
| } | ||
| }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 nit (optional): readers who copy the documented
gateextension get a server that refuses every 2025-era client, even one that declared the extension ininitialize. The example at docs/advanced/extensions.md:48 reads onlyrequestCtx.mcpReq.envelope, which a legacy request never carries, so the-32021throw at line 50 fires on every legacytools/call. The same guide promises legacy clients advertise the extension ininitialize(line 98). Fix: make the example era-aware, falling back toctx.server.getClientCapabilities()when the envelope is absent, or state that the gate only serves 2026-07-28 connections.Why this was flagged
A server built from the docs example installs
gateand is connected on a 2025-era session. A 2025 client built with this SDK andextensions: [gateClient]sendscapabilities.extensions['com.example/gate']ininitialize(packages/client/src/client/client.ts:1095), as docs/advanced/extensions.md:98 says it does. On a legacy request_onrequestsetsmcpReq.envelopeonly when the request carried reserved_metakeys (packages/core-internal/src/shared/protocol.ts:1089), sorequestCtx.mcpReq.envelopeis undefined anddeclaredis{}at docs/advanced/extensions.md:48. The middleware throwsMissingRequiredClientCapabilityErrorat line 50 and the client gets-32021for everytools/call. The server's own gates use the era-aware_inputRequestCapabilityView(packages/server/src/server/server.ts:742-746); that view is private, and the example neither uses the publicgetClientCapabilities()nor says it is 2026-only. On the base branch there is no extension guide, so no reader is steered into this.Verification: nit — triggered whenever a server built from the documented
gateexample serves a 2025-era client. docs/advanced/extensions.md:48-50 reads onlyrequestCtx.mcpReq.envelope?.[CLIENT_CAPABILITIES_META_KEY]?.extensions ?? {}and throwsMissingRequiredClientCapabilityErrorwhen the id is absent. A legacy client never sends an envelope, so on every legacytools/calldeclaredis{}and the gate throws -32021.Finding ID:
f-26792aac