|
| 1 | +# Skills |
| 2 | + |
| 3 | +[SEP-2640](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640) defines a |
| 4 | +convention for serving [Agent Skills](https://agentskills.io/) over MCP. A skill is just a |
| 5 | +directory of files — at minimum a `SKILL.md` with YAML frontmatter — that you expose as ordinary |
| 6 | +MCP resources, conventionally under a `skill://` URI. |
| 7 | + |
| 8 | +A server enumerates its skills with `skills/list`, answers for any single one by URI with |
| 9 | +`skills/get`, and — optionally — lists a directory's direct children with |
| 10 | +`resources/directory/read`. |
| 11 | + |
| 12 | +The SDK ships this as the built-in `Skills` extension (`io.modelcontextprotocol/skills`). There's |
| 13 | +one on the server side and one on the client side. If [Extensions](extensions.md) are new to you, |
| 14 | +skim that page first. |
| 15 | + |
| 16 | +!!! info |
| 17 | + `Skills` gives you the **protocol** primitives: request/response handling, capability |
| 18 | + advertisement, and SEP-2640 conformance validation. |
| 19 | + |
| 20 | + It does **not** discover, read, or hash skills from a filesystem. You supply handlers that |
| 21 | + answer from wherever your catalog actually lives — a database, a generated index, an in-memory |
| 22 | + list, or a directory you walk yourself — and you serve each skill's files as ordinary resources |
| 23 | + through `MCPServer.add_resource` or an `@mcp.resource(...)` template handler. |
| 24 | + |
| 25 | +## Serving a skill |
| 26 | + |
| 27 | +Here's a server that serves one skill: |
| 28 | + |
| 29 | +```python title="server.py" hl_lines="31-41 44-51 55" |
| 30 | +--8<-- "docs_src/skills/tutorial001.py" |
| 31 | +``` |
| 32 | + |
| 33 | +There are three moves here: |
| 34 | + |
| 35 | +* `Skill(uri=..., frontmatter=..., resources=[...])` is one entry. It has the same shape whether it |
| 36 | + comes back from `skills/list` or `skills/get`. `resources` is the skill's complete file |
| 37 | + manifest — every file, `SKILL.md` included, each with a `sha256:...` digest and byte size — or |
| 38 | + the string `"dynamic"` for content generated on demand. |
| 39 | +* `list_skills` and `get_skill` are plain async callables, invoked once per request. `get_skill` |
| 40 | + **must** answer for a skill even if your `list_skills` left it out — SEP-2640 requires a server |
| 41 | + to answer by URI for every skill it serves, listed or not. |
| 42 | +* `mcp.add_resource(TextResource(uri=SKILL_URI, ...))` registers the skill's actual file content, |
| 43 | + served through the SDK's ordinary resource machinery. `Skills` never reads or writes resource |
| 44 | + content itself. |
| 45 | + |
| 46 | +And that's it. `Skills(list_skills=..., get_skill=...)` is all a server needs; |
| 47 | +`resources/directory/read` is optional (more on that below). |
| 48 | + |
| 49 | +## Inspecting a skill |
| 50 | + |
| 51 | +On the client side, `Skills` is a [`ClientExtension`](extensions.md). You register it the same way |
| 52 | +you register any other one — by passing it to `Client(extensions=[...])` — and then call `extension` to |
| 53 | +get the verbs tied to that connection: |
| 54 | + |
| 55 | +```python title="client.py" hl_lines="9-11" |
| 56 | +--8<-- "docs_src/skills/tutorial001_client.py" |
| 57 | +``` |
| 58 | + |
| 59 | +`client.extension(Skills)` hands you a `SkillsClient` with the SEP-2640 methods: |
| 60 | + |
| 61 | +* `list_skills` and `read_directory` follow `nextCursor` to completion, so a single call gives you |
| 62 | + every page's skills or resources. |
| 63 | +* `get_skill` costs exactly one request. |
| 64 | + |
| 65 | +These three validate the server's response against the SEP-2640 conformance rules before returning |
| 66 | +it. A name that doesn't match its URI, a digest in the wrong shape, or an incomplete manifest raises |
| 67 | +`ValueError` rather than reaching your code. |
| 68 | + |
| 69 | +Read a skill file with `Client.read_resource(uri)`, the same method you use for any MCP resource. |
| 70 | +It returns raw content without checking it against the skill's manifest. |
| 71 | + |
| 72 | +!!! warning "Verify files before loading" |
| 73 | + A host that loads a skill must hold its `Skill` entry. Before using a fetched file, check that |
| 74 | + its URI appears in that entry's `resources` and that its bytes match the advertised size and |
| 75 | + SHA-256 digest. A `"dynamic"` skill has no digest to check. Refreshing the entry to accept |
| 76 | + changed bytes also changes the content to which any prior approval applied. |
| 77 | + |
| 78 | + Skill content is untrusted model input, exactly like any other server-provided text. SEP-2640 |
| 79 | + requires a host to tag it with its originating server before it reaches the model, and to |
| 80 | + never grant the frontmatter's `allowed-tools` field (or any other permission-widening field) |
| 81 | + without explicit per-skill user approval. |
| 82 | + |
| 83 | + A matching digest confirms the *bytes*, not the *frontmatter*: it doesn't prove the |
| 84 | + `frontmatter` the server advertised in `skills/get` matches the frontmatter inside the fetched |
| 85 | + `SKILL.md`. If you act on `skill.frontmatter` — especially `allowed-tools` — parse the fetched |
| 86 | + file and compare its frontmatter yourself. |
| 87 | + |
| 88 | + These are host responsibilities the SDK cannot discharge for you — read the SEP's |
| 89 | + [Security Implications](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640) |
| 90 | + section before building a host on top of this extension. |
| 91 | + |
| 92 | +## Directory reads |
| 93 | + |
| 94 | +A skill's instructions often point at a directory rather than a file ("pick the matching |
| 95 | +template from `templates/`"). `resources/list` can't answer that — it enumerates a server's |
| 96 | +entire resource space, not one subtree — so SEP-2640 adds `resources/directory/read`, gated |
| 97 | +behind the `directoryRead` capability setting: |
| 98 | + |
| 99 | +```python |
| 100 | +mcp = MCPServer( |
| 101 | + "catalog", |
| 102 | + extensions=[ |
| 103 | + Skills( |
| 104 | + list_skills=list_skills, |
| 105 | + get_skill=get_skill, |
| 106 | + read_directory=read_directory, # lists uri's direct children |
| 107 | + ) |
| 108 | + ], |
| 109 | +) |
| 110 | +``` |
| 111 | + |
| 112 | +Supplying `read_directory` advertises `{"directoryRead": true}` under the extension's |
| 113 | +capabilities. Omitting it advertises neither the setting nor the method — a client calling |
| 114 | +`resources/directory/read` against such a server gets `METHOD_NOT_FOUND`. On the client side, |
| 115 | +`read_directory` raises before it sends anything if the connected server hasn't advertised the |
| 116 | +setting. |
| 117 | + |
| 118 | +## Protocol version and caching |
| 119 | + |
| 120 | +In protocol version `2026-07-28` and later, `skills/list` and `skills/get` results carry the base |
| 121 | +protocol's caching fields, [`ttlMs` and `cacheScope`](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549) — |
| 122 | +the same freshness hint `tools/list`, `resources/list`, and `resources/read` carry. `Skills` fills |
| 123 | +`cacheScope` with `"private"` when your handler leaves it unset, and omits both fields entirely on an |
| 124 | +older connection. Set `cache_scope="public"` only when the result is the same for every user. |
| 125 | +You don't have to branch on protocol version yourself. |
| 126 | + |
| 127 | +## What this SDK doesn't do |
| 128 | + |
| 129 | +`Skills` is a protocol adapter, not a skills provider. It has no opinion on where a skill's bytes |
| 130 | +live, how they're indexed, or when a catalog is refreshed — that's for a higher-level library, or |
| 131 | +your own handler, to decide. |
| 132 | + |
| 133 | +If you're looking for "scan this directory and serve whatever's in it," you're looking for a |
| 134 | +provider built on top of `Skills`, not `Skills` itself. |
0 commit comments