Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
107 changes: 107 additions & 0 deletions docs/advanced/skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Skills

[SEP-2640](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640) defines a
convention for serving [Agent Skills](https://agentskills.io/) over MCP: a skill is a directory
of files — minimally a `SKILL.md` with YAML frontmatter — exposed as ordinary MCP resources,
conventionally under a `skill://` URI. A server enumerates its skills with `skills/list`,
answers for any one of them 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`). If
[Extensions](extensions.md) are new to you, skim that page first.

`Skills` provides 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 serve
each skill's files as ordinary resources through `MCPServer.add_resource` or an
`@mcp.resource(...)` template handler.

## Serving a skill

```python title="server.py" hl_lines="31-41 44-51 55"
--8<-- "docs_src/skills/tutorial001.py"
```

Three moves:

* `Skill(uri=..., frontmatter=..., resources=[...])`: one entry, identical in shape whether it
comes back from `skills/list` or `skills/get`. `resources` is the skill's complete file
manifest — every file, `SKILL.md` included, each with a `sha256:...` digest and byte size — or
the string `"dynamic"` for content generated on demand.
* `list_skills`/`get_skill`: plain async callables, invoked per request. `get_skill` **must**
answer for a skill even if a real `list_skills` implementation omitted it — SEP-2640 requires
a server to answer by URI for every skill it serves, listed or not.
* `mcp.add_resource(TextResource(uri=SKILL_URI, ...))`: the skill's actual file content, served
through the SDK's ordinary resource machinery. `Skills` never reads or writes resource content
itself.

`Skills(list_skills=..., get_skill=...)` is all a server needs; `resources/directory/read` is
optional (below).

## Fetching a skill

```python title="client.py" hl_lines="5"
--8<-- "docs_src/skills/tutorial001_client.py"
```

`list_skills` and `read_directory` follow `nextCursor` to completion, so you get every page's
skills or resources in one call; `get_skill` costs 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_skill_uri` is the exception: a thin, discoverable alias for
`resources/read` that returns bytes and validates nothing itself (see the next paragraph).

`verify_skill_resource(skill, uri, content)` checks a file's bytes — size, then SHA-256 digest —
against the entry you hold for it. Call it after `read_skill_uri` and before treating the content
as trustworthy: `resources/read` returns whatever bytes the server sends *right now*, verification
is what ties those bytes back to the manifest you already validated.

!!! warning
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. Both are host responsibilities the SDK cannot
discharge for you — see the SEP's [Security Implications](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640)
section before building a host on top of this extension.

## Directory reads

A skill's instructions often point at a directory rather than a file ("pick the matching
template from `templates/`"). `resources/list` cannot 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:

```python
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`.
`mcp.client.skills.read_directory` raises before sending if the connected server hasn't
advertised the setting.

## Protocol version and caching

In protocol version `2026-07-28` and later, `skills/list` results carry the base protocol's
list-caching fields, [`ttlMs` and `cacheScope`](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549) — the
same freshness hint `tools/list` and `resources/list` carry. `Skills` fills `cacheScope` with
`"public"` when your handler leaves it unset, and omits both fields entirely on an
older connection, so 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. 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.
Empty file added docs_src/skills/__init__.py
Empty file.
55 changes: 55 additions & 0 deletions docs_src/skills/tutorial001.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import hashlib
from typing import Any

from mcp_types import INVALID_PARAMS

from mcp.server.context import ServerRequestContext
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.resources import TextResource
from mcp.server.skills import Skills
from mcp.shared.exceptions import MCPError
from mcp.shared.skills import (
GetSkillParams,
GetSkillResult,
ListSkillsParams,
ListSkillsResult,
Skill,
SkillResource,
)

SKILL_URI = "skill://git-workflow/SKILL.md"
SKILL_MD = """\
---
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.
"""

GIT_WORKFLOW = Skill(
uri=SKILL_URI,
frontmatter={"name": "git-workflow", "description": "Follow this team's Git conventions for branching and commits"},
resources=[
SkillResource(
uri=SKILL_URI,
digest=f"sha256:{hashlib.sha256(SKILL_MD.encode()).hexdigest()}",
size=len(SKILL_MD.encode()),
)
],
)


async def list_skills(ctx: ServerRequestContext[Any, Any], params: ListSkillsParams) -> ListSkillsResult:
return ListSkillsResult(skills=[GIT_WORKFLOW])


async def get_skill(ctx: ServerRequestContext[Any, Any], params: GetSkillParams) -> GetSkillResult:
if params.uri != SKILL_URI:
raise MCPError(code=INVALID_PARAMS, message=f"unknown skill: {params.uri}")
return GetSkillResult(skill=GIT_WORKFLOW)


mcp = MCPServer("catalog", extensions=[Skills(list_skills=list_skills, get_skill=get_skill)])
mcp.add_resource(TextResource(uri=SKILL_URI, name="SKILL.md", mime_type="text/markdown", text=SKILL_MD))
22 changes: 22 additions & 0 deletions docs_src/skills/tutorial001_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import anyio
from mcp_types import TextResourceContents

from mcp import Client
from mcp.client.skills import get_skill, list_skills, read_skill_uri, verify_skill_resource


async def main() -> None:
async with Client("http://localhost:8000/mcp") as client:
for skill in await list_skills(client.session):
print(skill.uri, skill.frontmatter["description"])

skill = await get_skill(client.session, "skill://git-workflow/SKILL.md")
result = await read_skill_uri(client.session, skill.uri)
content = result.contents[0]
if isinstance(content, TextResourceContents):
verify_skill_resource(skill, skill.uri, content.text.encode())
print(content.text)


if __name__ == "__main__":
anyio.run(main)
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ nav:
- Middleware: advanced/middleware.md
- Extensions: advanced/extensions.md
- MCP Apps: advanced/apps.md
- Skills: advanced/skills.md
- Troubleshooting: troubleshooting.md
- Translations: translations.md
- Migration Guide: migration.md
Expand Down
126 changes: 126 additions & 0 deletions src/mcp/client/skills.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Client-side convenience wrappers for the Skills extension (SEP-2640).

SEP-2640 needs no client-side method registration: `skills/list`, `skills/get`,
and `resources/directory/read` are ordinary vendor requests sent through
`ClientSession.send_request`, exactly like [Extension verbs](../advanced/extensions.md#extension-verbs).
The functions below are the thin, named wrappers SEP-2640's "SDKs: Convenience
Wrappers" section recommends — each validates the server's advertised support
before sending, and `list_skills`/`read_directory` follow `nextCursor` to
completion so a caller sees one page's worth of ergonomics regardless of how
many requests it took.

async with Client("http://localhost:8000/mcp") as client:
for skill in await list_skills(client.session):
print(skill.uri, skill.frontmatter["description"])
"""

from __future__ import annotations

from mcp_types import ReadResourceResult, Resource

from mcp.client.session import ClientSession
from mcp.shared.skills import (
EXTENSION_ID,
GetSkillParams,
GetSkillRequest,
GetSkillResult,
ListSkillsParams,
ListSkillsRequest,
ListSkillsResult,
ReadDirectoryParams,
ReadDirectoryRequest,
ReadDirectoryResult,
Skill,
validate_directory_result,
validate_list_result,
validate_skill,
)
from mcp.shared.skills import verify_skill_resource as verify_skill_resource

__all__ = ["get_skill", "list_skills", "read_directory", "read_skill_uri", "verify_skill_resource"]


def _require_extension(session: ClientSession, *, directory_read: bool = False) -> None:
capabilities = session.server_capabilities
settings = (capabilities.extensions or {}).get(EXTENSION_ID) if capabilities else None
if settings is None:
raise ValueError(f"server does not advertise the {EXTENSION_ID!r} extension")
if directory_read and not settings.get("directoryRead"):
raise ValueError(f"server does not advertise {EXTENSION_ID!r}'s directoryRead setting")


async def list_skills(session: ClientSession, params: ListSkillsParams | None = None) -> list[Skill]:
"""Call `skills/list`, following `nextCursor` to completion, and validate the result.

Raises:
ValueError: If the server doesn't advertise the Skills extension, or
its response is not SEP-2640 conformant.
"""
_require_extension(session)
cursor = params.cursor if params is not None else None
skills: list[Skill] = []
seen_cursors: set[str] = set()
while True:
page = await session.send_request(ListSkillsRequest(params=ListSkillsParams(cursor=cursor)), ListSkillsResult)
validate_list_result(page)
skills.extend(page.skills)
if page.next_cursor is None:
return skills
if page.next_cursor in seen_cursors:
raise ValueError(f"server repeated skills/list pagination cursor {page.next_cursor!r}")
seen_cursors.add(page.next_cursor)
cursor = page.next_cursor


async def get_skill(session: ClientSession, uri: str) -> Skill:
"""Call `skills/get` for `uri` and validate the result.

Unlike `list_skills`, this succeeds for a skill absent from any listing —
per SEP-2640, a server MUST answer `skills/get` for every skill it serves.

Raises:
ValueError: If the server doesn't advertise the Skills extension, its
response names a different skill, or the skill is not conformant.
"""
_require_extension(session)
result = await session.send_request(GetSkillRequest(params=GetSkillParams(uri=uri)), GetSkillResult)
if result.skill.uri != uri:
raise ValueError(f"server returned skill {result.skill.uri!r} for requested {uri!r}")
validate_skill(result.skill)
return result.skill


async def read_skill_uri(session: ClientSession, uri: str) -> ReadResourceResult:
"""Read a skill file's content via `resources/read`.

A thin, discoverable alias: works for any `skill://` (or other-scheme)
file regardless of whether the skill was ever enumerated. Verify the
result against a held `Skill` entry with `verify_skill_resource` before
treating it as trusted content — this call does not verify anything itself.
"""
return await session.read_resource(uri)


async def read_directory(session: ClientSession, uri: str, params: ReadDirectoryParams | None = None) -> list[Resource]:
"""Call `resources/directory/read` for `uri`, following `nextCursor` to completion.

Raises:
ValueError: If the server doesn't advertise the `directoryRead`
setting, or its response is not a valid child listing of `uri`.
"""
_require_extension(session, directory_read=True)
cursor = params.cursor if params is not None else None
resources: list[Resource] = []
seen_cursors: set[str] = set()
while True:
page = await session.send_request(
ReadDirectoryRequest(params=ReadDirectoryParams(uri=uri, cursor=cursor)), ReadDirectoryResult
)
validate_directory_result(uri, page)
resources.extend(page.resources)
if page.next_cursor is None:
return resources
if page.next_cursor in seen_cursors:
raise ValueError(f"server repeated resources/directory/read pagination cursor {page.next_cursor!r}")
seen_cursors.add(page.next_cursor)
cursor = page.next_cursor
Loading