This is the documentation for v2 — looking for the v1 documentation?
Skip to content

Skills ​

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

ts
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
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
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
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
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
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
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
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
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.

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.