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
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 (experimental: the `extensions` option, the extension shapes, `use` and `acceptResultType` may change in a minor release). `ServerOptions.extensions` takes `ServerExtension` objects (`{ id, install(server) }`): each is advertised under `capabilities.extensions[id]` and installed at construction; everything else an extension does, settings included, happens in `install`. Extensions register custom methods with `setRequestHandler(method, { params, result }, handler)` and intercept spec methods with the new `Protocol.use(method, (request, ctx, next) => …)` — 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. `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.
13 changes: 13 additions & 0 deletions .changeset/skills-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@modelcontextprotocol/core-internal': minor
'@modelcontextprotocol/client': minor
'@modelcontextprotocol/server': minor
---

The MCP Skills extension (`io.modelcontextprotocol/skills`, SEP-2640) as a pair of extensions.

`@modelcontextprotocol/server/ext/skills`: `new SkillsExtension(source)` in `ServerOptions.extensions` declares the extension and the `resources` capability, and serves `skills/list` and `skills/get` from a `SkillSource`, plus `resources/directory/read` (declared as `directoryRead`) when the source implements `readDirectory`. Entries are checked against the specification's structural rules before they are sent. Skill files stay ordinary resources; `skillResourceOf` computes the digest and size an entry lists for each.

`@modelcontextprotocol/client/ext/skills`: `new SkillsClientExtension()` in `ClientOptions.extensions` wraps `list`, `get` and `readDirectory`, each refused unless the server declared support, and `read(skill, uri)`, which fetches a skill file and verifies it is listed and matches the entry's size and digest.

Wire types and zod schemas live at `@modelcontextprotocol/core-internal/ext/skills` and are re-exported from both subpaths.
3 changes: 3 additions & 0 deletions docs/.vitepress/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [
{ text: 'Elicitation', link: '/servers/elicitation' },
{ text: 'Sampling (sunset)', link: '/servers/sampling' },
{ text: 'Input required', link: '/servers/input-required' },
{ text: 'Skills (extension)', link: '/servers/skills' },
{ text: 'Notifications', link: '/servers/notifications' },
{ text: 'Errors', link: '/servers/errors' }
]
Expand All @@ -53,6 +54,7 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [
{ text: 'Handle server requests', link: '/clients/server-requests' },
{ text: 'Roots (sunset)', link: '/clients/roots' },
{ text: 'Subscriptions', link: '/clients/subscriptions' },
{ text: 'Skills (extension)', link: '/clients/skills' },
{ text: 'OAuth', link: '/clients/oauth' },
{ text: 'Machine auth', link: '/clients/machine-auth' },
{ text: 'Middleware', link: '/clients/middleware' },
Expand All @@ -66,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: '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
89 changes: 89 additions & 0 deletions docs/advanced/extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
shape: how-to
---

# Server extensions

::: warning Experimental
The `extensions` option, the `ServerExtension` and `ClientExtension` shapes, `use` and `acceptResultType` are experimental and may change in a minor release.
:::

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 middleware on spec methods. What the extension does behind those hooks is its own business.

## Write an extension

An extension is an `id` and an `install` function that receives the low-level `Server`. Everything it does to the protocol happens in `install`.

```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,
install(server) {
// Settings for the advertised capability, if the extension has any.
server.registerCapabilities({ extensions: { [GATE]: { exampleData: true } } });

// A custom method, exactly as in Custom methods.
server.setRequestHandler('gate/status', { params: z.looseObject({}) }, () => ({ armed: true }));

// Middleware on a spec method: runs around the registered handler,
// may answer, transform, or refuse.
server.use('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]` as `{}` — legacy connections see it in the `initialize` result, 2026-07-28 connections in `server/discover` — and then installed in order, after the built-in handlers exist. An extension with settings registers them in `install`, as above; the maps merge.

```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, 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 wrap the ones the SDK installs with middleware.

```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 middleware composes

`setRequestHandler` is the route handler; `use(method, middleware)` is the middleware around it, Koa-shaped: `await next(request, ctx)` yields the result and the middleware returns what goes on the wire. It wraps whatever handler serves `method` at dispatch time. 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 installed outermost. The returned function removes the middleware. Method names are exact; there is no wildcard. Something every peer must know about the extension belongs in its advertised capability (`registerCapabilities`), 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 protocol-level errors the client must see as errors — `UrlElicitationRequiredError` and `MissingRequiredClientCapabilityError` (`-32021`).

## Recap

- `ServerExtension` is `{ id, install(server) }`; pass it in `ServerOptions.extensions`. `ClientExtension` mirrors it on `ClientOptions.extensions`.
- `install` gets the low-level `Server`: `setRequestHandler` for custom methods, `use` for middleware on spec methods, `registerCapabilities` for the extension's settings.
- Middleware composes at dispatch time, in registration order, and applies 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.
39 changes: 39 additions & 0 deletions docs/clients/skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
shape: how-to
---

# Skills (extension)

The [MCP Skills extension](https://github.com/modelcontextprotocol/ext-skills) (`io.modelcontextprotocol/skills`) lets a server publish [Agent Skills](https://agentskills.io/). `@modelcontextprotocol/client/ext/skills` is the client side, as a client extension: it lists and gets skill entries, reads directories, and reads skill files verified against their entry.

## Install the extension

```ts
import { Client } from '@modelcontextprotocol/client';
import { SkillsClientExtension } from '@modelcontextprotocol/client/ext/skills';

const skills = new SkillsClientExtension();
const client = new Client({ name: 'host', version: '1.0.0' }, { extensions: [skills] });
await client.connect(transport);
```

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 of the client docs are told every method is refused unless the server declared the extension, but read is not gated. docs/clients/skills.md:20 says "Every method refuses with CapabilityNotSupported", while read at packages/client/src/ext/skills/skillsClientExtension.ts:108 never calls #require and issues resources/read regardless of what the server declared. Fix: make the words match the code, either by saying list, get and readDirectory are refused (as the changeset and the class JSDoc already do) or by gating read on the same check. [also at: docs/clients/skills.md:20 - nit: CLAUDE.md asks that docs say what the code does: this line states "Every method refuses with CapabilityNotSupported unless the server declared the extension and the resources capability", but SkillsClientExtension.read() (packages/client/src/ext/skills/skillsClientExtension.ts:108-120) never calls #require — it goes straight to client.readResource, so a server that declared resources but not the skills extension is read from without refusal.]

Why this was flagged

A user reads docs/clients/skills.md:20 and expects skills.read(skill, uri) to throw CapabilityNotSupported against a server that did not declare io.modelcontextprotocol/skills. The method at packages/client/src/ext/skills/skillsClientExtension.ts:108-120 performs no capability check and calls this.client.readResource directly, so the call proceeds and either succeeds or fails with whatever resources/read returns. The changeset and the class JSDoc at skillsClientExtension.ts:4-6 describe only list, get and readDirectory as refused, so the docs page alone is wrong. Nothing breaks at runtime; the doc misstates the behaviour.

Verification: nit. The doc line is docs/clients/skills.md:20: "Every method refuses with CapabilityNotSupported unless the server declared the extension and the resources capability." In packages/client/src/ext/skills/skillsClientExtension.ts, list (line 73), get (line 83) and readDirectory (line 89) call this.#require(...), but read (lines 108-120) never does. Nothing breaks at runtime, so this is a documentation inaccuracy.

Every method refuses with `CapabilityNotSupported` unless the server declared the extension and the `resources` capability. `readDirectory` also needs the server to declare `directoryRead`.

## List and get skills

`list` returns one page of entries. Pass `nextCursor` back as `cursor` for the next page. An entry is the full manifest: frontmatter, plus every file with its digest and size. `get(uri)` returns the entry for one skill by the URI of its `SKILL.md`, whether or not the listing included it.

```ts
const { skills: entries, nextCursor } = await skills.list();
const { skill } = await skills.get('skill://git-workflow/SKILL.md');
```

## Read a skill file

`read(skill, uri)` fetches the file with `resources/read` and checks it against the entry. The file must be listed, and its size and SHA-256 digest must match. Any mismatch throws `SdkError` with `InvalidResult`. Refresh the entry with `get` and ask the user to approve the skill again.

```ts
const manifest = await skills.read(skill, skill.uri);
```

A skill with `resources: 'dynamic'` has nothing to verify against, so its files come back as read. The extension leaves two checks to the host: comparing a `SKILL.md`'s frontmatter with the entry (this needs a YAML parser), and the specification's [security requirements](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx#security-considerations) for loading skills into a model.
57 changes: 57 additions & 0 deletions docs/servers/skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
shape: how-to
---

# Skills (extension)

The [MCP Skills extension](https://github.com/modelcontextprotocol/ext-skills) (`io.modelcontextprotocol/skills`) lets a server publish [Agent Skills](https://agentskills.io/): directories of instructions, each with a `SKILL.md`, that a host can load into its model. `@modelcontextprotocol/server/ext/skills` is the server side, as a [server extension](../advanced/extensions.md). It serves `skills/list`, `skills/get` and, optionally, `resources/directory/read`. Your server supplies the skill entries through a `SkillSource`, and serves the skill files as ordinary resources.

## Describe a skill

A skill entry names its `SKILL.md`, repeats that file's frontmatter, and lists every file of the skill with its SHA-256 digest and size. `skillResourceOf(uri, content)` computes a file's line in that list.

```ts
import type { Skill } from '@modelcontextprotocol/server/ext/skills';
import { skillResourceOf } from '@modelcontextprotocol/server/ext/skills';

const files = {
'skill://git-workflow/SKILL.md': '---\nname: git-workflow\ndescription: Follow our Git conventions\n---\n\nBranch from main.\n'
};

const gitWorkflow: Skill = {
uri: 'skill://git-workflow/SKILL.md',
frontmatter: { name: 'git-workflow', description: 'Follow our Git conventions' },
resources: await Promise.all(Object.entries(files).map(([uri, text]) => skillResourceOf(uri, text)))
};
```

The frontmatter must match the `SKILL.md` exactly, and the URI's last directory must equal `frontmatter.name`. The server checks the second rule, and the entry's other structural rules, and answers `-32603` rather than send an entry that breaks one. A skill generated per request, with no stable digests, sets `resources: 'dynamic'`.

## Install the extension

`SkillsExtension` takes the source. `list` returns a page of entries (an empty or partial listing is allowed). `get` answers for any skill the server serves, listed or not, and returns `undefined` for anything else, which the client receives as `-32602`.

```ts
import { McpServer } from '@modelcontextprotocol/server';
import { SkillsExtension } from '@modelcontextprotocol/server/ext/skills';

const skills = new SkillsExtension(
{
list: () => ({ skills: [gitWorkflow] }),
get: ({ uri }) => (uri === gitWorkflow.uri ? gitWorkflow : undefined)
},
{ cacheHint: { ttlMs: 300_000, cacheScope: 'public' } }
);

const server = new McpServer({ name: 'skills-server', version: '1.0.0' }, { extensions: [skills] });

for (const [uri, text] of Object.entries(files)) {
server.registerResource(uri, uri, { mimeType: 'text/markdown' }, () => ({ contents: [{ uri, mimeType: 'text/markdown', text }] }));
}
```

The server declares `io.modelcontextprotocol/skills` under `capabilities.extensions`, along with the `resources` capability the extension requires. `cacheHint` sets the `ttlMs` and `cacheScope` on `skills/list` and `skills/get` results. Both default to `0` and `'private'`.

## Serve directory reads

A source that implements `readDirectory` also gets `resources/directory/read`, and the server declares `directoryRead: true`. It returns the direct children of a directory such as `skill://pdf-processing/templates`, with subdirectories marked `mimeType: 'inode/directory'`, or `undefined` when the URI is not a directory.
13 changes: 13 additions & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@
"default": "./dist/stdio.cjs"
}
},
"./ext/skills": {
"import": {
"types": "./dist/ext/skills/index.d.mts",
"default": "./dist/ext/skills/index.mjs"
},
"require": {
"types": "./dist/ext/skills/index.d.cts",
"default": "./dist/ext/skills/index.cjs"
}
},
"./validators/ajv": {
"import": {
"types": "./dist/validators/ajv.d.mts",
Expand Down Expand Up @@ -115,6 +125,9 @@
],
"stdio": [
"dist/stdio.d.mts"
],
"ext/skills": [
"dist/ext/skills/index.d.mts"
]
}
},
Expand Down
17 changes: 17 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,17 @@ 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}.
*
* Experimental: the shape of an extension may change in a minor release.
*
* @experimental
*/
extensions?: ClientExtension[];

/**
* Capabilities to advertise as being supported by this client.
*/
Expand Down Expand Up @@ -658,6 +670,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.install(this);
}
}

protected override buildContext(ctx: BaseContext, _transportInfo?: MessageExtraInfo): ClientContext {
Expand Down
32 changes: 32 additions & 0 deletions packages/client/src/client/extension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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]`
* (as `{}`: supported, no settings)
* (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 wraps the ones the SDK
* installs with middleware (`client.use('elicitation/create', …)`).
* The SDK provides the hooks; what an extension does behind them is its own.
*
* 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
* `capabilities.extensions`. Settings for that key, if any, are the
* extension's to register in `install` via `registerCapabilities`.
*/
readonly id: string;
/** Installs the extension's handlers and middleware onto the client. Called once, at construction. */
install(client: Client): void;
}
Loading
Loading