Repository navigation
Add Skills extension
#3485
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Add Skills extension
#3485
Changes from 10 commits
a4c6ae3
573fc06
0cc8dbc
0729d95
3a3725b
d31511c
c00a1e7
b4edd53
b845490
2f363d5
1adba1d
70493de
38706c1
f1d2920
63254f8
7e32fc9
473804e
a2e49d5
071ad0e
6972162
e90e294
cde5cff
1ddace7
488e706
7ca165a
662ce93
083a5dc
12957be
03e24f0
01e5234
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| !!! 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. | ||
| 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 | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| 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)) | ||
| 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) |
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Probably not your fault, but we are doing a lot of We should either leverage Pydantic a bit more (using This point is only actionable if you can find ways to avoid this many
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @Kludex But we have same pattern across the entire sdk, are we willing to deviate for this PR or pick refactoring as follow-up exercise.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done |
| 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"] | ||
|
vijaydeepsinha marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| 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() | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
|
||
| while True: | ||
| page = await session.send_request(ListSkillsRequest(params=ListSkillsParams(cursor=cursor)), ListSkillsResult) | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
|
||
| 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 | ||
Uh oh!
There was an error while loading. Please reload this page.