The Skills extension (io.modelcontextprotocol/skills, SEP-2640).
SEP-2640 defines a convention for serving Agent Skills over MCP using the
Resources primitive: a skill is a directory of files, conventionally exposed
under the skill:// scheme, and enumerated and fetched through two required
methods (skills/list, skills/get) plus one optional one
(resources/directory/read). See
https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640.
This module provides the protocol-level plumbing only: request/response
handling, SEP-2640 conformance validation, and capability advertisement. It
does not discover, read, or hash skills from a filesystem — a server author
supplies handlers that answer skills/list/skills/get/resources/directory/read
however their catalog is stored, and serves the underlying skill:// file
content through the server's ordinary resource-registration APIs
(MCPServer.add_resource, or an @mcp.resource(...) template).
async def list_skills(ctx, params):
return ListSkillsResult(skills=[...])
async def get_skill(ctx, params):
if params.uri != "skill://git-workflow/SKILL.md":
raise MCPError(code=INVALID_PARAMS, message="unknown skill")
return GetSkillResult(skill=...)
mcp = MCPServer("catalog", extensions=[Skills(list_skills=list_skills, get_skill=get_skill)])
Skills
Bases: Extension
The Skills extension: serve skills/list, skills/get, and directory reads.
list_skills and get_skill are required; a server MUST answer both per
SEP-2640, whether or not a skill appears in the listing. read_directory
is optional — supplying it advertises the directoryRead capability
setting and serves resources/directory/read; omitting it advertises
neither. Handlers run per request, so a catalog that changes over time
(or is too large to enumerate) can return a partial or empty listing.
Handler error contract: raise MCPError to return a specific error to the
caller (e.g. INVALID_PARAMS from get_skill for a URI it doesn't serve).
A result that isn't SEP-2640 conformant is caught here and reported as
INTERNAL_ERROR — a server fault, not the caller's bad params.
Source code in src/mcp/server/skills.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142 | class Skills(Extension):
"""The Skills extension: serve `skills/list`, `skills/get`, and directory reads.
`list_skills` and `get_skill` are required; a server MUST answer both per
SEP-2640, whether or not a skill appears in the listing. `read_directory`
is optional — supplying it advertises the `directoryRead` capability
setting and serves `resources/directory/read`; omitting it advertises
neither. Handlers run per request, so a catalog that changes over time
(or is too large to enumerate) can return a partial or empty listing.
Handler error contract: raise `MCPError` to return a specific error to the
caller (e.g. `INVALID_PARAMS` from `get_skill` for a URI it doesn't serve).
A result that isn't SEP-2640 conformant is caught here and reported as
`INTERNAL_ERROR` — a server fault, not the caller's bad params.
"""
identifier = EXTENSION_ID
def __init__(
self,
*,
list_skills: ListSkillsHandler,
get_skill: GetSkillHandler,
read_directory: ReadDirectoryHandler | None = None,
) -> None:
self._list_skills = list_skills
self._get_skill = get_skill
self._read_directory = read_directory
def settings(self) -> dict[str, Any]:
return {"directoryRead": True} if self._read_directory is not None else {}
def methods(self) -> Sequence[MethodBinding]:
bindings = [
MethodBinding(METHOD_LIST, ListSkillsParams, self._handle_list),
MethodBinding(METHOD_GET, GetSkillParams, self._handle_get),
]
if self._read_directory is not None:
bindings.append(MethodBinding(METHOD_READ_DIRECTORY, ReadDirectoryParams, self._handle_read_directory))
return bindings
async def _handle_list(self, ctx: ServerRequestContext[Any, Any], params: ListSkillsParams) -> HandlerResult:
# `ListSkillsResult`/`Skill` self-validate on construction, so a handler that builds a
# non-conformant listing raises `ValidationError` here — a server fault, surfaced as an
# Internal error rather than the framework's default Invalid params for a bad body. The
# models stay mutable, so re-validate the outbound payload too: a handler that mutates a
# skill after building the result can't ship a non-conformant listing past this point.
try:
result = await self._list_skills(ctx, params)
ListSkillsResult.model_validate(result.model_dump())
except ValidationError:
raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None
return _finalize_cacheable(result, ctx.protocol_version)
async def _handle_get(self, ctx: ServerRequestContext[Any, Any], params: GetSkillParams) -> HandlerResult:
_require_skill_md_uri(params.uri)
# `Skill` self-validates on construction, and the re-validation guards post-construction
# mutation, both as in `_handle_list`.
try:
result = await self._get_skill(ctx, params)
GetSkillResult.model_validate(result.model_dump())
except ValidationError:
raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None
if result.skill.uri != params.uri:
raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result")
return _finalize_cacheable(result, ctx.protocol_version)
async def _handle_read_directory(
self, ctx: ServerRequestContext[Any, Any], params: ReadDirectoryParams
) -> HandlerResult:
assert self._read_directory is not None
_require_directory_uri(params.uri)
result = await self._read_directory(ctx, params)
try:
validate_directory_result(params.uri, result)
except ValueError:
raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None
return result
|