Repository navigation
feat: Skills extension for server and client (ext/skills subpaths) #2972
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
Open
mattzcarey
wants to merge
2
commits into
feat/server-extensions
Choose a base branch
from
feat/skills-extension
base: feat/server-extensions
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| ``` | ||
|
|
||
| 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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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 of the client docs are told every method is refused unless the server declared the extension, but
readis not gated. docs/clients/skills.md:20 says "Every method refuses withCapabilityNotSupported", whilereadat packages/client/src/ext/skills/skillsClientExtension.ts:108 never calls#requireand issuesresources/readregardless of what the server declared. Fix: make the words match the code, either by sayinglist,getandreadDirectoryare refused (as the changeset and the class JSDoc already do) or by gatingreadon 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 withCapabilityNotSupportedunless the server declared the extension and theresourcescapability", butSkillsClientExtension.read()(packages/client/src/ext/skills/skillsClientExtension.ts:108-120) never calls#require— it goes straight toclient.readResource, so a server that declaredresourcesbut 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 throwCapabilityNotSupportedagainst a server that did not declareio.modelcontextprotocol/skills. The method at packages/client/src/ext/skills/skillsClientExtension.ts:108-120 performs no capability check and callsthis.client.readResourcedirectly, so the call proceeds and either succeeds or fails with whateverresources/readreturns. The changeset and the class JSDoc at skillsClientExtension.ts:4-6 describe onlylist,getandreadDirectoryas 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
CapabilityNotSupportedunless the server declared the extension and theresourcescapability." In packages/client/src/ext/skills/skillsClientExtension.ts,list(line 73),get(line 83) andreadDirectory(line 89) callthis.#require(...), butread(lines 108-120) never does. Nothing breaks at runtime, so this is a documentation inaccuracy.