Skills
Give agents instructions for using your server.
Skills over MCP (io.modelcontextprotocol/skills, SEP-2640) lets a server publish Agent Skills. A skill is a folder containing a SKILL.md file and any supporting files, such as examples or templates. The host loads these files as instructions for the model.
mcpSkills() lists the skills and serves their files as ordinary MCP resources.
import { readFile } from "node:fs/promises";
import { defineMcpHandler } from "h3-mcp";
import { defineSkill, mcpSkills } from "h3-mcp/skills";
const file = (path: string) => () =>
readFile(new URL(`skills/refunds/${path}`, import.meta.url), "utf8");
const refunds = defineSkill({
uri: "skill://acme/billing/refunds", // root directory; the last segment is the skill name
files: {
"SKILL.md": file("SKILL.md"), // required; starts with YAML frontmatter
"examples/email.md": file("examples/email.md"),
"templates/credit-note.md": file("templates/credit-note.md"),
},
});
export default defineMcpHandler(
{ name: "billing", version: "1.0.0" },
{ extensionPlugins: [mcpSkills({ skills: [refunds], directoryRead: true })] },
);The plugin is optional. Add it to extensionPlugins to enable skills; there is no skills option on the handler itself. An empty skills list adds no resources and does not advertise the extension.
#Listing skills
skills/list returns all skills. skills/get returns one skill, using its SKILL.md URI.
Each entry includes the frontmatter from SKILL.md and a manifest: a list of files with their SHA-256 hashes and sizes in bytes.
// skills/list
{
"skills": [
{
"uri": "skill://acme/billing/refunds/SKILL.md",
"frontmatter": {
"name": "refunds",
"description": "Process customer refund requests per company policy",
"license": "Apache-2.0"
},
"resources": [
{ "uri": "skill://acme/billing/refunds/SKILL.md", "digest": "sha256:b2c3…", "size": 3871 },
{
"uri": "skill://acme/billing/refunds/examples/email.md",
"digest": "sha256:c3d4…",
"size": 962
},
{
"uri": "skill://acme/billing/refunds/templates/credit-note.md",
"digest": "sha256:e2f3…",
"size": 1104
}
]
}
],
"ttlMs": 0,
"cacheScope": "private"
}The host uses this manifest to ask for approval and check that the files it reads have not changed. The plugin loads each static file once, computes its hash, and serves those same bytes on later reads.
#Reading files
Every skill file appears in resources/list, after the server's own resources. The SKILL.md resource gets its name and description from its frontmatter. Static files also include their media type and size.
Clients read the files with resources/read. They do not need to support the skills extension to list or read these resources.
#Browsing directories
Set directoryRead: true to enable resources/directory/read. It lists the files and subdirectories directly inside a directory:
// resources/directory/read { "uri": "skill://acme/billing/refunds/templates" }
{
"resources": [
{
"name": "credit-note.md",
"uri": "skill://acme/billing/refunds/templates/credit-note.md",
"mimeType": "text/markdown",
"size": 1104
}
]
}Subdirectories have mimeType: "inode/directory". Call the same method with a subdirectory's URI to browse it.
The plugin also lists parent directories, such as skill://acme and skill://acme/billing, so clients can browse from the top.
Without directoryRead: true, the method returns -32601 and the server advertises the extension as {} instead of { "directoryRead": true }. Directories cannot be read with resources/read.
#Client support
The server advertises the extension through server/discover. To call skills/list, skills/get or resources/directory/read, a client must declare it in the request:
"_meta": {
"io.modelcontextprotocol/clientCapabilities": {
"extensions": { "io.modelcontextprotocol/skills": {} }
}
}Without this declaration, these methods return -32021 and name the missing extension. Ordinary resources/list and resources/read calls do not need the declaration.
See Extensions for more on per-request negotiation.
#Defining a skill
uri is the skill's root directory, without a trailing slash. The last segment must match the frontmatter's name. Names use lowercase letters, digits and single hyphens. Earlier segments can group skills however you like.
The usual scheme is skill://, but other schemes work if new URL() accepts the URI without changing it. The skill entry's URI is always <root>/SKILL.md.
files maps relative paths to content. Use / between path segments and include a SKILL.md at the root. Each value can be content directly, or { content, mimeType } to set the media type.
| Content | Served as | Hashed as |
|---|---|---|
string | text | UTF-8 bytes |
Uint8Array | base64 blob | The original bytes |
() => either | As above | The returned content, loaded once |
Loaders can also return a promise. Use readFile(path, "utf8") for text. Without the encoding, readFile(path) returns a Buffer, which is served as a blob.
The plugin infers the media type from the file extension, such as .md → text/markdown or .py → text/x-python. Unknown extensions use text/plain for strings and application/octet-stream for bytes.
Static files load on the first request that needs them and stay in memory for the handler's lifetime. Restart the server to update a static skill. This keeps the served files consistent with their hashes.
#Frontmatter
SKILL.md starts with YAML frontmatter. The plugin parses it and includes every field in the skill entry. The host compares these fields with the file, so they must match exactly.
For inline content, parsing happens during setup. For a loader, it happens on the first load.
The built-in parser supports common YAML forms:
- Block mappings and lists.
- Plain, single-quoted and double-quoted values, including multiline values.
|and>block strings.- Inline lists of simple values, such as
[Read, Write]. - YAML core types:
version: 2.1is a number;version: "2.1"is a string.
Anchors, tags and nested inline collections are not supported. Unsupported or invalid YAML throws a TypeError with a line number. Quote values that contain : , for example:
description: "Use this when: the user asks for a refund"Use the exported parseFrontmatter(text) helper to check a file yourself. If you need YAML the parser does not support, pass defineSkill({ frontmatter }) to skip parsing. You must keep that object identical to the file's frontmatter, or the host will reject the skill.
#Nested skills
Declare each nested skill separately, with its own files and a URI under the parent:
defineSkill({ uri: "skill://outer", files: { "SKILL.md": outerMd, "notes.md": notes } });
defineSkill({ uri: "skill://outer/tools/inner", files: { "SKILL.md": innerMd, "run.sh": run } });Both appear in skills/list. The parent manifest includes the nested skill's files; the nested manifest includes only its own files.
A dynamic skill cannot sit inside a static skill because it has no stable file hashes for the parent manifest.
#Dynamic skills
Use dynamic: true for content that changes between reads. The manifest then uses resources: "dynamic" instead of file hashes. File sizes are omitted, and loaders run on every read with the request event:
defineSkill({
uri: "skill://reports/daily",
dynamic: true,
frontmatter: { name: "daily", description: "Assemble today's operational report" },
files: { "SKILL.md": (event) => renderReport(event) },
});When a dynamic skill's SKILL.md is a loader, you must provide frontmatter explicitly. Hosts may refuse dynamic skills because they cannot verify stable file hashes.
#Caching
skills/list and skills/get always include ttlMs and cacheScope. They use the handler's cache defaults, unless you override them with mcpSkills({ cache }).
For a static catalog that is the same for every client, you can use:
mcpSkills({ skills, cache: { ttlMs: 300_000, cacheScope: "public" } });Use defineSkill({ cache }) to set caching for that skill's resources/read results. resources/directory/read has no caching hints.
If enabled is a function, the available skills can differ by request. The plugin then forces cacheScope: "private" on server/discover, resources/list, skills/list and skills/get, including responses where skills are disabled. This prevents shared caches from serving one client's catalog to another.
#Enabling skills per request
mcpSkills({ skills, enabled: (event) => isBeta(event) });enabled: false turns the extension off completely: no advertisement, methods or skill resources.
A function decides whether to enable it for each request. With static handler options, server/discover still advertises the extension even when the function rejects a request. Use a handler options function, defineMcpHandler((event) => options, ...), if the advertisement should follow the same check.
#Validation
mcpSkills() throws a TypeError during setup for problems it can check before loading files:
| Problem | Requirement |
|---|---|
Invalid URI, changed by URL parsing, or ending in / | Use a stable root URI without a trailing slash |
| Invalid name in the URI's last segment | Follow the Agent Skills naming rules |
Missing SKILL.md | Include it at the skill root |
| Frontmatter name differs from the URI's last segment | Use the same name in both places |
File path contains .., ., empty segments, ?, # or spaces | Use a relative path that can be read back by URI |
| Duplicate URI, or a URI used for both a file and a directory | Give each file and directory a unique URI |
| More than 512 files, including nested skills' files | Stay within the per-skill file limit |
Dynamic SKILL.md loader without explicit frontmatter | Supply frontmatter |
| Dynamic skill inside a static skill | Keep dynamic skills outside static skills |
| Unsupported or invalid frontmatter YAML | Use supported YAML or supply frontmatter explicitly |
Some checks must wait until files load: the 16 MiB total-size limit, and frontmatter parsing or name checks for a loaded SKILL.md.
If any skill fails to load or validate, the whole catalog fails. server/discover, resources/list and skills/* return -32603 rather than a partial list.
- Loader errors, such as a missing file, are retried on the next request.
- Validation errors, such as a size limit or name mismatch, are kept. Fix the skill and restart the server.
#Errors
| Case | Code |
|---|---|
skills/get with a URI that does not identify a skill's SKILL.md | -32602 |
resources/directory/read with a URI that is not a directory | -32602 |
Either method with a missing or invalid uri | -32602 |
| A skills extension method called without the client declaration | -32021 |
A skills extension method disabled by enabled | -32601 |
Error messages do not include client input. For lookup errors, the client's URI is returned in error.data.uri, with a length limit, just as with resources/read.
#Not implemented
- Reading directories through
resources/read; useresources/directory/readinstead. - The optional
io.modelcontextprotocol.skills/_metafields on resources. The skill entry already includes the full frontmatter. - Host-side approval and file verification. These are the client's responsibility.