
# Skills

> Give agents instructions for using your server.

Skills over MCP (`io.modelcontextprotocol/skills`, [SEP-2640](https://github.com/modelcontextprotocol/ext-skills)) lets a server publish [Agent Skills](https://agentskills.io/). 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.

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

```json
// 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:

```json
// 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:

```json
"_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](/protocols/modern/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.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:

```yaml
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:

```ts
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:

```ts
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](/protocols/modern/caching), unless you override them with `mcpSkills({ cache })`.

For a static catalog that is the same for every client, you can use:

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

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