Skip to content
Draft
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
9 changes: 9 additions & 0 deletions .changeset/server-extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@modelcontextprotocol/core-internal': minor
'@modelcontextprotocol/client': minor
'@modelcontextprotocol/server': minor
---

Server and client extensions. `ServerOptions.extensions` takes `ServerExtension` objects (`{ id, capability?, install(server) }`): each is advertised under `capabilities.extensions[id]` and installed at construction. Extensions register custom methods with `setRequestHandler(method, { params, result }, handler)` and intercept spec methods with the new `Protocol.overrideRequestHandler(method, (request, ctx, next) => …)`, which composes around the registered handler at dispatch time (so an override on `tools/call` applies even though `McpServer` registers that handler lazily) and returns a remover. `ClientOptions.extensions` takes the symmetric `ClientExtension` objects, advertised under the client's `capabilities.extensions[id]` (in `initialize` on a legacy connection, in every request's client-capabilities envelope on 2026-07-28) and installed with the `Client`. `Protocol.acceptResultType(method, resultType)` declares an extension result kind for a method: a raw response carrying that `resultType` bypasses the era codec's closed vocabulary and is validated against the caller's explicit result schema as-is, which is how a client extension receives shapes such as the Tasks extension's `resultType: "task"` on `tools/call`.

`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 @@ -66,6 +66,7 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [
items: [
{ text: 'Low-level server', link: '/advanced/low-level-server' },
{ text: 'Custom methods', link: '/advanced/custom-methods' },
{ text: 'Server extensions', link: '/advanced/extensions' },
{ text: 'Schema libraries', link: '/advanced/schema-libraries' },
{ text: 'Custom transports', link: '/advanced/custom-transports' },
{ text: 'Wire schemas', link: '/advanced/wire-schemas' },
Expand Down
83 changes: 83 additions & 0 deletions docs/advanced/extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
---
shape: how-to
---

# Server extensions

A **server extension** packages protocol behaviour outside the core specification — an MCP extension such as `io.modelcontextprotocol/tasks`, or a vendor feature — as one object you pass to the server. The SDK advertises it, installs it, and gives it two hooks: custom methods and overrides of spec methods. What the extension does behind those hooks is its own business.

## Write an extension

An extension is an `id`, an optional settings object, and an `install` function that receives the low-level `Server`.

```ts
import type { ServerExtension } from '@modelcontextprotocol/server';
import { MissingRequiredClientCapabilityError, CLIENT_CAPABILITIES_META_KEY } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

const GATE = 'com.example/gate';

export const gate: ServerExtension = {
id: GATE,
capability: { exampleData: true },
install(server) {
// A custom method, exactly as in Custom methods.
server.setRequestHandler('gate/status', { params: z.looseObject({}) }, () => ({ armed: true }));

// An override of a spec method: runs before the registered handler,
// may answer, transform, or refuse.
server.overrideRequestHandler('tools/call', (request, ctx, next) => {
const declared = ctx.mcpReq.envelope?.[CLIENT_CAPABILITIES_META_KEY]?.extensions ?? {};
if (!(GATE in declared)) {
throw new MissingRequiredClientCapabilityError({ requiredCapabilities: { extensions: { [GATE]: {} } } }, 'declare the gate');
}
return next(request, ctx);
});
}
};
```

## Install it

Pass extensions at construction. Each is advertised under `capabilities.extensions[id]` — legacy connections see it in the `initialize` result, 2026-07-28 connections in `server/discover` — and installed in order after the built-in handlers exist.

```ts
const server = new McpServer({ name: 'gated', version: '1.0.0' }, { extensions: [gate] });
```

The same option exists on the low-level `Server`.

## Client extensions

The client half is symmetric: `ClientExtension` is `{ id, capability?, install(client) }`, passed in `ClientOptions.extensions`. The client advertises it under its own `capabilities.extensions[id]` — in `initialize` on a legacy connection, and in every request's `_meta` client-capabilities envelope on a 2026-07-28 connection, which is where a server extension reads it — and `install` receives the `Client` to register handlers for server-to-client requests and notifications, or override the ones the SDK installs.

```ts
import type { ClientExtension } from '@modelcontextprotocol/client';

const gateClient: ClientExtension = {
id: GATE,
install(client) {
client.setRequestHandler('gate/ping', { params: z.looseObject({}) }, () => ({ pong: true }));
// Results of tools/call may carry the extension's own kind; the
// explicit-schema request() path then hands them to the caller's
// schema as-is instead of rejecting the unknown resultType.
client.acceptResultType('tools/call', 'gate');
}
};

const client = new Client({ name: 'gated-client', version: '1.0.0' }, { extensions: [gateClient] });
```

## How overrides compose

`overrideRequestHandler(method, override)` wraps whatever handler serves `method` at dispatch time. That matters for `tools/call`, which `McpServer` registers on the first tool registration: an override installed at construction still applies. With no underlying handler, `next` throws `MethodNotFound`. Several overrides nest, the latest outermost. The returned function removes the override.

A thrown `ProtocolError` becomes the JSON-RPC error response. Inside a tool handler, `McpServer` converts most throws into an `isError` tool result; the exceptions are protocol-level errors the client must see as errors — `UrlElicitationRequiredError` and `MissingRequiredClientCapabilityError` (`-32021`).

## Recap

- `ServerExtension` is `{ id, capability?, install(server) }`; pass it in `ServerOptions.extensions`. `ClientExtension` mirrors it on `ClientOptions.extensions`.
- `install` gets the low-level `Server`: `setRequestHandler` for custom methods, `overrideRequestHandler` to intercept spec methods.
- Overrides compose at dispatch time and apply to handlers registered later.
- `acceptResultType(method, resultType)` lets a client extension receive a result kind outside `complete` / `input_required` through the explicit-schema `request()` path.
- The SDK owns the hooks and the capability advertisement, not the extension's state or execution.
13 changes: 13 additions & 0 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import {
SUPPORTED_MODERN_PROTOCOL_VERSIONS
} from '@modelcontextprotocol/core-internal';

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

export type ClientOptions = ProtocolOptions & {
/**
* Extensions to install at construction. Each is advertised under
* `capabilities.extensions[extension.id]` and then installed, in order —
* see {@linkcode ClientExtension}.
*/
extensions?: ClientExtension[];

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

for (const extension of options?.extensions ?? []) {
this.registerCapabilities({ extensions: { [extension.id]: extension.capability ?? {} } });
extension.install(this);
}
}

protected override buildContext(ctx: BaseContext, _transportInfo?: MessageExtraInfo): ClientContext {
Expand Down
34 changes: 34 additions & 0 deletions packages/client/src/client/extension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { JSONObject } from '@modelcontextprotocol/core-internal';

import type { Client } from './client';

/**
* A client extension: the client half of protocol behaviour outside the
* core specification (an MCP extension such as `io.modelcontextprotocol/tasks`,
* or a vendor feature) that installs itself onto a {@linkcode Client}.
*
* Pass extensions at construction — `new Client(info, { extensions: [ext] })`.
* The client advertises each extension under `capabilities.extensions[id]`
* (in `initialize` on a legacy connection, in every request's
* `_meta` client-capabilities envelope on a 2026-07-28 connection) and then
* calls `install`, which is where the extension registers handlers for
* server-to-client requests and notifications, or overrides the ones the
* SDK installs (`client.overrideRequestHandler('elicitation/create', …)`).
* The SDK provides the hooks; what an extension does behind them is its own.
*/
export interface ClientExtension {
/**
* The extension identifier, prefix-qualified (`io.modelcontextprotocol/tasks`,
* `com.example/feature-flags`). Advertised as the key under
* `capabilities.extensions`.
*/
readonly id: string;
/**
* The extension's settings object, advertised as the value under
* `capabilities.extensions[id]`. `{}` (the default) means supported with
* no settings.
*/
readonly capability?: JSONObject;
/** Installs the extension's handlers and overrides onto the client. Called once, at construction. */
install(client: Client): void;
}
1 change: 1 addition & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export {
StaticPrivateKeyJwtProvider
} from './client/authExtensions';
export type { CacheableRequestOptions, CallToolRequestOptions, ClientOptions, ConnectOptions, McpSubscription } from './client/client';
export type { ClientExtension } from './client/extension';
export { Client } from './client/client';
export { getSupportedElicitationModes } from './client/client';
export type { DiscoverAndRequestJwtAuthGrantOptions, JwtAuthGrantResult, RequestJwtAuthGrantOptions } from './client/crossAppAccess';
Expand Down
145 changes: 145 additions & 0 deletions packages/client/test/client/extensions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* `ClientOptions.extensions`: an extension is advertised under the client's
* `capabilities.extensions[id]` — in `initialize` on a legacy connection, in
* every request's `_meta` client-capabilities envelope on a 2026-07-28
* connection — and installed at construction, where it can register
* handlers for server-to-client requests.
*/
import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal';
import { CLIENT_CAPABILITIES_META_KEY, InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';
import * as z from 'zod/v4';

import { Client } from '../../src/client/client';
import type { ClientExtension } from '../../src/client/extension';

const MODERN = '2026-07-28';
const EXT_ID = 'com.example/gate';

const flush = () => new Promise(resolve => setTimeout(resolve, 20));

function gateExtension(log: string[]): ClientExtension {
return {
id: EXT_ID,
capability: { exampleData: true },
install(client) {
log.push('installed');
client.setRequestHandler('gate/ping', { params: z.looseObject({}) }, () => ({ pong: true }));
client.acceptResultType('tools/call', 'task');
}
};
}

/** A scripted server side: answers the handshake for the requested era and records everything the client writes. */
async function scriptedServer(era: 'modern' | 'legacy') {
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
const written: JSONRPCMessage[] = [];
serverTx.onmessage = message => {
written.push(message);
const request = message as { id?: number | string; method?: string };
if (request.id === undefined) return;
if (request.method === 'server/discover') {
void serverTx.send(
era === 'modern'
? {
jsonrpc: '2.0',
id: request.id,
result: {
resultType: 'complete',
supportedVersions: [MODERN],
capabilities: { tools: {} },
_meta: { 'io.modelcontextprotocol/serverInfo': { name: 'scripted', version: '1.0.0' } }
}
}
: { jsonrpc: '2.0', id: request.id, error: { code: -32_601, message: 'Method not found' } }
);
} else if (request.method === 'initialize') {
void serverTx.send({
jsonrpc: '2.0',
id: request.id,
result: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: { name: 'scripted', version: '1.0.0' }
}
});
} else if (request.method === 'tools/call') {
void serverTx.send({
jsonrpc: '2.0',
id: request.id,
result: { resultType: 'task', taskId: 't-1', status: 'working', createdAt: 'now', lastUpdatedAt: 'now', ttlMs: null }
});
} else if (request.method === 'tools/list') {
void serverTx.send({
jsonrpc: '2.0',
id: request.id,
result: era === 'modern' ? { resultType: 'complete', tools: [], ttlMs: 0, cacheScope: 'public' } : { tools: [] }
});
}
};
await serverTx.start();
return { clientTx, serverTx, written };
}

const paramsOf = (message: JSONRPCMessage): Record<string, unknown> => (message as { params?: Record<string, unknown> }).params ?? {};

describe('ClientOptions.extensions', () => {
it('installs the extension at construction', () => {
const log: string[] = [];
new Client({ name: 'c', version: '1' }, { extensions: [gateExtension(log)] });
expect(log).toEqual(['installed']);
});

it('defaults the advertised settings to {}', async () => {
const { clientTx, written } = await scriptedServer('legacy');
const client = new Client({ name: 'c', version: '1' }, { extensions: [{ id: 'com.example/plain', install: () => {} }] });
await client.connect(clientTx);
const initialize = written.find(message => (message as { method?: string }).method === 'initialize');
expect(paramsOf(initialize as JSONRPCMessage)['capabilities']).toMatchObject({ extensions: { 'com.example/plain': {} } });
await client.close();
});

it('sends the extension in initialize on a legacy connection', async () => {
const { clientTx, written } = await scriptedServer('legacy');
const client = new Client({ name: 'c', version: '1' }, { extensions: [gateExtension([])] });
await client.connect(clientTx);
const initialize = written.find(message => (message as { method?: string }).method === 'initialize');
expect(paramsOf(initialize as JSONRPCMessage)['capabilities']).toMatchObject({ extensions: { [EXT_ID]: { exampleData: true } } });
await client.close();
});

it('stamps the extension into every request envelope on a 2026-07-28 connection', async () => {
const { clientTx, written } = await scriptedServer('modern');
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' }, extensions: [gateExtension([])] });
await client.connect(clientTx);
await client.listTools();
await flush();
const toolsList = written.find(message => (message as { method?: string }).method === 'tools/list');
const meta = paramsOf(toolsList as JSONRPCMessage)['_meta'] as Record<string, unknown>;
expect(meta[CLIENT_CAPABILITIES_META_KEY]).toMatchObject({ extensions: { [EXT_ID]: { exampleData: true } } });
await client.close();
});

it('receives an extension result kind the extension accepted, discriminator included', async () => {
const { clientTx } = await scriptedServer('modern');
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' }, extensions: [gateExtension([])] });
await client.connect(clientTx);
const taskSchema = z.looseObject({ resultType: z.literal('task'), taskId: z.string(), status: z.string() });
const result = await client.request({ method: 'tools/call', params: { name: 'slow', arguments: {} } }, taskSchema);
expect(result).toMatchObject({ resultType: 'task', taskId: 't-1', status: 'working' });
// callTool validates against CallToolResultSchema, which a task handle does not satisfy.
await expect(client.callTool({ name: 'slow', arguments: {} })).rejects.toThrow(/Invalid result for tools\/call/);
await client.close();
});

it('serves the handler the extension installed for a server-to-client request', async () => {
const { clientTx, serverTx, written } = await scriptedServer('legacy');
const client = new Client({ name: 'c', version: '1' }, { extensions: [gateExtension([])] });
await client.connect(clientTx);
await serverTx.send({ jsonrpc: '2.0', id: 'srv-1', method: 'gate/ping', params: {} });
await flush();
const response = written.find(message => 'id' in message && message.id === 'srv-1');
expect((response as { result?: unknown }).result).toEqual({ pong: true });
await client.close();
});
});
Loading
Loading