99
1010import hashlib
1111import re
12- from typing import Any , Literal
12+ from typing import Annotated , Any , Literal
1313from urllib .parse import urlsplit
1414
1515from mcp_types import CacheableResult , PaginatedRequestParams , PaginatedResult , Request , RequestParams , Resource
16- from pydantic import BaseModel , ConfigDict
16+ from pydantic import AfterValidator , BaseModel , ConfigDict , Field , model_validator
1717from pydantic .alias_generators import to_camel
1818
1919EXTENSION_ID = "io.modelcontextprotocol/skills"
2727"""SEP-2640 per-skill resource-count threshold (`SKILL.md` included).
2828
2929A SHOULD NOT limit, not a hard cap: the spec requires a host to support skills
30- *up to and including* 512 entries and permits it to support larger ones, so
31- `validate_skill ` does not reject an over-count manifest."""
30+ *up to and including* 512 entries and permits it to support larger ones, so a
31+ `Skill ` does not reject an over-count manifest."""
3232
3333MAX_TOTAL_SIZE = 16 * 1024 * 1024
3434"""SEP-2640 per-skill total-byte-size threshold (16 MiB), summed over `resources[].size`.
4040_DIGEST_RE = re .compile (r"^sha256:[0-9a-f]{64}$" )
4141
4242
43+ def _check_digest (value : str ) -> str :
44+ if not _DIGEST_RE .fullmatch (value ):
45+ raise ValueError (f"invalid SHA-256 digest { value !r} ; expected 'sha256:' + 64 lowercase hex characters" )
46+ return value
47+
48+
4349class _SkillModel (BaseModel ):
4450 """Base for Skills value types: matches `mcp_types`' internal `MCPModel` config.
4551
@@ -51,12 +57,16 @@ class _SkillModel(BaseModel):
5157
5258
5359class SkillResource (_SkillModel ):
54- """One file in a skill's manifest: `{uri, digest, size}`."""
60+ """One file in a skill's manifest: `{uri, digest, size}`.
61+
62+ Shape rules are intrinsic: a `digest` that isn't `sha256:` + 64 lowercase
63+ hex characters, or a negative `size`, is rejected at construction.
64+ """
5565
5666 uri : str
57- digest : str
67+ digest : Annotated [ str , AfterValidator ( _check_digest )]
5868 """SHA-256 digest of the file's raw bytes, formatted `sha256:{64 hex chars}`."""
59- size : int
69+ size : Annotated [ int , Field ( ge = 0 )]
6070 """Length in bytes of the file's raw content."""
6171
6272
@@ -68,13 +78,49 @@ class SkillResource(_SkillModel):
6878
6979
7080class Skill (_SkillModel ):
71- """An entry returned by `skills/list` or `skills/get`."""
81+ """An entry returned by `skills/list` or `skills/get`.
82+
83+ SEP-2640 conformance is intrinsic: constructing (or parsing) a `Skill`
84+ validates the frontmatter `name`/`description`, and — unless `resources` is
85+ `"dynamic"` — that every entry names a file within the skill's own directory,
86+ with no duplicates and `SKILL.md` present. The 512-entry/16-MiB limits are
87+ SHOULD NOT thresholds, not MUST NOT, so an over-limit manifest is accepted.
88+ """
7289
7390 uri : str
7491 """Resource URI of the skill's `SKILL.md`."""
7592 frontmatter : Frontmatter
7693 resources : SkillResources
7794
95+ @model_validator (mode = "after" )
96+ def _check_conformance (self ) -> Skill :
97+ name = skill_name_from_uri (self .uri )
98+ frontmatter_name = self .frontmatter .get ("name" )
99+ if (
100+ not isinstance (frontmatter_name , str )
101+ or not _NAME_RE .fullmatch (frontmatter_name )
102+ or len (frontmatter_name ) > 64
103+ ):
104+ raise ValueError (f"skill { self .uri !r} frontmatter name must be 1-64 lowercase, digits, or hyphens" )
105+ if frontmatter_name != name :
106+ raise ValueError (
107+ f"skill { self .uri !r} frontmatter name { frontmatter_name !r} does not match URI name { name !r} "
108+ )
109+ description = self .frontmatter .get ("description" )
110+ if not isinstance (description , str ) or not (1 <= len (description ) <= 1024 ):
111+ raise ValueError (f"skill { self .uri !r} frontmatter description must contain 1 to 1024 characters" )
112+ if self .resources == "dynamic" :
113+ return self
114+ seen : set [str ] = set ()
115+ for resource in self .resources :
116+ _validate_resource_uri_in_skill (self .uri , resource .uri )
117+ if resource .uri in seen :
118+ raise ValueError (f"skill { self .uri !r} lists resource { resource .uri !r} more than once" )
119+ seen .add (resource .uri )
120+ if self .uri not in seen :
121+ raise ValueError (f"skill { self .uri !r} resources does not include its own SKILL.md" )
122+ return self
123+
78124
79125class ListSkillsParams (PaginatedRequestParams ):
80126 """Parameters for `skills/list`."""
@@ -83,6 +129,9 @@ class ListSkillsParams(PaginatedRequestParams):
83129class ListSkillsResult (PaginatedResult , CacheableResult ):
84130 """Result of `skills/list`.
85131
132+ Each skill self-validates; on top of that, constructing (or parsing) this
133+ result rejects two entries that share a `uri`.
134+
86135 `ttl_ms`/`cache_scope` are SEP-2549 fields inherited from `CacheableResult`;
87136 unlike a core spec method, nothing sieves them off the wire for a
88137 pre-2026-07-28 connection automatically (see `mcp.server.skills`), so
@@ -91,6 +140,15 @@ class ListSkillsResult(PaginatedResult, CacheableResult):
91140
92141 skills : list [Skill ]
93142
143+ @model_validator (mode = "after" )
144+ def _check_unique_uris (self ) -> ListSkillsResult :
145+ seen : set [str ] = set ()
146+ for skill in self .skills :
147+ if skill .uri in seen :
148+ raise ValueError (f"skills/list result lists skill { skill .uri !r} more than once" )
149+ seen .add (skill .uri )
150+ return self
151+
94152
95153class GetSkillParams (RequestParams ):
96154 """Parameters for `skills/get`."""
@@ -183,60 +241,6 @@ def _validate_resource_uri_in_skill(skill_uri: str, resource_uri: str) -> None:
183241 raise ValueError (f"resource URI { resource_uri !r} contains a traversal segment" )
184242
185243
186- def validate_skill (skill : Skill ) -> None :
187- """Validate `skill` against the SEP-2640 and Agent Skills conformance rules.
188-
189- Checks the frontmatter's `name`/`description` fields, that `resources` (when
190- not `"dynamic"`) is complete — every entry names a file within the skill's
191- own directory, has a well-formed digest, and `SKILL.md` is present. The
192- 512-entry/16-MiB limits are SEP-2640 SHOULD NOT thresholds, not MUST NOT, so
193- an over-limit manifest is accepted (a conforming host must support up to the
194- limits and may support larger).
195-
196- Raises:
197- ValueError: If `skill` violates any of the above.
198- """
199- name = skill_name_from_uri (skill .uri )
200- frontmatter_name = skill .frontmatter .get ("name" )
201- if not isinstance (frontmatter_name , str ) or not _NAME_RE .fullmatch (frontmatter_name ) or len (frontmatter_name ) > 64 :
202- raise ValueError (f"skill { skill .uri !r} frontmatter name must be 1-64 lowercase, digits, or hyphens" )
203- if frontmatter_name != name :
204- raise ValueError (f"skill { skill .uri !r} frontmatter name { frontmatter_name !r} does not match URI name { name !r} " )
205- description = skill .frontmatter .get ("description" )
206- if not isinstance (description , str ) or not (1 <= len (description ) <= 1024 ):
207- raise ValueError (f"skill { skill .uri !r} frontmatter description must contain 1 to 1024 characters" )
208-
209- if skill .resources == "dynamic" :
210- return
211- resources = skill .resources
212- seen : set [str ] = set ()
213- for resource in resources :
214- _validate_resource_uri_in_skill (skill .uri , resource .uri )
215- if resource .uri in seen :
216- raise ValueError (f"skill { skill .uri !r} lists resource { resource .uri !r} more than once" )
217- seen .add (resource .uri )
218- if not _DIGEST_RE .fullmatch (resource .digest ):
219- raise ValueError (f"skill { skill .uri !r} resource { resource .uri !r} has an invalid SHA-256 digest" )
220- if resource .size < 0 :
221- raise ValueError (f"skill { skill .uri !r} resource { resource .uri !r} has a negative size" )
222- if skill .uri not in seen :
223- raise ValueError (f"skill { skill .uri !r} resources does not include its own SKILL.md" )
224-
225-
226- def validate_list_result (result : ListSkillsResult ) -> None :
227- """Validate every skill in `result.skills` and reject duplicate URIs.
228-
229- Raises:
230- ValueError: If any skill is invalid, or two entries share a `uri`.
231- """
232- seen : set [str ] = set ()
233- for skill in result .skills :
234- validate_skill (skill )
235- if skill .uri in seen :
236- raise ValueError (f"skills/list result lists skill { skill .uri !r} more than once" )
237- seen .add (skill .uri )
238-
239-
240244def parse_directory_uri (uri : str ) -> tuple [str , str , str ]:
241245 """Split a directory resource URI into `(scheme, netloc, path)`.
242246
0 commit comments