SEP-2640 defines a
convention for serving Agent Skills over MCP. A skill is just a
directory of files — at minimum a SKILL.md with YAML frontmatter — that you expose as ordinary
MCP resources, conventionally under a skill:// URI.
A server enumerates its skills with skills/list, answers for any single one by URI with
skills/get, and — optionally — lists a directory's direct children with
resources/directory/read.
The SDK ships this as the built-in Skills extension (io.modelcontextprotocol/skills). There's
one on the server side and one on the client side. If Extensions are new to you,
skim that page first.
!!! info
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.
Here's a server that serves one skill:
--8<-- "docs_src/skills/tutorial001.py"There are three moves here:
Skill(uri=..., frontmatter=..., resources=[...])is one entry. It has the same shape whether it comes back fromskills/listorskills/get.resourcesis the skill's complete file manifest — every file,SKILL.mdincluded, each with asha256:...digest and byte size — or the string"dynamic"for content generated on demand.list_skillsandget_skillare plain async callables, invoked once per request.get_skillmust answer for a skill even if yourlist_skillsleft it out — SEP-2640 requires a server to answer by URI for every skill it serves, listed or not.mcp.add_resource(TextResource(uri=SKILL_URI, ...))registers the skill's actual file content, served through the SDK's ordinary resource machinery.Skillsnever reads or writes resource content itself.
And that's it. Skills(list_skills=..., get_skill=...) is all a server needs;
resources/directory/read is optional (more on that below).
On the client side, Skills is a ClientExtension. You register it the same way
you register any other one — by passing it to Client(extensions=[...]) — and then call extension to
get the verbs tied to that connection:
--8<-- "docs_src/skills/tutorial001_client.py"client.extension(Skills) hands you a SkillsClient with the SEP-2640 methods:
list_skillsandread_directoryfollownextCursorto completion, so a single call gives you every page's skills or resources.get_skillcosts exactly one request.
These three validate the server's response against the SEP-2640 conformance rules before returning
it. A name that doesn't match its URI, a digest in the wrong shape, or an incomplete manifest raises
ValueError rather than reaching your code.
Read a skill file with Client.read_resource(uri), the same method you use for any MCP resource.
It returns raw content without checking it against the skill's manifest.
!!! warning "Verify files before loading"
A host that loads a skill must hold its Skill entry. Before using a fetched file, check that
its URI appears in that entry's resources and that its bytes match the advertised size and
SHA-256 digest. A "dynamic" skill has no digest to check. Refreshing the entry to accept
changed bytes also changes the content to which any prior approval applied.
Skill content is untrusted model input, exactly like any other server-provided text. SEP-2640
requires a host to tag it with its originating server before it reaches the model, and to
never grant the frontmatter's `allowed-tools` field (or any other permission-widening field)
without explicit per-skill user approval.
A matching digest confirms the *bytes*, not the *frontmatter*: it doesn't prove the
`frontmatter` the server advertised in `skills/get` matches the frontmatter inside the fetched
`SKILL.md`. If you act on `skill.frontmatter` — especially `allowed-tools` — parse the fetched
file and compare its frontmatter yourself.
These are host responsibilities the SDK cannot discharge for you — read the SEP's
[Security Implications](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640)
section before building a host on top of this extension.
A skill's instructions often point at a directory rather than a file ("pick the matching
template from templates/"). resources/list can't answer that — it enumerates a server's
entire resource space, not one subtree — so SEP-2640 adds resources/directory/read, gated
behind the directoryRead capability setting:
mcp = MCPServer(
"catalog",
extensions=[
Skills(
list_skills=list_skills,
get_skill=get_skill,
read_directory=read_directory, # lists uri's direct children
)
],
)Supplying read_directory advertises {"directoryRead": true} under the extension's
capabilities. Omitting it advertises neither the setting nor the method — a client calling
resources/directory/read against such a server gets METHOD_NOT_FOUND. On the client side,
read_directory raises before it sends anything if the connected server hasn't advertised the
setting.
In protocol version 2026-07-28 and later, skills/list and skills/get results carry the base
protocol's caching fields, ttlMs and cacheScope —
the same freshness hint tools/list, resources/list, and resources/read carry. Skills fills
cacheScope with "private" when your handler leaves it unset, and omits both fields entirely on an
older connection. Set cache_scope="public" only when the result is the same for every user.
You don't have to branch on protocol version yourself.
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.
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.