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
Open
vijaydeepsinha
wants to merge
35
commits into
modelcontextprotocol:main
Choose a base branch
from
vijaydeepsinha:sep-2640-python-sdk-support
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Add Skills extension
#3485
Changes from 22 commits
Commits
Show all changes
35 commits
Select commit
Hold shift + click to select a range
a4c6ae3
Add SEP-2640 protocol types and validation
573fc06
Add SEP-2640 server support
0cc8dbc
Add SEP-2640 client support
0729d95
Document SEP-2640 Python SDK support
3a3725b
Strengthen SEP-2640 validation test coverage
d31511c
Rename skill resource-URI validator for clarity
c00a1e7
Extract parallel directory-URI validator in Skills handlers
b4edd53
Pin the skill-name grammar rejection cases
b845490
Fix documentation accuracy in the Skills guide
2f363d5
Add end-to-end dynamic-skill and cursor-resume client tests
1adba1d
Report Skills handler-output faults as INTERNAL_ERROR, not INVALID_PA…
70493de
Reject directory-shaped skill resource URIs and dot-segment directory…
38706c1
Seed the pagination cursor and preserve request _meta across skills p…
f1d2920
Correct Skills docs on read_skill_uri return type and verify on dynam…
63254f8
Construct ListSkillsParams _meta via model_validate in the skills cli…
7e32fc9
Test that request _meta is camelCase on the wire and snake_case to a …
473804e
Empty commit to re-trigger CI after a transient PyPI network flake
a2e49d5
empty coomit to re-trigger ci check
071ad0e
added changes for cache-attributes and its tests
6972162
Merge branch 'main' into sep-2640-python-sdk-support
vijaydeepsinha e90e294
Merge branch 'main' into sep-2640-python-sdk-support
vijaydeepsinha cde5cff
incorporated review comments and defined skills as extensions
1ddace7
removed restriction to allow only 512 files and 16 mb for each skill,…
488e706
removed separate skill validations as per review suggestion
7ca165a
re validate the outbound payload for mutation
662ce93
modified the docs and added check for de-dup across pages
083a5dc
relaxed the de-dup failure
12957be
removed extra logging and extra exports
03e24f0
Merge branch 'main' into sep-2640-python-sdk-support
vijaydeepsinha 01e5234
Merge branch 'main' into sep-2640-python-sdk-support
vijaydeepsinha 3f45540
Expose typed client extension APIs and tighten Skills validation
Kludex 18e0634
Link the Skills server setup from the extension example
Kludex aea9f96
Keep skill resource verification in the shared API
Kludex 5e4fb31
Verify skill resources through the client extension
Kludex 9e0da68
Default skill reads to the main resource
Kludex File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| # 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 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](extensions.md) 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. | ||
|
|
||
| ## Serving a skill | ||
|
|
||
| Here's a server that serves one skill: | ||
|
|
||
| ```python title="server.py" hl_lines="31-41 44-51 55" | ||
| --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 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` and `get_skill` are plain async callables, invoked once per request. `get_skill` | ||
| **must** answer for a skill even if your `list_skills` left 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. `Skills` never 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). | ||
|
|
||
| ## Fetching a skill | ||
|
|
||
| On the client side, `Skills` is a [`ClientExtension`](extensions.md). You register it the same way | ||
| you register any other one — by passing it to `Client(extensions=[...])` — and then call `bind` to | ||
| get the verbs tied to that connection: | ||
|
|
||
| ```python title="client.py" hl_lines="9-11" | ||
| --8<-- "docs_src/skills/tutorial001_client.py" | ||
| ``` | ||
|
|
||
| `skills.bind(client)` hands you a `BoundSkills`, and its methods are the SEP-2640 verbs: | ||
|
|
||
| * `list_skills` and `read_directory` follow `nextCursor` to completion, so a single call gives you | ||
| every page's skills or resources. | ||
| * `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. It's a thin, discoverable alias for `resources/read` that returns | ||
| a `ReadResourceResult` (text or blob contents) and validates nothing itself — that's the next step. | ||
|
|
||
| !!! tip | ||
| `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* you | ||
| treat 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. It applies to a static manifest only — | ||
| a `"dynamic"` skill carries no digests, so calling it on one raises `ValueError`. | ||
|
|
||
| !!! 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 — read 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` 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: | ||
|
|
||
| ```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`. On the client side, | ||
| `read_directory` raises before it sends anything if the connected server hasn't advertised the | ||
| setting. | ||
|
|
||
| ## Protocol version and caching | ||
|
|
||
| In protocol version `2026-07-28` and later, `skills/list` and `skills/get` results carry the base | ||
| protocol's caching fields, [`ttlMs` and `cacheScope`](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549) — | ||
| the same freshness hint `tools/list`, `resources/list`, and `resources/read` 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import hashlib | ||
| from typing import Any | ||
|
|
||
| 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, | ||
| ) | ||
| from mcp.types import INVALID_PARAMS | ||
|
|
||
| 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)) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import anyio | ||
|
|
||
| from mcp import Client | ||
| from mcp.client.skills import Skills, verify_skill_resource | ||
| from mcp.types import TextResourceContents | ||
|
|
||
|
|
||
| async def main() -> None: | ||
| skills = Skills() | ||
| async with Client("http://localhost:8000/mcp", extensions=[skills]) as client: | ||
| catalog = skills.bind(client) | ||
| for skill in await catalog.list_skills(): | ||
| print(skill.uri, skill.frontmatter["description"]) | ||
|
|
||
| skill = await catalog.get_skill("skill://git-workflow/SKILL.md") | ||
| result = await catalog.read_skill_uri(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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| """Client-side Skills extension (SEP-2640). | ||
|
|
||
| `Skills` is an opt-in [`ClientExtension`](../advanced/extensions.md) for talking | ||
| to a skills catalog server. Register it with `Client(extensions=[Skills()])`, | ||
| then call `bind(client)` for the SEP-2640 verbs — `list_skills`, `get_skill`, | ||
| `read_skill_uri`, and `read_directory` — tied to that connection: | ||
|
|
||
| async with Client("http://localhost:8000/mcp", extensions=[skills := Skills()]) as client: | ||
| for skill in await skills.bind(client).list_skills(): | ||
| print(skill.uri, skill.frontmatter["description"]) | ||
|
|
||
| `bind(client)` returns a `BoundSkills`. Each verb validates that the server | ||
| advertises the extension before sending; `list_skills` and `read_directory` | ||
| follow `nextCursor` to completion, so one call returns every page's results. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import TYPE_CHECKING | ||
|
|
||
| from mcp_types import ReadResourceResult, Resource | ||
|
|
||
| from mcp.client.extension import ClientExtension | ||
| 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 | ||
|
|
||
| if TYPE_CHECKING: | ||
| from mcp.client.client import Client | ||
|
|
||
| __all__ = ["BoundSkills", "Skills", "verify_skill_resource"] | ||
|
|
||
|
|
||
| class Skills(ClientExtension): | ||
| """The client-side Skills extension: register, then `bind` for typed verbs. | ||
|
|
||
| Pass an instance to `Client(extensions=[Skills()])` — this advertises | ||
| `io.modelcontextprotocol/skills` under the client's capabilities — and call | ||
| `bind(client)` once the client is connected for a `BoundSkills` handle. | ||
| """ | ||
|
|
||
| identifier = EXTENSION_ID | ||
|
|
||
| def bind(self, client: Client) -> BoundSkills: | ||
| """Return the SEP-2640 verbs bound to `client`'s connected session. | ||
|
|
||
| Raises: | ||
| RuntimeError: If `client` has not entered its `async with` block yet. | ||
| """ | ||
| return BoundSkills(client.session) | ||
|
|
||
|
|
||
| class BoundSkills: | ||
| """The SEP-2640 verbs bound to one connected session. | ||
|
|
||
| Obtain it from `Skills.bind(client)`. `list_skills` and `read_directory` | ||
| follow `nextCursor` to completion; every method validates the server's | ||
| response against the SEP-2640 conformance rules before returning it. | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
|
||
| """ | ||
|
|
||
| def __init__(self, session: ClientSession) -> None: | ||
| self._session = session | ||
|
|
||
| def _require_extension(self, *, directory_read: bool = False) -> None: | ||
| capabilities = self._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(self, 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. | ||
| """ | ||
| self._require_extension() | ||
| base = params if params is not None else ListSkillsParams() | ||
| cursor = base.cursor | ||
| skills: list[Skill] = [] | ||
| seen_cursors: set[str] = {cursor} if cursor is not None else set() | ||
| while True: | ||
| page = await self._session.send_request( | ||
| ListSkillsRequest(params=base.model_copy(update={"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(self, 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. | ||
| """ | ||
| self._require_extension() | ||
| result = await self._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(self, 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 self._session.read_resource(uri) | ||
|
|
||
| async def read_directory(self, 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`. | ||
| """ | ||
| self._require_extension(directory_read=True) | ||
| base = params if params is not None else ReadDirectoryParams(uri=uri) | ||
| cursor = base.cursor | ||
| resources: list[Resource] = [] | ||
| seen_cursors: set[str] = {cursor} if cursor is not None else set() | ||
| while True: | ||
| page = await self._session.send_request( | ||
| ReadDirectoryRequest(params=base.model_copy(update={"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 | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably not your fault, but we are doing a lot of
validate_*calls everywhere. That a smell.We should either leverage Pydantic a bit more (using
Annotated), or not have them.This point is only actionable if you can find ways to avoid this many
validate_... Which you probably can 👀There was a problem hiding this comment.
Choose a reason for hiding this comment
The 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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done