Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8b8049f
Add SkillCatalog and FilesystemSkillProvider for serving skills from …
vijaydeepsinha Oct 25, 2026
baabbd1
Add optional pagination and harden SkillCatalog and FilesystemSkillPr…
vijaydeepsinha Oct 25, 2026
036abf7
Document serving skills from a directory
vijaydeepsinha Oct 25, 2026
7cc4f99
updated the sample code
vijaydeepsinha Oct 31, 2026
b5d40d6
Merge branch 'main' into issue-3683-file-system-support
vijaydeepsinha Oct 31, 2026
3c63a53
made some cleanup, added uts and few checks
vijaydeepsinha Oct 31, 2026
18eece6
modified the tests
vijaydeepsinha Oct 31, 2026
c71635c
Made SkillCatalog's public surface minimal
vijaydeepsinha Oct 31, 2026
2fc1932
Rewrite the SkillCatalog docs tutorial-first
vijaydeepsinha Oct 31, 2026
4402d2a
modified the documentation to include skills folder structure
vijaydeepsinha Oct 31, 2026
be15d59
Reject Windows path syntax in FilesystemSkillProvider.read_file and t…
vijaydeepsinha Oct 31, 2026
10e13e2
Build skill manifests with a bisect range instead of scanning every path
vijaydeepsinha Oct 31, 2026
f986b9a
modified the description of the cursor and list_files contracts as im…
vijaydeepsinha Oct 31, 2026
9c75fd6
Merge branch 'main' into issue-3683-file-system-support
vijaydeepsinha Nov 3, 2026
da96a6f
Merge branch 'modelcontextprotocol:main' into issue-3683-file-system-…
vijaydeepsinha Nov 9, 2026
702da02
added support for periodic refresh at regular interval of skills
vijaydeepsinha Nov 11, 2026
b4f9be7
Pin non-blocking skill reads, drop SSE guidance, and tighten dynamic …
vijaydeepsinha Nov 11, 2026
af17ea2
added a new ut for stale uri check
vijaydeepsinha Nov 11, 2026
03b0bd8
Report a skill file deleted before reload as a failed read and align …
vijaydeepsinha Nov 11, 2026
16a5809
added a test for coverage
vijaydeepsinha Nov 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/advanced/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,32 @@ The client is its own program, and it carries both halves of the client story:
so the client declares them for itself. A published extension would ship them in
a package that both sides import.

### Dynamic resources and background work

Normally `resources()` is called once, when the server is built. If your resources change while
the server runs, set `resources_are_dynamic = True` and the server asks `resources()` again on
every listing, and on every lookup that a resource registered directly on the server or an
earlier dynamic extension hasn't already answered.

Build the new resources completely before you start returning them, so a request never sees a
half-updated set. Return them as a tuple, and the same tuple until they change, so the server can
look a resource up by URI instead of scanning every resource on each request. Don't change a
resource's URI inside a tuple you've returned; return a new tuple instead. If two resources share
a URI, the one registered directly on the server wins, then dynamic ones in extension order, then
templates.

To run background work, such as a task that keeps those resources up to date, override
`lifespan()` with an `@asynccontextmanager`. Code before its `yield` runs when the server starts,
and code after it runs when the server stops.

The server enters it after your application's own lifespan, in extension order, and leaves it in
the reverse order. The default does nothing, so you only pay for it if you override it.

!!! note
Some transports enter the lifespan once per connection, so the same extension can be inside
several at once. If a worker must run only once, coordinate that yourself.
[`SkillCatalog`](skills.md#reloading-on-a-timer) does this for its refresh timer.

### Intercepting `tools/call`

The one interceptive hook. Override `intercept_tool_call` to observe, short-circuit,
Expand Down
292 changes: 283 additions & 9 deletions docs/advanced/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ skim that page first.
`Skills` gives you the **protocol** primitives: request/response handling, capability
advertisement, and SEP-2640 conformance validation.

It does **not** discover, read, or hash skills from a filesystem. You supply handlers that
answer from wherever your catalog actually lives — a database, a generated index, an in-memory
list, or a directory you walk yourself — and you serve each skill's files as ordinary resources
through `MCPServer.add_resource` or an `@mcp.resource(...)` template handler.
On its own it doesn't discover, read, or hash anything. You supply handlers that answer from
wherever your catalog lives — a database, a generated index, an in-memory list — and you serve
each skill's files as ordinary resources through `MCPServer.add_resource` or an
`@mcp.resource(...)` template handler.

If your skills live in a directory, skip the handlers and use `SkillCatalog`. See
[Serving skills from a directory](#serving-skills-from-a-directory).

## Serving a skill

Expand Down Expand Up @@ -116,6 +119,272 @@ capabilities. Omitting it advertises neither the setting nor the method — a cl
`read_directory` raises before it sends anything if the connected server hasn't advertised the
setting.

## Serving skills from a directory

Most of the time your skills are just a folder on disk. For that, you don't need to write handlers
or register each file: `SkillCatalog` reads the folder and answers everything for you.

Say you have this layout:

```text
skills/ <-- the path you give FilesystemSkillProvider
└── git-workflow/ <-- one skill: a folder named after the skill
├── SKILL.md <-- required: YAML frontmatter, then instructions
└── references/
└── conventions.md <-- any other files the skill needs
```

The `SKILL.md` starts with the skill's `name` and `description`, and `name` must match the
folder name:

```markdown
---
name: git-workflow
description: Follow this team's Git conventions for branching and commits
---

Branch from `main` using `type/short-description`. ...
```

Load it and pass it to your server:

```python title="server.py" hl_lines="11-15"
--8<-- "docs_src/skills/tutorial002.py"
```

That's the whole server. `skills/list`, `skills/get`, and `resources/directory/read` are answered
from the folder, and every file in it is served as a `skill://` resource.

* `FilesystemSkillProvider(root)` tells the catalog where the files are.
* `parse_frontmatter` turns the YAML between the `---` lines of a `SKILL.md` into a dict. The SDK
doesn't ship a YAML parser, so bring your own. This example uses
[PyYAML](https://pypi.org/project/PyYAML/) (`uv add pyyaml`).
* `directory_read=True` also serves `resources/directory/read` (more on that
[below](#directory-reads-from-a-catalog)).

`SkillCatalog.load()` is a coroutine, so build the server inside a running event loop, as
`main()` does above.

### How skills are found

The path you give `FilesystemSkillProvider` is the **root**: a folder that holds one folder per
skill. A fuller example:

```text
skills/ <-- FilesystemSkillProvider(Path("skills"))
├── git-workflow/ <-- a skill, served as skill://git-workflow/SKILL.md
│ ├── SKILL.md
│ ├── references/
│ │ └── conventions.md
│ └── scripts/
│ └── check-branch.sh
├── pdf-processing/ <-- another skill
│ ├── SKILL.md
│ └── assets/
│ └── template.pdf
├── .git/ <-- ignored: names starting with `.` are skipped
├── README.md <-- not served: it is outside any skill folder
└── draft/ <-- not a skill: it has no SKILL.md
└── notes.txt
```

A skill is a directory that contains a `SKILL.md`. Its files are everything beneath that
directory, including the files of any skill nested inside it, which is also listed as a skill in
its own right.

Each file's URI is `skill://` followed by its path, with every segment percent-encoded. Files and
directories whose names start with `.` are ignored entirely, and files outside any skill directory
are not served.

!!! warning
Point the provider at the folder that **contains** your skill folders, not at a skill folder
itself. A skill's URI is built from its path under the root, and its last segment must match
the skill's `name`. With `FilesystemSkillProvider(Path("skills/git-workflow"))`, the
`SKILL.md` sits directly in the root, so it has no directory name and the skill is skipped
(an error with `strict=True`). Use `FilesystemSkillProvider(Path("skills"))` instead.

!!! note
The catalog reads and hashes every file when `load()` runs. By default, it reads files from
the provider again on each `resources/read` and checks their bytes against that fingerprint.

If a file changes or is deleted afterwards, reading it fails with an internal error (`-32603`)
until you [reload the catalog](#reloading-skills) or restart the server. And because `load()`
reads every file, a large asset directory makes startup slower.

### Reloading skills

While you're writing a skill you'll keep editing its files, adding skills and deleting others.
Call `reload()` to pick all of it up without restarting the server:

```python
async def refresh(skills_catalog: SkillCatalog) -> None:
changes = await skills_catalog.reload()
print(changes.added, changes.removed, changes.changed) # SKILL.md URIs
```

`reload()` scans the whole provider again, and only when every skill has been read and validated
does it replace everything the catalog serves, in one step. `skills/list`, `skills/get`, resource
and directory listings, and file reads all switch together, so no request sees a half-updated
catalog. Skills and files can be added, removed or edited. It returns a `SkillCatalogChanges`
with the `added`, `removed` and `changed` skills.

It is all or nothing. If the scan fails, `reload()` raises and the previous catalog stays installed:

* the provider fails (`OSError`), or lists a path that isn't valid;
* `strict=True` and any skill is invalid, as with `load()`;
* a skill the catalog is serving has become invalid. This holds even without `strict`, so a typo in
the frontmatter mid-edit doesn't take a skill down. A skill that was already skipped at `load()`
stays skipped and doesn't block reloads.

Calls to `reload()` run one after another.

Without `auto_refresh`, file reads still go to the provider: a file that has changed or disappeared
can fail to read even when the previous catalog stays installed. Enable `auto_refresh` to retain
the previous files' bytes as well.

#### Reloading on a timer

Calling `reload()` yourself gets old fast. To have the catalog do it, turn on `auto_refresh`:

```python title="server.py" hl_lines="14 15"
--8<-- "docs_src/skills/tutorial003.py"
```

Every 30 seconds here, the catalog calls `reload()`. Automatic refresh is **off by default**. With
`auto_refresh=True` alone, `refresh_interval` defaults to 60 seconds; it must be a finite number
greater than zero.

There's no task to start and nothing to stop: the timer runs with the server's lifespan. On
streamable HTTP that is the server's whole lifetime. A transport that enters the lifespan once per
connection runs the timer only while a client is connected.

* **A failed refresh changes nothing.** The error is logged and the previous skills keep serving.
The catalog tries again after the next interval.
* **Scans never overlap.** The next interval starts after the previous scan finishes.

!!! note
With `auto_refresh`, the catalog keeps the bytes of every served file **in memory**. That's
what lets the previous skills keep serving consistently, even if you edit or delete a file on
disk between refreshes. Without it, a file edited or deleted on disk reads as an internal
error until you call `reload()`.

!!! warning
Clients don't hear about a reload. They may keep a `skills/list` result for `ttl_ms`, and a
manifest fetched before a reload can describe different bytes than a file fetched after it.
Re-fetch the manifest and verify the digest before loading a changed skill.

Pagination cursors are offsets, not snapshots, so restart a paged listing if the catalog
changed between pages.

### Directory reads from a catalog

Pass `directory_read=True` and every directory that contains a served file can be read, including
directories above a skill such as `skill://acme`. A directory lists its direct children;
subdirectories come back with the MIME type `inode/directory`. Empty directories are not served.

### Caching hints

`ttl_ms` and `cache_scope` set the `ttlMs` and `cacheScope` values on every result:

```python
async def build_catalog(root: Path) -> SkillCatalog:
return await SkillCatalog.load(
FilesystemSkillProvider(root),
parse_frontmatter=yaml.safe_load,
ttl_ms=60_000,
cache_scope="public",
)
```

They follow the rules in [Protocol version and caching](#protocol-version-and-caching). A negative
`ttl_ms` raises `ValueError`.

### Pagination

By default every entry comes back in one response. If your catalog is big, set `page_size`:

```python
async def build_catalog(root: Path) -> SkillCatalog:
return await SkillCatalog.load(
FilesystemSkillProvider(root),
parse_frontmatter=yaml.safe_load,
directory_read=True,
page_size=50,
)
```

Each response then holds at most 50 entries and carries a `nextCursor` while more remain.
`SkillsClient.list_skills()` and `read_directory()` follow it for you. A skill entry is never split
across pages.

The cursor is opaque, and the catalog keeps no record of the cursors it has handed out. It accepts
a cursor that marks a page boundary of its listing. A cursor that is malformed, out of range, or
not on a page boundary is rejected with Invalid params (`-32602`), and so is any cursor when
`page_size` isn't set. A `page_size` below 1 raises `ValueError`.

### Invalid skills

A skill is invalid when its `SKILL.md` has no frontmatter block, has an unterminated block, can't
be parsed, or fails the SEP-2640 requirements for `name` and `description`. A `SKILL.md` placed
directly in the root is also invalid, because the skill has no directory name.

By default (`strict=False`) an invalid skill is left out and a warning is logged, so one bad skill
doesn't take the server down. Pass `strict=True` to make `load()` raise a single `ValueError` that
lists every invalid skill, which is handy in CI.

The frontmatter is returned in `skills/list` and `skills/get` as written, including keys the SDK
doesn't interpret. Values are converted to JSON, so YAML dates become ISO 8601 strings. A value with
no JSON form, such as NaN, makes the skill invalid.

A skill whose manifest exceeds the 512 files or 16 MiB recommended by SEP-2640 is still served,
and a warning is logged.

### File content

`SKILL.md` and other Markdown files are served as `text/markdown`, and YAML files as
`application/yaml`. The MIME type of any other file is determined with `mimetypes`, with
`application/octet-stream` as the fallback.

File content is returned unchanged. A file whose bytes are valid UTF-8 is returned as text,
including any byte order mark and line endings; any other file is returned as a base64 blob. In
both cases the content matches the `digest` and `size` in the skill's manifest. Each read is
verified against the values recorded at load time, and a file that has changed is reported as an
internal error (`-32603`) until you [reload the catalog](#reloading-skills).
With [`auto_refresh`](#reloading-on-a-timer), reads instead return the retained snapshot's
bytes until the next successful reload.

`load()` reads and hashes every file of every valid skill once, so a large asset directory makes
startup slower and temporarily uses memory for the largest file.

### Providers

A `SkillProvider` supplies the files to the catalog. It is a `typing.Protocol`, so an
implementation does not need to subclass it.

```python
class SkillProvider(Protocol):
async def list_files(self) -> Sequence[str]: ...
async def read_file(self, path: str) -> bytes: ...
```

`list_files()` returns the path of every file it serves, recursively and without directories. It
may leave files out; `FilesystemSkillProvider` omits any name that starts with `.`. `read_file()`
returns the raw bytes of one file. Paths use `/` as the separator and are relative to the root of
the provider. A provider can therefore be backed by any storage, such as an archive, a database,
or object storage.

`load()` raises `ValueError` if the provider lists a path that is empty, absolute, contains a `.`
or `..` segment, is listed more than once, or lies beneath another listed file. This applies
regardless of `strict`, because it indicates an error in the provider rather than in a skill.

`FilesystemSkillProvider(root)` is the provider for a local directory. It serves regular files
only. Symbolic links and Windows junctions found while listing are omitted, with a warning, and
ones found while reading are refused, so a skill directory doesn't expose files outside itself.

The root is trusted. This is a best-effort check, not a defence against someone who can modify the
directory while the server runs. As a backstop, the catalog refuses to serve any file whose bytes
no longer match the digest taken at `load()` or the last `reload()`. Blocking file system calls run in a worker thread.

## Protocol version and caching

In protocol version `2026-07-28` and later, `skills/list`, `skills/get`, and
Expand All @@ -128,9 +397,14 @@ You don't have to branch on protocol version yourself.

## What this SDK doesn't do

`Skills` is a protocol adapter, not a skills provider. It has no opinion on where a skill's bytes
live, how they're indexed, or when a catalog is refreshed — that's for a higher-level library, or
your own handler, to decide.
`Skills` is a protocol adapter, not a skills store. It has no opinion on where a skill's bytes
live. `SkillCatalog` supplies `reload()` and an optional refresh timer.

`SkillCatalog` covers the common case of a directory (or any other file source behind a
`SkillProvider`), with a few limits:

* It doesn't include a YAML parser. `parse_frontmatter` supplies one.
* It doesn't watch the file system or tell clients about a reload; call `reload()` or set `auto_refresh`.
* Empty directories aren't served by directory reads.

If you're looking for "scan this directory and serve whatever's in it," you're looking for a
provider built on top of `Skills`, not `Skills` itself.
For anything else, write your own handlers for `Skills`.
9 changes: 9 additions & 0 deletions docs_src/skills/skills/git-workflow/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
name: git-workflow
description: Follow this team's Git conventions for branching and commits
---

Branch from `main` using `type/short-description`. Write commit subjects in the
imperative mood, under 72 characters.

See [references/conventions.md](references/conventions.md) for the full list.
5 changes: 5 additions & 0 deletions docs_src/skills/skills/git-workflow/references/conventions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Conventions

- Branch names: `feat/`, `fix/` or `docs/` followed by a short description.
- One logical change per commit.
- Reference the issue number in the commit body, never in the subject.
25 changes: 25 additions & 0 deletions docs_src/skills/tutorial002.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from pathlib import Path

import anyio
import yaml

from mcp.server.mcpserver import MCPServer
from mcp.server.skills import FilesystemSkillProvider, SkillCatalog


async def build_server(root: Path) -> MCPServer:
skills_catalog = await SkillCatalog.load(
FilesystemSkillProvider(root),
parse_frontmatter=yaml.safe_load,
directory_read=True,
)
return MCPServer("MCP-over-skills", extensions=[skills_catalog])


async def main() -> None:
server = await build_server(Path(__file__).parent / "skills")
await server.run_stdio_async()


if __name__ == "__main__":
anyio.run(main)
Loading
Loading