Skip to content

Commit 036abf7

Browse files
Document serving skills from a directory
1 parent baabbd1 commit 036abf7

5 files changed

Lines changed: 194 additions & 1 deletion

File tree

‎docs/advanced/skills.md‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ skim that page first.
2222
list, or a directory you walk yourself — and you serve each skill's files as ordinary resources
2323
through `MCPServer.add_resource` or an `@mcp.resource(...)` template handler.
2424

25+
`SkillCatalog` is a provider built on top of `Skills` that implements these handlers for a
26+
source of skill files, such as a local directory. See
27+
[Serving skills from a directory](#serving-skills-from-a-directory).
28+
2529
## Serving a skill
2630

2731
Here's a server that serves one skill:
@@ -116,6 +120,124 @@ capabilities. Omitting it advertises neither the setting nor the method — a cl
116120
`read_directory` raises before it sends anything if the connected server hasn't advertised the
117121
setting.
118122

123+
## Serving skills from a directory
124+
125+
`SkillCatalog` is a `Skills` extension that discovers skills in a `SkillProvider` and answers
126+
`skills/list`, `skills/get`, and optionally `resources/directory/read` from what it finds. It
127+
removes the need to write handlers or to register each file as a resource.
128+
129+
```python title="server.py" hl_lines="11-15"
130+
--8<-- "docs_src/skills/tutorial002.py"
131+
```
132+
133+
`SkillCatalog.load()` is a coroutine, so the server is constructed inside a running event loop.
134+
`FilesystemSkillProvider` reads skills from a local directory. `parse_frontmatter` supplies the
135+
YAML parser; the SDK does not depend on one.
136+
137+
### How skills are discovered
138+
139+
A skill is a directory that contains a `SKILL.md` file. The skill's files are all files beneath
140+
that directory, including the files of any skill nested inside it. A nested skill is also listed
141+
as a skill in its own right.
142+
143+
Each file is served as a `skill://` resource. Its URI is `skill://` followed by its path, with
144+
each path segment percent-encoded. A file has the same URI regardless of which skill lists it.
145+
146+
Files and directories whose names begin with `.` are excluded. They are never read, listed, or
147+
served. Files outside any skill directory are not served.
148+
149+
The catalog reads and hashes every file once, when `load()` runs, and serves that snapshot. The
150+
`resources` manifest of each skill therefore describes the files exactly as they were read.
151+
The catalog is fixed for the lifetime of the server; to publish changes, create a new catalog.
152+
153+
### Options
154+
155+
`SkillCatalog.load(provider, *, parse_frontmatter, ...)` accepts the following arguments.
156+
157+
| Argument | Default | Description |
158+
|---|---|---|
159+
| `provider` | required | The `SkillProvider` that lists and reads the files. |
160+
| `parse_frontmatter` | required | Receives the text between the `---` lines of a `SKILL.md` and returns a mapping with `str` keys, for example `yaml.safe_load`. |
161+
| `strict` | `False` | Controls the handling of invalid skills. See [Invalid skills](#invalid-skills). |
162+
| `directory_read` | `False` | Serves `resources/directory/read` and advertises the `directoryRead` setting. |
163+
| `ttl_ms` | `0` | The `ttlMs` value set on `skills/list`, `skills/get` and directory read results. |
164+
| `cache_scope` | `"private"` | The `cacheScope` value set on the same results. |
165+
| `page_size` | `None` | The maximum number of entries in one `skills/list` or directory read response. See [Pagination](#pagination). |
166+
167+
`ttl_ms` and `cache_scope` follow the rules described in
168+
[Protocol version and caching](#protocol-version-and-caching). A negative `ttl_ms` raises
169+
`ValueError`.
170+
171+
The frontmatter mapping is returned in `skills/list` and `skills/get` without modification,
172+
including keys the SDK does not interpret. Values are converted to JSON; for example, YAML dates
173+
are returned as ISO 8601 strings. A value with no JSON representation, such as NaN, makes the
174+
skill invalid.
175+
176+
With `directory_read=True`, every directory that contains a served file can be read, including
177+
directories above the skill root such as `skill://acme`. A directory lists its direct children;
178+
subdirectories are returned with the MIME type `inode/directory`. Empty directories are not served.
179+
180+
### Pagination
181+
182+
By default, `skills/list` and `resources/directory/read` return every entry in a single response.
183+
When `page_size` is set, each response contains at most that many entries, and a response that is
184+
followed by another page carries a `nextCursor`. The client passes it back as `cursor`, as for any
185+
other paginated list in the protocol. `SkillsClient.list_skills()` and `read_directory()` do this
186+
automatically.
187+
188+
A skill entry is never split across pages. The cursor is opaque to clients. A cursor that the
189+
catalog did not issue is rejected with Invalid params (`-32602`), and so is any cursor when
190+
`page_size` is not set. A `page_size` below 1 raises `ValueError`.
191+
192+
### Invalid skills
193+
194+
A skill is invalid when its `SKILL.md` has no frontmatter block, has an unterminated block, cannot
195+
be parsed, or fails the SEP-2640 requirements for `name` and `description`. A `SKILL.md` placed
196+
directly in the provider root is also invalid, because the skill has no directory name.
197+
198+
* With `strict=False`, an invalid skill is omitted and a warning is logged.
199+
* With `strict=True`, `load()` raises a single `ValueError` that lists every invalid skill.
200+
201+
A skill whose manifest exceeds the 512 files or 16 MiB recommended by SEP-2640 is still served,
202+
and a warning is logged.
203+
204+
### File content
205+
206+
`SKILL.md` and other Markdown files are served as `text/markdown`, and YAML files as
207+
`application/yaml`. The MIME type of any other file is determined with `mimetypes`, with
208+
`application/octet-stream` as the fallback.
209+
210+
File content is returned unchanged. A file whose bytes are valid UTF-8 is returned as text,
211+
including any byte order mark and line endings; any other file is returned as a base64 blob. In
212+
both cases the content matches the `digest` and `size` in the skill's manifest. Each read is
213+
verified against the values recorded at load time, and a file that has changed is reported as an
214+
internal error (`-32603`).
215+
216+
### Providers
217+
218+
A `SkillProvider` supplies the files to the catalog. It is a `typing.Protocol`, so an
219+
implementation does not need to subclass it.
220+
221+
```python
222+
class SkillProvider(Protocol):
223+
async def list_files(self) -> Sequence[str]: ...
224+
async def read_file(self, path: str) -> bytes: ...
225+
```
226+
227+
`list_files()` returns the path of every file, recursively and without directories. `read_file()`
228+
returns the raw bytes of one file. Paths use `/` as the separator and are relative to the root of
229+
the provider. A provider can therefore be backed by any storage, such as an archive, a database,
230+
or object storage.
231+
232+
`load()` raises `ValueError` if the provider lists a path that is empty, absolute, contains a `.`
233+
or `..` segment, is listed more than once, or lies beneath another listed file. This applies
234+
regardless of `strict`, because it indicates an error in the provider rather than in a skill.
235+
236+
`FilesystemSkillProvider(root)` is the provider for a local directory. It serves regular files
237+
only. Symbolic links and Windows junctions are never followed: they are omitted, with a warning,
238+
when listing, and refused when read. A skill directory therefore cannot expose files outside
239+
itself. Blocking file system calls run in a worker thread.
240+
119241
## Protocol version and caching
120242

121243
In protocol version `2026-07-28` and later, `skills/list`, `skills/get`, and
@@ -134,3 +256,12 @@ your own handler, to decide.
134256

135257
If you're looking for "scan this directory and serve whatever's in it," you're looking for a
136258
provider built on top of `Skills`, not `Skills` itself.
259+
260+
[`SkillCatalog`](#serving-skills-from-a-directory) is such a provider. It has the following
261+
limitations:
262+
263+
* It does not include a YAML parser. The `parse_frontmatter` argument supplies one.
264+
* The catalog is not reloaded. To publish changes, create a new catalog.
265+
* Empty directories are not served by directory reads.
266+
* Pagination is opt-in through `page_size`. Without it, `skills/list` and directory reads return
267+
all entries in a single response.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
name: git-workflow
3+
description: Follow this team's Git conventions for branching and commits
4+
---
5+
6+
Branch from `main` using `type/short-description`. Write commit subjects in the
7+
imperative mood, under 72 characters.
8+
9+
See [references/conventions.md](references/conventions.md) for the full list.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Conventions
2+
3+
- Branch names: `feat/`, `fix/` or `docs/` followed by a short description.
4+
- One logical change per commit.
5+
- Reference the issue number in the commit body, never in the subject.

‎docs_src/skills/tutorial002.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from pathlib import Path
2+
3+
import anyio
4+
import yaml
5+
6+
from mcp.server.mcpserver import MCPServer
7+
from mcp.server.skills import FilesystemSkillProvider, SkillCatalog
8+
9+
10+
async def build_server(root: Path) -> MCPServer:
11+
catalog = await SkillCatalog.load(
12+
FilesystemSkillProvider(root),
13+
parse_frontmatter=yaml.safe_load,
14+
directory_read=True,
15+
)
16+
return MCPServer("skills", extensions=[catalog])
17+
18+
19+
async def main() -> None:
20+
server = await build_server(Path(__file__).parent / "skills")
21+
await server.run_stdio_async()
22+
23+
24+
if __name__ == "__main__":
25+
anyio.run(main)

‎tests/docs_src/test_skills.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""`docs/advanced/skills.md`: every claim the page makes, proved against the real SDK."""
22

3+
from pathlib import Path
4+
35
import pytest
46
from mcp_types import INVALID_PARAMS, TextResourceContents
57

6-
from docs_src.skills import tutorial001
8+
from docs_src.skills import tutorial001, tutorial002
79
from mcp import Client
810
from mcp.client.extensions.skills import Skills
911
from mcp.shared.exceptions import MCPError
@@ -45,3 +47,24 @@ async def test_the_skill_file_is_served_as_an_ordinary_resource() -> None:
4547
assert isinstance(contents, TextResourceContents)
4648
assert contents.mime_type == "text/markdown"
4749
assert contents.text == tutorial001.SKILL_MD
50+
51+
52+
async def test_a_directory_of_skills_is_served_with_no_handlers() -> None:
53+
"""tutorial002: `SkillCatalog` over `FilesystemSkillProvider` lists the sample skill and its files."""
54+
server = await tutorial002.build_server(Path(tutorial002.__file__).parent / "skills")
55+
skills = Skills()
56+
async with Client(server, extensions=[skills]) as client:
57+
skill = (await client.extension(Skills).list_skills())[0]
58+
children = await client.extension(Skills).read_directory("skill://git-workflow/references")
59+
contents = (await client.read_resource("skill://git-workflow/references/conventions.md")).contents[0]
60+
61+
assert skill.uri == "skill://git-workflow/SKILL.md"
62+
assert skill.frontmatter["name"] == "git-workflow"
63+
assert isinstance(skill.resources, list)
64+
assert [r.uri for r in skill.resources] == [
65+
"skill://git-workflow/SKILL.md",
66+
"skill://git-workflow/references/conventions.md",
67+
]
68+
assert [r.uri for r in children] == ["skill://git-workflow/references/conventions.md"]
69+
assert isinstance(contents, TextResourceContents)
70+
assert contents.text.startswith("# Conventions")

0 commit comments

Comments
 (0)