Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
c1439c9
feat(skills): add SEP-2640 Skills extension APIs (schemas, client ops…
tobi-oye Sep 14, 2026
b009106
test(core-internal): pin ./ext/skills in the public package topology
tobi-oye Sep 14, 2026
abdc658
fix(skills): default cache hints only on modern protocol revisions
tobi-oye Sep 22, 2026
a3b4450
Merge pull request #1 from tobi-oye/codex/skills-cache-hint-compatibi…
tobi-oye Sep 23, 2026
868a491
Merge branch 'main' into feat/ext-skills-phase-1
tobi-oye Sep 23, 2026
b981473
Merge branch 'main' into feat/ext-skills-phase-1
KKonstantinov Oct 16, 2026
94976dd
feat(core): align Skills extension schemas with the stable spec
KKonstantinov Oct 17, 2026
5515d03
feat(core-internal): skills frontmatter parser
KKonstantinov Oct 17, 2026
c77490c
fix(core-internal): fail closed on flow indicators and complex flow keys
KKonstantinov Oct 17, 2026
77cbce7
feat(core-internal): skills digests, URIs and entry validation
KKonstantinov Oct 17, 2026
9c25b72
fix(core-internal): tighten skill URI containment and root rules
KKonstantinov Oct 17, 2026
faaa890
feat(server): SkillsExtension and SkillSource
KKonstantinov Oct 17, 2026
498c89f
feat(server): build skill catalogs from a storage adapter
KKonstantinov Oct 17, 2026
173caff
fix(server): report symlinked skills and unencodable names, speed up …
KKonstantinov Oct 17, 2026
73431e2
perf(server): build skill catalogs in near-linear time
KKonstantinov Oct 17, 2026
7c61b9a
feat(server): adapter-built skill catalogs with reload
KKonstantinov Oct 17, 2026
4805bda
fix(server): keep reload() notifications from crashing the process
KKonstantinov Oct 17, 2026
ef74191
feat(server): load skills from a folder (ext/skills/node)
KKonstantinov Oct 17, 2026
e934057
test(server): one module-graph walker for the runtime-neutrality pins
KKonstantinov Oct 17, 2026
8fb3e69
feat(client): SkillsClient list, get and readDirectory
KKonstantinov Oct 17, 2026
02c8479
feat(client): SkillsClient with verified reads
KKonstantinov Oct 17, 2026
ceafeb9
fix(skills): publish skill URIs as URL serialises them and match read…
KKonstantinov Oct 17, 2026
9a322bc
fix(skills): encode the URI authority strictly and key stray percent …
KKonstantinov Oct 17, 2026
b7753ae
fix(core-internal): key a stray percent-hex escape as a literal percent
KKonstantinov Oct 17, 2026
96a6d1e
fix(client): surface 2026-07-28 method-not-found responses as JSON-RP…
KKonstantinov Oct 17, 2026
07dd73c
test: skills integration and wire-behaviour suites
KKonstantinov Oct 17, 2026
05c82ce
test(conformance): run SEP-2640 scenarios against the SDK API
KKonstantinov Oct 17, 2026
175b8c4
test(conformance): run the skills scenarios on both protocol versions
KKonstantinov Oct 17, 2026
c38113f
docs(skills): examples/skills story
KKonstantinov Oct 17, 2026
d5f924d
docs(skills): guides and changesets
KKonstantinov Oct 17, 2026
7ec12ec
docs(skills): cover invalid entries, strict and host duties; tighten …
KKonstantinov Oct 17, 2026
19c43da
fix(core-internal): keep Unicode whitespace in skill frontmatter values
KKonstantinov Oct 17, 2026
0ddb7b4
fix(server): brand SkillCatalogError across bundle copies
KKonstantinov Oct 17, 2026
7ee8d91
fix(skills): keep skills out of the root bundles and harden the serve…
KKonstantinov Oct 17, 2026
6da07a6
fix(core-internal): count skill limits in characters and list each fi…
KKonstantinov Oct 18, 2026
1e80947
fix(server): refuse handler overwrites in attach and hash each skill …
KKonstantinov Oct 18, 2026
8afe260
fix(client): close the frontmatter __proto__ bypass and share Client'…
KKonstantinov Oct 18, 2026
62fee61
revert(client): move the 2026-07-28 HTTP 404 method-not-found fix to …
KKonstantinov Oct 18, 2026
e99022e
fix(skills): trim trailing slashes without a backtracking regex
KKonstantinov Oct 18, 2026
5d72594
test(skills): write in-code markdown as template literals
KKonstantinov Oct 18, 2026
290d049
fix(core-internal): look skill MIME types up by own keys only
KKonstantinov Oct 18, 2026
7521058
fix(server): keep a throwing onWarning from escaping list_changed not…
KKonstantinov Oct 18, 2026
8127bc5
fix(client): request a dynamic skill file at the URI its ref resolves to
KKonstantinov Oct 18, 2026
ba97a2a
fix(client): make SkillsClient.list() strict by default, with skipInv…
KKonstantinov Oct 18, 2026
8117e28
docs(skills): name the YAML typing that frontmatter verification uses
KKonstantinov Oct 18, 2026
c033b09
refactor(core): drop three unused skills constants
KKonstantinov Oct 18, 2026
ba0dcb5
chore(core-internal): cut the skills note on the barrel to one line
KKonstantinov Oct 18, 2026
a7da98d
fix(client): pass parseFrontmatter only the YAML between the --- lines
KKonstantinov Oct 18, 2026
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
18 changes: 18 additions & 0 deletions .changeset/skills-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@modelcontextprotocol/core': minor
'@modelcontextprotocol/client': minor
'@modelcontextprotocol/server': minor
---

author: @KKonstantinov
author: @tobi-oye
author: @mattzcarey

Add the Skills extension (SEP-2640), reachable through new `ext/skills` subpaths; package root exports are unchanged.

- `@modelcontextprotocol/server/ext/skills`: `SkillsExtension` (`attach(server)` serves `skills/list`, `skills/get`, `resources/directory/read` and a `skill://{+path}` resource template), `SkillsExtension.load(files)` returning a `SkillCatalog` with `entries` and `reload()`, the `SkillFiles` storage adapter with `memorySkillFiles`, `SkillSource` for generated catalogs, `skillEntry`, and `SkillCatalogError`. Invalid skills are skipped with a warning (`onWarning`), or fail under `strict`. A file whose bytes are valid UTF-8 is served as `text`, whatever its MIME type; any other file as a base64 `blob`.
- `@modelcontextprotocol/server/ext/skills/node`: `loadSkills(folder)` and `nodeSkillFiles(folder)`.
- `@modelcontextprotocol/client/ext/skills`: `SkillsClient` with `list()` (throws on an invalid entry; `skipInvalid: true` keeps the valid ones and returns the rest in `invalid`), `get()`, `read(skill, path?)` (size, digest and `SKILL.md` frontmatter verified, `SkillVerificationError` on a mismatch; a copy from the response cache that fails a check is fetched once more with `cacheMode: 'refresh'` first) and `readDirectory()`.
- `@modelcontextprotocol/core/ext/skills`: schemas, types and constants, including `SKILL_LIMITS`.

Works on 2025-11-25 and 2026-07-28; caching fields (`ttlMs`, `cacheScope`) are sent on 2026-07-28 only.
2 changes: 2 additions & 0 deletions .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jobs:
- run: pnpm install
- run: pnpm run build:all
- run: pnpm run test:conformance:client:all
- run: pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:client:skills:2026
- run: pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:client:2026

server-conformance:
Expand All @@ -50,4 +51,5 @@ jobs:
- run: pnpm run test:conformance:server
- run: pnpm run test:conformance:server:draft
- run: pnpm run test:conformance:server:extensions
- run: pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:skills:2025
- run: pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:2026
4 changes: 3 additions & 1 deletion docs/.vitepress/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [
items: [
{ text: 'Tools', link: '/servers/tools' },
{ text: 'Resources', link: '/servers/resources' },
{ text: 'Skills', link: '/servers/skills' },
{ text: 'Prompts', link: '/servers/prompts' },
{ text: 'Completion', link: '/servers/completion' },
{ text: 'Logging, progress, cancellation', link: '/servers/logging-progress-cancellation' },
Expand Down Expand Up @@ -56,7 +57,8 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [
{ text: 'OAuth', link: '/clients/oauth' },
{ text: 'Machine auth', link: '/clients/machine-auth' },
{ text: 'Middleware', link: '/clients/middleware' },
{ text: 'Caching', link: '/clients/caching' }
{ text: 'Caching', link: '/clients/caching' },
{ text: 'Skills', link: '/clients/skills' }
]
},
{ text: 'Protocol versions', link: '/protocol-versions' },
Expand Down
151 changes: 151 additions & 0 deletions docs/clients/skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
---
shape: how-to
---

# Skills

`SkillsClient` lists a server's skills and reads their files, checking each file against what the server published.

```ts source="../../examples/guides/clients/skills.examples.ts#SkillsClient_list"
import { SkillsClient } from '@modelcontextprotocol/client/ext/skills';

const skills = new SkillsClient(client);
const { skills: entries } = await skills.list();
console.log(entries.map(entry => `${entry.frontmatter.name}: ${entry.frontmatter.description}`).join('\n'));
```

Against a server with two skills, it prints each skill's name and description:

```text
refunds: Process customer refunds.
git-workflow: Branching and review conventions.
```

Listing reads no skill file: each **entry** carries the skill's frontmatter and its file list with digests, enough to decide which skill to load.

## Handle refused entries

Create the client with `skipInvalid: true` to keep the valid skills and log the refused entries.

```ts source="../../examples/guides/clients/skills.examples.ts#list_invalid"
const tolerant = new SkillsClient(client, { skipInvalid: true });
const { invalid } = await tolerant.list();
for (const refused of invalid) console.error(`skills: refused entry #${refused.index} ${refused.uri ?? ''}: ${refused.reason}`);
```

By default `list()` throws an `SdkError` naming every entry that fails validation, and every copy of a repeated URI. With `skipInvalid: true` it returns the valid skills and puts those entries in `invalid`, each with `index`, `uri` when it has one, and `reason`. Against this server it is empty, so the loop prints nothing.

## Load a skill

Read the entry's `SKILL.md`.

```ts source="../../examples/guides/clients/skills.examples.ts#read_manifest"
const git = entries.find(entry => entry.frontmatter.name === 'git-workflow')!;
const manifest = await skills.read(git);
console.log(manifest.verified, manifest.frontmatter);
```

The file passes every check, and its frontmatter matches the entry:

```text
true {
name: 'git-workflow',
description: 'Branching and review conventions.'
}
```

`read(entry)` checks the size and SHA-256 digest against the entry, then compares the frontmatter field by field. `manifest.body` holds the instructions to give the model.

The comparison parses the frontmatter with YAML 1.2 core-schema typing, so `yes` is the string `"yes"`, not `true`. If a server builds its entries with a parser that types values differently, pass a matching YAML parser as `parseFrontmatter` to `new SkillsClient`; it receives the YAML between the `---` lines.

## Read the files a skill references

Pass the path `SKILL.md` uses.

```ts source="../../examples/guides/clients/skills.examples.ts#read_reference"
const review = await skills.read(git, 'references/REVIEW.md');
console.log(review.text);
```

`review.text` holds the verified file:

```text
# Review checklist

- Tests pass.

```

A relative path resolves against the skill's folder, as a link in `SKILL.md` does, and `read` ignores a fragment. It matches the path to the entry's file list however either side percent-encodes the name, so `references/Q&A.md` works. `bytes` always holds the file's bytes; `text` holds the file's text when the server sent it as text, and always for the skill's own `SKILL.md`.

## Handle verification failures

Catch `SkillVerificationError`.

```ts source="../../examples/guides/clients/skills.examples.ts#SkillVerificationError"
import { SkillVerificationError } from '@modelcontextprotocol/client/ext/skills';

try {
await skills.read(git, 'references/MISSING.md');
} catch (error) {
if (!(error instanceof SkillVerificationError)) throw error;
console.log(error.reason, error.uri);
}
```

The entry does not list `references/MISSING.md`, so `read` refuses it before sending any request:

```text
unlisted skill://git-workflow/references/MISSING.md
```

`reason` is `unlisted`, `size`, `digest` or `frontmatter`; a path that does not parse is `unlisted` too. When a size, digest or frontmatter check fails on a copy the client served from its response cache, `read` fetches the file once more with `cacheMode: 'refresh'` and throws only if the fresh copy fails as well; a copy that came from the server fails at once. On any reason, do not use the file; for `SKILL.md`, do not load the skill.

## Browse a skill folder

List a folder's direct children when the server declares `directoryRead`.

```ts source="../../examples/guides/clients/skills.examples.ts#readDirectory"
if (skills.directoryRead) {
const children = await skills.readDirectory('skill://acme/billing/refunds');
console.log(children.map(child => `${child.name} ${child.mimeType}`).join('\n'));
}
```

The `refunds` folder holds its `SKILL.md` and a `templates` folder:

```text
SKILL.md text/markdown
templates inode/directory
```

## Get a skill by URI

Fetch one entry, listed or not.

```ts source="../../examples/guides/clients/skills.examples.ts#get"
const refunds = await skills.get('skill://acme/billing/refunds/SKILL.md');
console.log(refunds.frontmatter.name);
```

It prints the skill's name from the entry's frontmatter:

```text
refunds
```

::: info Dynamic skills
An entry whose `resources` is `"dynamic"` has no file list. `read` then skips the size and digest checks and returns `verified: false`, but still compares the `SKILL.md` frontmatter.
:::

::: warning Host responsibilities
Accept skills up to `SKILL_LIMITS` (512 files, 16 MiB) and explain why you decline one, get approval before you load a skill from a server, and record which server each skill came from. Read a skill's files when you load it, never while listing.
:::

## Recap

- `new SkillsClient(client)` wraps a connected client; `list()` returns entries without reading files.
- `list()` throws on an invalid entry; `skipInvalid: true` keeps the valid skills and returns the refused ones in `invalid`.
- `read(entry)` verifies `SKILL.md` (size, digest, frontmatter) and returns its frontmatter and body.
- `read(entry, path)` resolves a path against the skill's folder and verifies that file.
- A failed check throws `SkillVerificationError` with a `reason`.
196 changes: 196 additions & 0 deletions docs/servers/skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
---
shape: how-to
---

# Skills

`loadSkills` reads a folder of skills once; `attach` serves it on a server.

```ts source="../../examples/guides/servers/skills.examples.ts#loadSkills_basic"
import { McpServer } from '@modelcontextprotocol/server';
import { loadSkills } from '@modelcontextprotocol/server/ext/skills/node';

const skills = await loadSkills('./skills');

const server = new McpServer({ name: 'team-skills', version: '1.0.0' });
skills.attach(server);
```

A **skill** is a folder with a `SKILL.md` and the files it references (SEP-2640). Clients list skills with `skills/list` and read each file with `resources/read`, checking it against the digest your server published.

## Lay out the skills folder

Each folder that contains a `SKILL.md` is one skill; other folders only group skills in the URI.

```text
skills/
├── git-workflow/
│ ├── SKILL.md
│ └── references/REVIEW.md
└── acme/billing/refunds/
└── SKILL.md
```

`SKILL.md` starts with YAML frontmatter holding `name` (the same as the skill's folder name) and `description`. Print the URIs the catalog publishes:

```ts source="../../examples/guides/servers/skills.examples.ts#entries"
console.log(skills.entries.map(skill => skill.uri).join('\n'));
```

It prints one `SKILL.md` URI per skill, sorted:

```text
skill://acme/billing/refunds/SKILL.md
skill://git-workflow/SKILL.md
```

## Serve one catalog from every server

Attach the same catalog to each server your handler creates.

```ts source="../../examples/guides/servers/skills.examples.ts#attach_perRequest"
import { createMcpHandler } from '@modelcontextprotocol/server';

const handler = createMcpHandler(() => {
const mcp = new McpServer({ name: 'team-skills', version: '1.0.0' });
skills.attach(mcp);
return mcp;
});
```

The catalog keeps metadata only (paths, digests, sizes, frontmatter); each read goes to the folder, and the SDK serves the bytes only if they still match the published digest. It sends a file as `text` whenever its bytes are valid UTF-8 (a BOM included), whatever its MIME type, and as a base64 `blob` otherwise.

## Pick up changes with `reload()`

Rescan the folder and swap the new catalog in at once.

```ts source="../../examples/guides/servers/skills.examples.ts#reload"
const changes = await skills.reload();
console.log(changes);
```

With nothing changed on disk, `reload()` reports no changes:

```text
{ added: [], removed: [], changed: [], listChanged: false }
```

Until you call `reload()`, the server answers a read of a file edited on disk with an error instead of unverified content. If the rescan fails, `reload()` rejects and the old catalog keeps serving.

`listChanged` is `true` when a skill appears, disappears, or changes its name or description. A server that stays connected (on stdio, or in a stateful 2025-11-25 HTTP session) then sends its clients `notifications/resources/list_changed` itself. A server that `createMcpHandler` builds lives for one request, so publish the change on the handler instead:

```ts source="../../examples/guides/servers/skills.examples.ts#reload_notify"
if (changes.listChanged) handler.notify.resourcesChanged();
```

Every open `subscriptions/listen` stream that opted in to resource list changes receives the notification.

## Serve skills defined in code

Pass the files to `memorySkillFiles` where there is no file system, such as Workers or tests.

```ts source="../../examples/guides/servers/skills.examples.ts#memorySkillFiles"
import { memorySkillFiles, SkillsExtension } from '@modelcontextprotocol/server/ext/skills';

const RELEASE_NOTES_MD = `---
name: release-notes
description: Write release notes from merged pull requests.
---
# Release notes
`;

const inline = await SkillsExtension.load(memorySkillFiles({ 'release-notes/SKILL.md': RELEASE_NOTES_MD }));
```

`memorySkillFiles` re-reads the object you pass whenever the catalog rescans (at load and on `reload()`), so `reload()` sees files you add or change later.

## Serve skills from your own storage

Implement `SkillFiles`, two functions over relative paths, to load from any store.

```ts source="../../examples/guides/servers/skills.examples.ts#skillFiles_custom"
import type { SkillFiles } from '@modelcontextprotocol/server/ext/skills';

// Two operations over '/'-separated paths, '' being the root: here, a key-value store of objects.
function bucketSkillFiles(bucket: ReadonlyMap<string, Uint8Array>): SkillFiles {
return {
async list(dir) {
const prefix = dir === '' ? '' : `${dir}/`;
const children = new Map<string, 'file' | 'directory'>();
for (const key of bucket.keys()) {
if (!key.startsWith(prefix)) continue;
const [name, ...rest] = key.slice(prefix.length).split('/');
if (name !== undefined) children.set(name, rest.length > 0 ? 'directory' : 'file');
}
return [...children].map(([name, kind]) => ({ name, kind }));
},
async read(key) {
const bytes = bucket.get(key);
if (bytes === undefined) throw new Error(`no object ${key}`);
return bytes;
}
};
}

const fromBucket = await SkillsExtension.load(
bucketSkillFiles(new Map([['release-notes/SKILL.md', new TextEncoder().encode(RELEASE_NOTES_MD)]]))
);
```

The catalog logic (discovery, digests, rule checks, paging, verified reads) is the same for every adapter. Report a symlink or other special file as `kind: 'unsupported'`; the catalog skips the skill that contains one.

## Generate skills with a `SkillSource`

Build entries with `skillEntry` and serve them from a `SkillSource` when the catalog depends on the request.

```ts source="../../examples/guides/servers/skills.examples.ts#skillSource"
import type { SkillSource } from '@modelcontextprotocol/server/ext/skills';
import { skillEntry } from '@modelcontextprotocol/server/ext/skills';

const ON_CALL_MD = `---
name: on-call
description: Handle a page while on call.
---
# On call
`;
const onCall = await skillEntry('on-call', { 'SKILL.md': ON_CALL_MD });

const source: SkillSource = {
list: async () => ({ skills: [onCall] }),
get: async uri => (uri === onCall.uri ? onCall : undefined),
read: async uri => (uri === onCall.uri ? { text: ON_CALL_MD, mimeType: 'text/markdown' } : undefined)
};
const generated = new SkillsExtension({ source });
```

Every source method receives the request context (`ctx`), so you can list different skills per caller. Add `readDirectory` to serve folder listings, and `listResources` to list skills in `resources/list`.

## Handle invalid skills

Route warnings where you want them; by default they go to stderr.

```ts source="../../examples/guides/servers/skills.examples.ts#onWarning"
const checked = await loadSkills('./skills', {
onWarning: ({ path, message }) => console.error(`skills: ${path}: ${message}`)
});
```

`loadSkills` skips, with a warning, any skill that breaks the rules, such as a `name` that differs from its folder or a symlinked file; the other skills still serve. Skills over 512 files or 16 MiB still publish, with a warning; `limits` changes those thresholds. The same options take `prefix` (`'acme'` serves every skill under `skill://acme/…`) and `pageSize` (entries per `skills/list` and directory page, default 100).

Set `strict: true` to make `loadSkills` throw a `SkillCatalogError` listing every problem instead. Under `strict: true`, a symlink or other special file anywhere in the folder fails the load, even one outside every skill; hidden entries (names starting with `.`) are the exception, since the scan skips them entirely.

::: warning
Do not register your own resources under `skill://`. A resource you register at a skill file's URI takes precedence over that file, and clients then fail to verify it.
:::

::: info
Skills work on both protocol versions; the `cacheHint` option applies on 2026-07-28 only — see [Protocol versions](../protocol-versions.md).
:::

## Recap

- `loadSkills('./skills')` builds a catalog once; `attach(server)` serves it on any number of servers.
- Each folder with a `SKILL.md` is a skill; other folders group skills in the URI.
- `reload()` swaps in a rescanned catalog all at once; behind `createMcpHandler`, call `handler.notify.resourcesChanged()` when it reports `listChanged`.
- `memorySkillFiles`, a `SkillFiles` adapter or a `SkillSource` serve skills without a folder.
- `loadSkills` skips invalid skills with a warning; `strict: true` makes it throw instead.
Loading
Loading