Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/server-extensions.md
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.
1 change: 1 addition & 0 deletions docs/.vitepress/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
125 changes: 125 additions & 0 deletions docs/advanced/extensions.md
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);
});
Comment on lines +45 to +51

Copy link
Copy Markdown
Contributor

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 gate extension get a server that refuses every 2025-era client, even one that declared the extension in initialize. The example at docs/advanced/extensions.md:48 reads only requestCtx.mcpReq.envelope, which a legacy request never carries, so the -32021 throw at line 50 fires on every legacy tools/call. The same guide promises legacy clients advertise the extension in initialize (line 98). Fix: make the example era-aware, falling back to ctx.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 gate and is connected on a 2025-era session. A 2025 client built with this SDK and extensions: [gateClient] sends capabilities.extensions['com.example/gate'] in initialize (packages/client/src/client/client.ts:1095), as docs/advanced/extensions.md:98 says it does. On a legacy request _onrequest sets mcpReq.envelope only when the request carried reserved _meta keys (packages/core-internal/src/shared/protocol.ts:1089), so requestCtx.mcpReq.envelope is undefined and declared is {} at docs/advanced/extensions.md:48. The middleware throws MissingRequiredClientCapabilityError at line 50 and the client gets -32021 for every tools/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 public getClientCapabilities() 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 gate example serves a 2025-era client. docs/advanced/extensions.md:48-50 reads only requestCtx.mcpReq.envelope?.[CLIENT_CAPABILITIES_META_KEY]?.extensions ?? {} and throws MissingRequiredClientCapabilityError when the id is absent. A legacy client never sends an envelope, so on every legacy tools/call declared is {} and the gate throws -32021.

Finding ID: f-26792aac

}
};
```

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): readers who follow the guide and call server.use(...) on the McpServer it builds get a compile error, since McpServer has no use method. docs/advanced/extensions.md:121 says application middleware is added "with server.use" while server in the same page (lines 75, 83) is new McpServer(...); packages/server/src/server/extension.ts:80 repeats it. The real entry point is mcp.server.use, which is what the test uses. Fix: name the low-level Server (server.server.use(...) or mcp.server.use) in both places, or add use to McpServer if that is the intended surface.

Why this was flagged

A reader builds const server = new McpServer(...) as docs/advanced/extensions.md:83 shows and then, following docs/advanced/extensions.md:121 ("middleware the application adds afterwards with server.use"), writes server.use('tools/call', mw). McpServer in packages/server/src/server/mcp.ts declares no use member (only public readonly server: Server at mcp.ts:131), so TypeScript rejects the call and at runtime it is TypeError: server.use is not a function. The JSDoc at packages/server/src/server/extension.ts:80 makes the same claim. The PR's own test reaches middleware through mcp.server.use at packages/server/test/server/extensions.test.ts:138. On the base branch neither the guide nor the JSDoc exists, so nothing steers a reader to a non-existent method. CLAUDE.md principle 6 asks that docs and comments say what the code does.

Verification: nit — triggered whenever a reader follows the guide literally and calls server.use(...) on the McpServer the page builds. docs/advanced/extensions.md:121 says "middleware the application adds afterwards with server.use", while on the same page server is new McpServer(...). McpServer (packages/server/src/server/mcp.ts:127) declares no use member. A reader gets a TypeScript error from their own code.

Finding ID: f-0dd2b8ad


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.
16 changes: 16 additions & 0 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ import {
SUPPORTED_MODERN_PROTOCOL_VERSIONS
} from '@modelcontextprotocol/core-internal';

import type { ClientExtension } from './extension';
import { installClientExtensions } from './extension';
import type { PriorDiscovery } from './probeClassifier';
import type { CacheMode, CacheScope, ResponseCacheStore } from './responseCache';
import { ClientResponseCache, InMemoryResponseCacheStore, MAX_CACHE_TTL_MS } from './responseCache';
Expand Down Expand Up @@ -181,6 +183,18 @@ export function getSupportedElicitationModes(capabilities: ClientCapabilities['e
}

export type ClientOptions = ProtocolOptions & {
/**
* Extensions to set up at construction, in order. Each is advertised under
* `capabilities.extensions[id]` as `{}`, then its `setup` runs with a
* `ClientExtensionContext`. A duplicate id fails construction before any
* extension is set up.
*
* Experimental: the shape of an extension may change in a minor release.
*
* @experimental
*/
extensions?: readonly ClientExtension[];

/**
* Capabilities to advertise as being supported by this client.
*/
Expand Down Expand Up @@ -680,6 +694,8 @@ export class Client extends Protocol<ClientContext> {
if (options?.listChanged) {
this._listChangedConfig = options.listChanged;
}

installClientExtensions(this, options?.extensions ?? []);
}

protected override buildContext(ctx: BaseContext, _transportInfo?: MessageExtraInfo): ClientContext {
Expand Down
131 changes: 131 additions & 0 deletions packages/client/src/client/extension.ts
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);
}
};
}
1 change: 1 addition & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading