Skip to content

Commit 9a754b0

Browse files
vijaydeepsinhavijayKludex
authored
Add Skills extension (#3485)
Co-authored-by: vijay <vijasinh@adobe.com> Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
1 parent 91941ed commit 9a754b0

17 files changed

Lines changed: 2047 additions & 2 deletions

File tree

‎docs/advanced/extensions.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,31 @@ that did not declare it (error -32021), and a claimed shape from a server that
190190
skips the gate fails validation, exactly as the spec requires for an
191191
unrecognized `resultType`. Off by default, on both ends of the wire.
192192

193+
An extension can also expose methods of its own. Pass its type to `client.extension()`
194+
after connecting to get the API bound to that connection. Run the
195+
[Skills server](skills.md#serving-a-skill) at `http://localhost:8000/mcp` for this example:
196+
197+
```python
198+
import anyio
199+
200+
from mcp import Client
201+
from mcp.client.extensions.skills import Skills
202+
203+
204+
async def main() -> None:
205+
async with Client("http://localhost:8000/mcp", extensions=[Skills()]) as client:
206+
for skill in await client.extension(Skills).list_skills():
207+
print(skill.uri)
208+
209+
210+
if __name__ == "__main__":
211+
anyio.run(main)
212+
```
213+
214+
`client.extension(Skills)` returns the type produced by `Skills.bind(session)`. Your own
215+
extension can define the same hook. The client raises `ValueError` when the type was not
216+
registered and `RuntimeError` when it is not connected.
217+
193218
To advertise an identifier with **no** client-side behaviour (the server gates on
194219
the capability, the client does nothing, as in the search client above), use
195220
`advertise()`:

‎docs/advanced/skills.md‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# Skills
2+
3+
[SEP-2640](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640) defines a
4+
convention for serving [Agent Skills](https://agentskills.io/) over MCP. A skill is just a
5+
directory of files — at minimum a `SKILL.md` with YAML frontmatter — that you expose as ordinary
6+
MCP resources, conventionally under a `skill://` URI.
7+
8+
A server enumerates its skills with `skills/list`, answers for any single one by URI with
9+
`skills/get`, and — optionally — lists a directory's direct children with
10+
`resources/directory/read`.
11+
12+
The SDK ships this as the built-in `Skills` extension (`io.modelcontextprotocol/skills`). There's
13+
one on the server side and one on the client side. If [Extensions](extensions.md) are new to you,
14+
skim that page first.
15+
16+
!!! info
17+
`Skills` gives you the **protocol** primitives: request/response handling, capability
18+
advertisement, and SEP-2640 conformance validation.
19+
20+
It does **not** discover, read, or hash skills from a filesystem. You supply handlers that
21+
answer from wherever your catalog actually lives — a database, a generated index, an in-memory
22+
list, or a directory you walk yourself — and you serve each skill's files as ordinary resources
23+
through `MCPServer.add_resource` or an `@mcp.resource(...)` template handler.
24+
25+
## Serving a skill
26+
27+
Here's a server that serves one skill:
28+
29+
```python title="server.py" hl_lines="31-41 44-51 55"
30+
--8<-- "docs_src/skills/tutorial001.py"
31+
```
32+
33+
There are three moves here:
34+
35+
* `Skill(uri=..., frontmatter=..., resources=[...])` is one entry. It has the same shape whether it
36+
comes back from `skills/list` or `skills/get`. `resources` is the skill's complete file
37+
manifest — every file, `SKILL.md` included, each with a `sha256:...` digest and byte size — or
38+
the string `"dynamic"` for content generated on demand.
39+
* `list_skills` and `get_skill` are plain async callables, invoked once per request. `get_skill`
40+
**must** answer for a skill even if your `list_skills` left it out — SEP-2640 requires a server
41+
to answer by URI for every skill it serves, listed or not.
42+
* `mcp.add_resource(TextResource(uri=SKILL_URI, ...))` registers the skill's actual file content,
43+
served through the SDK's ordinary resource machinery. `Skills` never reads or writes resource
44+
content itself.
45+
46+
And that's it. `Skills(list_skills=..., get_skill=...)` is all a server needs;
47+
`resources/directory/read` is optional (more on that below).
48+
49+
## Inspecting a skill
50+
51+
On the client side, `Skills` is a [`ClientExtension`](extensions.md). You register it the same way
52+
you register any other one — by passing it to `Client(extensions=[...])` — and then call `extension` to
53+
get the verbs tied to that connection:
54+
55+
```python title="client.py" hl_lines="9-11"
56+
--8<-- "docs_src/skills/tutorial001_client.py"
57+
```
58+
59+
`client.extension(Skills)` hands you a `SkillsClient` with the SEP-2640 methods:
60+
61+
* `list_skills` and `read_directory` follow `nextCursor` to completion, so a single call gives you
62+
every page's skills or resources.
63+
* `get_skill` costs exactly one request.
64+
65+
These three validate the server's response against the SEP-2640 conformance rules before returning
66+
it. A name that doesn't match its URI, a digest in the wrong shape, or an incomplete manifest raises
67+
`ValueError` rather than reaching your code.
68+
69+
Read a skill file with `Client.read_resource(uri)`, the same method you use for any MCP resource.
70+
It returns raw content without checking it against the skill's manifest.
71+
72+
!!! warning "Verify files before loading"
73+
A host that loads a skill must hold its `Skill` entry. Before using a fetched file, check that
74+
its URI appears in that entry's `resources` and that its bytes match the advertised size and
75+
SHA-256 digest. A `"dynamic"` skill has no digest to check. Refreshing the entry to accept
76+
changed bytes also changes the content to which any prior approval applied.
77+
78+
Skill content is untrusted model input, exactly like any other server-provided text. SEP-2640
79+
requires a host to tag it with its originating server before it reaches the model, and to
80+
never grant the frontmatter's `allowed-tools` field (or any other permission-widening field)
81+
without explicit per-skill user approval.
82+
83+
A matching digest confirms the *bytes*, not the *frontmatter*: it doesn't prove the
84+
`frontmatter` the server advertised in `skills/get` matches the frontmatter inside the fetched
85+
`SKILL.md`. If you act on `skill.frontmatter` — especially `allowed-tools` — parse the fetched
86+
file and compare its frontmatter yourself.
87+
88+
These are host responsibilities the SDK cannot discharge for you — read the SEP's
89+
[Security Implications](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640)
90+
section before building a host on top of this extension.
91+
92+
## Directory reads
93+
94+
A skill's instructions often point at a directory rather than a file ("pick the matching
95+
template from `templates/`"). `resources/list` can't answer that — it enumerates a server's
96+
entire resource space, not one subtree — so SEP-2640 adds `resources/directory/read`, gated
97+
behind the `directoryRead` capability setting:
98+
99+
```python
100+
mcp = MCPServer(
101+
"catalog",
102+
extensions=[
103+
Skills(
104+
list_skills=list_skills,
105+
get_skill=get_skill,
106+
read_directory=read_directory, # lists uri's direct children
107+
)
108+
],
109+
)
110+
```
111+
112+
Supplying `read_directory` advertises `{"directoryRead": true}` under the extension's
113+
capabilities. Omitting it advertises neither the setting nor the method — a client calling
114+
`resources/directory/read` against such a server gets `METHOD_NOT_FOUND`. On the client side,
115+
`read_directory` raises before it sends anything if the connected server hasn't advertised the
116+
setting.
117+
118+
## Protocol version and caching
119+
120+
In protocol version `2026-07-28` and later, `skills/list` and `skills/get` results carry the base
121+
protocol's caching fields, [`ttlMs` and `cacheScope`](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549) —
122+
the same freshness hint `tools/list`, `resources/list`, and `resources/read` carry. `Skills` fills
123+
`cacheScope` with `"private"` when your handler leaves it unset, and omits both fields entirely on an
124+
older connection. Set `cache_scope="public"` only when the result is the same for every user.
125+
You don't have to branch on protocol version yourself.
126+
127+
## What this SDK doesn't do
128+
129+
`Skills` is a protocol adapter, not a skills provider. It has no opinion on where a skill's bytes
130+
live, how they're indexed, or when a catalog is refreshed — that's for a higher-level library, or
131+
your own handler, to decide.
132+
133+
If you're looking for "scan this directory and serve whatever's in it," you're looking for a
134+
provider built on top of `Skills`, not `Skills` itself.

‎docs_src/skills/__init__.py‎

Whitespace-only changes.

‎docs_src/skills/tutorial001.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import hashlib
2+
from typing import Any
3+
4+
from mcp.server.context import ServerRequestContext
5+
from mcp.server.mcpserver import MCPServer
6+
from mcp.server.mcpserver.resources import TextResource
7+
from mcp.server.skills import Skills
8+
from mcp.shared.exceptions import MCPError
9+
from mcp.shared.skills import (
10+
GetSkillParams,
11+
GetSkillResult,
12+
ListSkillsParams,
13+
ListSkillsResult,
14+
Skill,
15+
SkillResource,
16+
)
17+
from mcp.types import INVALID_PARAMS
18+
19+
SKILL_URI = "skill://git-workflow/SKILL.md"
20+
SKILL_MD = """\
21+
---
22+
name: git-workflow
23+
description: Follow this team's Git conventions for branching and commits
24+
---
25+
26+
Branch from `main` using `type/short-description`. Write commit subjects in the
27+
imperative mood, under 72 characters.
28+
"""
29+
30+
GIT_WORKFLOW = Skill(
31+
uri=SKILL_URI,
32+
frontmatter={"name": "git-workflow", "description": "Follow this team's Git conventions for branching and commits"},
33+
resources=[
34+
SkillResource(
35+
uri=SKILL_URI,
36+
digest=f"sha256:{hashlib.sha256(SKILL_MD.encode()).hexdigest()}",
37+
size=len(SKILL_MD.encode()),
38+
)
39+
],
40+
)
41+
42+
43+
async def list_skills(ctx: ServerRequestContext[Any, Any], params: ListSkillsParams) -> ListSkillsResult:
44+
return ListSkillsResult(skills=[GIT_WORKFLOW])
45+
46+
47+
async def get_skill(ctx: ServerRequestContext[Any, Any], params: GetSkillParams) -> GetSkillResult:
48+
if params.uri != SKILL_URI:
49+
raise MCPError(code=INVALID_PARAMS, message=f"unknown skill: {params.uri}")
50+
return GetSkillResult(skill=GIT_WORKFLOW)
51+
52+
53+
mcp = MCPServer("catalog", extensions=[Skills(list_skills=list_skills, get_skill=get_skill)])
54+
mcp.add_resource(TextResource(uri=SKILL_URI, name="SKILL.md", mime_type="text/markdown", text=SKILL_MD))
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import anyio
2+
3+
from mcp import Client
4+
from mcp.client.extensions.skills import Skills
5+
6+
7+
async def main() -> None:
8+
async with Client("http://localhost:8000/mcp", extensions=[Skills()]) as client:
9+
catalog = client.extension(Skills)
10+
for skill in await catalog.list_skills():
11+
print(skill.uri, skill.frontmatter["description"])
12+
13+
skill = await catalog.get_skill("skill://git-workflow/SKILL.md")
14+
print(skill.resources)
15+
16+
17+
if __name__ == "__main__":
18+
anyio.run(main)

‎mkdocs.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ nav:
6969
- Header parameters: advanced/header-parameters.md
7070
- Extensions: advanced/extensions.md
7171
- MCP Apps: advanced/apps.md
72+
- Skills: advanced/skills.md
7273
- Troubleshooting: troubleshooting.md
7374
- Translations: translations.md
7475
- Migration Guide: migration.md

‎src/mcp/client/client.py‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
from mcp.client._probe import negotiate_auto
5050
from mcp.client._transport import Transport
5151
from mcp.client.caching import CacheConfig, CacheMode, ClientResponseCache, InMemoryResponseCacheStore
52-
from mcp.client.extension import ClaimContext, ClientExtension, NotificationBinding, ResultClaim
52+
from mcp.client.extension import ClaimContext, ClientExtension, ClientExtensionBinding, NotificationBinding, ResultClaim
5353
from mcp.client.session import (
5454
ClientRequestContext,
5555
ClientSession,
@@ -87,6 +87,7 @@
8787
_T = TypeVar("_T")
8888
_ResultT = TypeVar("_ResultT")
8989
_CacheableT = TypeVar("_CacheableT", bound=CacheableResult)
90+
_BoundT = TypeVar("_BoundT")
9091

9192
_Connector = Callable[[AsyncExitStack, ConnectMode, bool], Awaitable["Dispatcher[Any]"]]
9293
"""Resolved at ``__post_init__`` from the shape of ``server`` alone: enter whatever resources
@@ -376,6 +377,7 @@ async def main():
376377
_connect: _Connector = field(init=False, repr=False, compare=False)
377378
_response_cache: ClientResponseCache | None = field(init=False, default=None, repr=False, compare=False)
378379
_folded_extensions: _FoldedExtensions = field(init=False, repr=False, compare=False)
380+
_registered_extensions: tuple[ClientExtension, ...] = field(init=False, repr=False, compare=False)
379381

380382
def __post_init__(self) -> None:
381383
if self.mode not in ("legacy", "auto") and self.mode not in MODERN_PROTOCOL_VERSIONS:
@@ -389,6 +391,7 @@ def __post_init__(self) -> None:
389391
)
390392

391393
self._folded_extensions = _fold_extensions(self.extensions)
394+
self._registered_extensions = tuple(self.extensions or ())
392395

393396
srv = self.server
394397
if isinstance(srv, MCPServer):
@@ -493,6 +496,19 @@ def session(self) -> ClientSession:
493496
raise RuntimeError("Client must be used within an async context manager")
494497
return self._session
495498

499+
def extension(self, extension_type: type[ClientExtensionBinding[_BoundT]]) -> _BoundT:
500+
"""Return the typed API of a registered extension for this connection.
501+
502+
Raises:
503+
RuntimeError: If the client is not connected.
504+
ValueError: If no extension of this type was registered.
505+
"""
506+
session = self.session
507+
for extension in self._registered_extensions:
508+
if isinstance(extension, extension_type):
509+
return cast(ClientExtensionBinding[_BoundT], extension).bind(session)
510+
raise ValueError(f"client extension {extension_type.__name__!r} is not registered")
511+
496512
# TODO(maxisbey): the by-construction shape is for __aenter__ to return a connected-view
497513
# type whose protocol_version/server_capabilities are non-Optional fields,
498514
# eliminating these guards (and the one in .session). Same family as resolving the

‎src/mcp/client/extension.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
1616
from pydantic import AliasChoices, AliasPath, BaseModel
1717
from pydantic.fields import FieldInfo
18+
from typing_extensions import Protocol
1819

1920
from mcp.shared.extension import validate_extension_identifier
2021

@@ -24,6 +25,7 @@
2425
__all__ = [
2526
"ClaimContext",
2627
"ClientExtension",
28+
"ClientExtensionBinding",
2729
"NotificationBinding",
2830
"ResultClaim",
2931
"UnexpectedClaimedResult",
@@ -54,6 +56,15 @@ def _wire_keys(name: str, field: FieldInfo) -> frozenset[str]:
5456

5557
ClaimedT = TypeVar("ClaimedT", bound=Result)
5658
NotifyParamsT = TypeVar("NotifyParamsT", bound=BaseModel)
59+
BoundT_co = TypeVar("BoundT_co", covariant=True)
60+
61+
62+
class ClientExtensionBinding(Protocol[BoundT_co]):
63+
"""An extension that exposes a typed API for one connected client."""
64+
65+
def bind(self, session: ClientSession) -> BoundT_co:
66+
"""Return the extension API bound to `session`."""
67+
...
5768

5869

5970
@dataclass(frozen=True, kw_only=True)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from mcp.client.extensions.skills import Skills, SkillsClient
2+
3+
__all__ = ["Skills", "SkillsClient"]

0 commit comments

Comments
 (0)