Skip to content

skills

Wire types and SEP-2640 conformance checks for the Skills extension.

Shared by the server (mcp.server.skills) and client (mcp.client.extensions.skills) surfaces, mirroring how mcp.shared.extension hosts the identifier grammar both tiers need. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640.

EXTENSION_ID module-attribute

EXTENSION_ID = 'io.modelcontextprotocol/skills'

The Skills extension identifier, advertised under ServerCapabilities.extensions.

MAX_RESOURCES_PER_SKILL module-attribute

MAX_RESOURCES_PER_SKILL = 512

SEP-2640 per-skill resource-count threshold (SKILL.md included).

A SHOULD NOT limit, not a hard cap: the spec requires a host to support skills up to and including 512 entries and permits it to support larger ones, so a Skill does not reject an over-count manifest.

MAX_TOTAL_SIZE module-attribute

MAX_TOTAL_SIZE = 16 * 1024 * 1024

SEP-2640 per-skill total-byte-size threshold (16 MiB), summed over resources[].size.

A SHOULD NOT limit, not a hard cap (see MAX_RESOURCES_PER_SKILL); an over-size manifest is not rejected.

SkillResource

Bases: _SkillModel

One file in a skill's manifest: {uri, digest, size}.

Shape rules are intrinsic: a digest that isn't sha256: + 64 lowercase hex characters, or a negative size, is rejected at construction.

Source code in src/mcp/shared/skills.py
71
72
73
74
75
76
77
78
79
80
81
82
class SkillResource(_SkillModel):
    """One file in a skill's manifest: `{uri, digest, size}`.

    Shape rules are intrinsic: a `digest` that isn't `sha256:` + 64 lowercase
    hex characters, or a negative `size`, is rejected at construction.
    """

    uri: str
    digest: Annotated[str, AfterValidator(_check_digest)]
    """SHA-256 digest of the file's raw bytes, formatted `sha256:{64 hex chars}`."""
    size: Annotated[int, Field(ge=0)]
    """Length in bytes of the file's raw content."""

digest instance-attribute

digest: Annotated[str, AfterValidator(_check_digest)]

SHA-256 digest of the file's raw bytes, formatted sha256:{64 hex chars}.

size instance-attribute

size: Annotated[int, Field(ge=0)]

Length in bytes of the file's raw content.

Frontmatter module-attribute

Frontmatter = dict[str, Any]

A skill's SKILL.md YAML frontmatter, rendered verbatim as JSON.

SkillResources module-attribute

SkillResources = list[SkillResource] | Literal['dynamic']

A skill's complete resource manifest, or the "dynamic" marker (SEP-2640 Resources).

Skill

Bases: _SkillModel

An entry returned by skills/list or skills/get.

SEP-2640 conformance is intrinsic: constructing (or parsing) a Skill validates the frontmatter name/description, and — unless resources is "dynamic" — that every entry names a file within the skill's own directory, with no duplicates and SKILL.md present. The MAX_RESOURCES_PER_SKILL (512-entry) and MAX_TOTAL_SIZE (16-MiB) limits are SHOULD NOT thresholds, not MUST NOT, so an over-limit manifest is accepted.

Source code in src/mcp/shared/skills.py
 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
class Skill(_SkillModel):
    """An entry returned by `skills/list` or `skills/get`.

    SEP-2640 conformance is intrinsic: constructing (or parsing) a `Skill`
    validates the frontmatter `name`/`description`, and — unless `resources` is
    `"dynamic"` — that every entry names a file within the skill's own directory,
    with no duplicates and `SKILL.md` present. The `MAX_RESOURCES_PER_SKILL`
    (512-entry) and `MAX_TOTAL_SIZE` (16-MiB) limits are SHOULD NOT thresholds,
    not MUST NOT, so an over-limit manifest is accepted.
    """

    uri: str
    """Resource URI of the skill's `SKILL.md`."""
    frontmatter: Frontmatter
    resources: SkillResources

    @model_validator(mode="after")
    def _check_conformance(self) -> Skill:
        name = skill_name_from_uri(self.uri)
        frontmatter_name = self.frontmatter.get("name")
        if (
            not isinstance(frontmatter_name, str)
            or not _NAME_RE.fullmatch(frontmatter_name)
            or len(frontmatter_name) > 64
        ):
            raise ValueError(f"skill {self.uri!r} frontmatter name must be 1-64 lowercase, digits, or hyphens")
        if frontmatter_name != name:
            raise ValueError(
                f"skill {self.uri!r} frontmatter name {frontmatter_name!r} does not match URI name {name!r}"
            )
        description = self.frontmatter.get("description")
        if not isinstance(description, str) or not (1 <= len(description) <= 1024):
            raise ValueError(f"skill {self.uri!r} frontmatter description must contain 1 to 1024 characters")
        if self.resources == "dynamic":
            return self
        seen: set[str] = set()
        for resource in self.resources:
            _validate_resource_uri_in_skill(self.uri, resource.uri)
            if resource.uri in seen:
                raise ValueError(f"skill {self.uri!r} lists resource {resource.uri!r} more than once")
            seen.add(resource.uri)
        if self.uri not in seen:
            raise ValueError(f"skill {self.uri!r} resources does not include its own SKILL.md")
        return self

uri instance-attribute

uri: str

Resource URI of the skill's SKILL.md.

ListSkillsParams

Bases: PaginatedRequestParams

Parameters for skills/list.

Source code in src/mcp/shared/skills.py
138
139
class ListSkillsParams(PaginatedRequestParams):
    """Parameters for `skills/list`."""

ListSkillsResult

Bases: PaginatedResult, CacheableResult

Result of skills/list.

Each skill self-validates; on top of that, constructing (or parsing) this result rejects two entries that share a uri.

ttl_ms/cache_scope are SEP-2549 fields inherited from CacheableResult; unlike a core spec method, nothing sieves them off the wire for a pre-2026-07-28 connection automatically (see mcp.server.skills), so callers constructing this directly for such a connection must omit them.

Source code in src/mcp/shared/skills.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
class ListSkillsResult(PaginatedResult, CacheableResult):
    """Result of `skills/list`.

    Each skill self-validates; on top of that, constructing (or parsing) this
    result rejects two entries that share a `uri`.

    `ttl_ms`/`cache_scope` are SEP-2549 fields inherited from `CacheableResult`;
    unlike a core spec method, nothing sieves them off the wire for a
    pre-2026-07-28 connection automatically (see `mcp.server.skills`), so
    callers constructing this directly for such a connection must omit them.
    """

    skills: list[Skill]

    @model_validator(mode="after")
    def _check_unique_uris(self) -> ListSkillsResult:
        seen: set[str] = set()
        for skill in self.skills:
            if skill.uri in seen:
                raise ValueError(f"skills/list result lists skill {skill.uri!r} more than once")
            seen.add(skill.uri)
        return self

GetSkillParams

Bases: RequestParams

Parameters for skills/get.

Source code in src/mcp/shared/skills.py
166
167
168
169
170
class GetSkillParams(RequestParams):
    """Parameters for `skills/get`."""

    uri: str
    """URI of the skill's `SKILL.md`."""

uri instance-attribute

uri: str

URI of the skill's SKILL.md.

GetSkillResult

Bases: CacheableResult

Result of skills/get.

Like ListSkillsResult, this extends CacheableResult: the stable spec page makes GetSkillResult carry SEP-2549's ttl_ms/cache_scope, the same freshness hint resources/read gives. As on skills/list, nothing sieves these fields off the wire for a pre-2026-07-28 connection automatically (see mcp.server.skills), so a caller constructing this directly for such a connection must omit them.

Source code in src/mcp/shared/skills.py
173
174
175
176
177
178
179
180
181
182
183
184
class GetSkillResult(CacheableResult):
    """Result of `skills/get`.

    Like `ListSkillsResult`, this extends `CacheableResult`: the stable spec page
    makes `GetSkillResult` carry SEP-2549's `ttl_ms`/`cache_scope`, the same
    freshness hint `resources/read` gives. As on `skills/list`, nothing sieves
    these fields off the wire for a pre-2026-07-28 connection automatically (see
    `mcp.server.skills`), so a caller constructing this directly for such a
    connection must omit them.
    """

    skill: Skill

ReadDirectoryParams

Bases: PaginatedRequestParams

Parameters for resources/directory/read.

Source code in src/mcp/shared/skills.py
187
188
189
190
191
class ReadDirectoryParams(PaginatedRequestParams):
    """Parameters for `resources/directory/read`."""

    uri: str
    """URI of the directory resource whose direct children are listed."""

uri instance-attribute

uri: str

URI of the directory resource whose direct children are listed.

ReadDirectoryResult

Bases: PaginatedResult

Result of resources/directory/read.

Source code in src/mcp/shared/skills.py
194
195
196
197
class ReadDirectoryResult(PaginatedResult):
    """Result of `resources/directory/read`."""

    resources: list[Resource]

skill_name_from_uri

skill_name_from_uri(uri: str) -> str

Return the skill name encoded in a SKILL.md resource URI.

Per SEP-2640 Resource Mapping, the final <skill-path> segment equals the skill's name; for a bare skill://<name>/SKILL.md (no organizational prefix) that segment is the authority.

Raises:

Type Description
ValueError

If uri is not an absolute URI ending in /SKILL.md.

Source code in src/mcp/shared/skills.py
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
def skill_name_from_uri(uri: str) -> str:
    """Return the skill `name` encoded in a `SKILL.md` resource URI.

    Per SEP-2640 Resource Mapping, the final `<skill-path>` segment equals the
    skill's `name`; for a bare `skill://<name>/SKILL.md` (no organizational
    prefix) that segment is the authority.

    Raises:
        ValueError: If `uri` is not an absolute URI ending in `/SKILL.md`.
    """
    parts = urlsplit(uri)
    if not parts.scheme or parts.query or parts.fragment:
        raise ValueError(f"skill URI {uri!r} is not a valid absolute resource URI")
    if not parts.path.endswith("/SKILL.md"):
        raise ValueError(f"skill URI {uri!r} must end in /SKILL.md")
    directory = parts.path[: -len("/SKILL.md")].strip("/")
    if directory:
        name = directory.rsplit("/", 1)[-1]
    else:
        name = parts.hostname or ""
    if not name:
        raise ValueError(f"skill URI {uri!r} has no skill name")
    return name

parse_directory_uri

parse_directory_uri(uri: str) -> tuple[str, str, str]

Split a directory resource URI into (scheme, netloc, path).

Raises:

Type Description
ValueError

If uri has a trailing slash, or is otherwise not a valid absolute resource URI.

Source code in src/mcp/shared/skills.py
257
258
259
260
261
262
263
264
265
266
267
268
269
def parse_directory_uri(uri: str) -> tuple[str, str, str]:
    """Split a directory resource URI into `(scheme, netloc, path)`.

    Raises:
        ValueError: If `uri` has a trailing slash, or is otherwise not a valid
            absolute resource URI.
    """
    if uri.endswith("/"):
        raise ValueError(f"directory URI {uri!r} must not have a trailing slash")
    parts = urlsplit(uri)
    if not parts.scheme or parts.query or parts.fragment:
        raise ValueError(f"directory URI {uri!r} is not a valid absolute resource URI")
    return parts.scheme, parts.netloc, parts.path

validate_directory_result

validate_directory_result(
    uri: str, result: ReadDirectoryResult
) -> None

Validate that each entry in result.resources is a unique direct child of uri.

Checks containment and shape only — that every listed resource is a direct child of uri with a unique uri. It cannot confirm the listing is exhaustive, since it has no independent view of the directory's contents.

Raises:

Type Description
ValueError

If uri is malformed, or any entry is not a direct child, or two entries share a uri.

Source code in src/mcp/shared/skills.py
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
def validate_directory_result(uri: str, result: ReadDirectoryResult) -> None:
    """Validate that each entry in `result.resources` is a unique direct child of `uri`.

    Checks containment and shape only — that every listed resource is a direct
    child of `uri` with a unique `uri`. It cannot confirm the listing
    is exhaustive, since it has no independent view of the directory's contents.

    Raises:
        ValueError: If `uri` is malformed, or any entry is not a direct child,
            or two entries share a `uri`.
    """
    scheme, netloc, parent_path = parse_directory_uri(uri)
    seen_uris: set[str] = set()
    prefix = parent_path.rstrip("/") + "/" if parent_path.rstrip("/") else "/"
    for resource in result.resources:
        child = urlsplit(resource.uri)
        if not child.scheme or child.query or child.fragment:
            raise ValueError(f"directory {uri!r} child has invalid URI {resource.uri!r}")
        if child.scheme != scheme or child.netloc != netloc:
            raise ValueError(f"resource {resource.uri!r} is not a child of directory {uri!r}")
        relative = child.path.removeprefix(prefix)
        if relative == child.path or not relative or "/" in relative or relative in (".", ".."):
            raise ValueError(f"resource {resource.uri!r} is not a direct child of directory {uri!r}")
        if resource.uri in seen_uris:
            raise ValueError(f"directory {uri!r} contains a duplicate child {resource.uri!r}")
        seen_uris.add(resource.uri)