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.

ContentServed asHashed as
stringtextUTF-8 bytes
Uint8Arraybase64 blobThe original bytes
() => eitherAs aboveThe 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.1 is 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:

ProblemRequirement
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 segmentFollow the Agent Skills naming rules
Missing SKILL.mdInclude it at the skill root
Frontmatter name differs from the URI's last segmentUse the same name in both places
File path contains .., ., empty segments, ?, # or spacesUse a relative path that can be read back by URI
Duplicate URI, or a URI used for both a file and a directoryGive each file and directory a unique URI
More than 512 files, including nested skills' filesStay within the per-skill file limit
Dynamic SKILL.md loader without explicit frontmatterSupply frontmatter
Dynamic skill inside a static skillKeep dynamic skills outside static skills
Unsupported or invalid frontmatter YAMLUse 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

CaseCode
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; use resources/directory/read instead.
  • The optional io.modelcontextprotocol.skills/ _meta fields on resources. The skill entry already includes the full frontmatter.
  • Host-side approval and file verification. These are the client's responsibility.

h3-mcp  MCP servers, built on H3.