Skip to content

Commit 488e706

Browse files
author
vijay
committed
removed separate skill validations as per review suggestion
1 parent 1ddace7 commit 488e706

4 files changed

Lines changed: 169 additions & 180 deletions

File tree

‎src/mcp/client/skills.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,6 @@
3838
ReadDirectoryResult,
3939
Skill,
4040
validate_directory_result,
41-
validate_list_result,
42-
validate_skill,
4341
)
4442
from mcp.shared.skills import verify_skill_resource as verify_skill_resource
4543

@@ -105,10 +103,11 @@ async def list_skills(self, params: ListSkillsParams | None = None) -> list[Skil
105103
skills: list[Skill] = []
106104
seen_cursors: set[str] = {cursor} if cursor is not None else set()
107105
while True:
106+
# `send_request` parses each page into `ListSkillsResult`, whose validators reject a
107+
# non-conformant skill or a duplicate URI — no separate conformance call is needed.
108108
page = await self._session.send_request(
109109
ListSkillsRequest(params=base.model_copy(update={"cursor": cursor})), ListSkillsResult
110110
)
111-
validate_list_result(page)
112111
skills.extend(page.skills)
113112
if page.next_cursor is None:
114113
return skills
@@ -130,10 +129,11 @@ async def get_skill(self, uri: str) -> Skill:
130129
for a URI it does not serve.
131130
"""
132131
self._require_extension()
132+
# Parsing `GetSkillResult` validates the skill's own conformance; the requested-URI match
133+
# is the one rule the skill body can't carry, so it stays an explicit check here.
133134
result = await self._session.send_request(GetSkillRequest(params=GetSkillParams(uri=uri)), GetSkillResult)
134135
if result.skill.uri != uri:
135136
raise ValueError(f"server returned skill {result.skill.uri!r} for requested {uri!r}")
136-
validate_skill(result.skill)
137137
return result.skill
138138

139139
async def read_skill_uri(self, uri: str) -> ReadResourceResult:

‎src/mcp/server/skills.py‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ async def get_skill(ctx, params):
3535
from mcp_types import CacheableResult
3636
from mcp_types.jsonrpc import INTERNAL_ERROR, INVALID_PARAMS
3737
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
38+
from pydantic import ValidationError
3839

3940
from mcp.server.context import HandlerResult, ServerRequestContext
4041
from mcp.server.extension import Extension, MethodBinding
@@ -53,8 +54,6 @@ async def get_skill(ctx, params):
5354
parse_directory_uri,
5455
skill_name_from_uri,
5556
validate_directory_result,
56-
validate_list_result,
57-
validate_skill,
5857
)
5958

6059
__all__ = ["Skills"]
@@ -103,25 +102,27 @@ def methods(self) -> Sequence[MethodBinding]:
103102
return bindings
104103

105104
async def _handle_list(self, ctx: ServerRequestContext[Any, Any], params: ListSkillsParams) -> HandlerResult:
106-
result = await self._list_skills(ctx, params)
105+
# `ListSkillsResult`/`Skill` self-validate on construction, so a handler that builds a
106+
# non-conformant listing raises `ValidationError` here — a server fault, surfaced as an
107+
# Internal error rather than the framework's default Invalid params for a bad body.
107108
try:
108-
validate_list_result(result)
109-
except ValueError:
109+
result = await self._list_skills(ctx, params)
110+
except ValidationError:
110111
logger.exception("list_skills handler returned an invalid result")
111112
raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None
112113
return _finalize_cacheable(result, ctx.protocol_version)
113114

114115
async def _handle_get(self, ctx: ServerRequestContext[Any, Any], params: GetSkillParams) -> HandlerResult:
115116
_require_skill_md_uri(params.uri)
116-
result = await self._get_skill(ctx, params)
117-
if result.skill.uri != params.uri:
118-
logger.error("get_skill handler returned %r for requested %r", result.skill.uri, params.uri)
119-
raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result")
117+
# `Skill` self-validates on construction (see `_handle_list`).
120118
try:
121-
validate_skill(result.skill)
122-
except ValueError:
119+
result = await self._get_skill(ctx, params)
120+
except ValidationError:
123121
logger.exception("get_skill handler returned an invalid result")
124122
raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None
123+
if result.skill.uri != params.uri:
124+
logger.error("get_skill handler returned %r for requested %r", result.skill.uri, params.uri)
125+
raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result")
125126
return _finalize_cacheable(result, ctx.protocol_version)
126127

127128
async def _handle_read_directory(

‎src/mcp/shared/skills.py‎

Lines changed: 66 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@
99

1010
import hashlib
1111
import re
12-
from typing import Any, Literal
12+
from typing import Annotated, Any, Literal
1313
from urllib.parse import urlsplit
1414

1515
from 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
1717
from pydantic.alias_generators import to_camel
1818

1919
EXTENSION_ID = "io.modelcontextprotocol/skills"
@@ -27,8 +27,8 @@
2727
"""SEP-2640 per-skill resource-count threshold (`SKILL.md` included).
2828
2929
A 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

3333
MAX_TOTAL_SIZE = 16 * 1024 * 1024
3434
"""SEP-2640 per-skill total-byte-size threshold (16 MiB), summed over `resources[].size`.
@@ -40,6 +40,12 @@
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+
4349
class _SkillModel(BaseModel):
4450
"""Base for Skills value types: matches `mcp_types`' internal `MCPModel` config.
4551
@@ -51,12 +57,16 @@ class _SkillModel(BaseModel):
5157

5258

5359
class 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

7080
class 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

79125
class ListSkillsParams(PaginatedRequestParams):
80126
"""Parameters for `skills/list`."""
@@ -83,6 +129,9 @@ class ListSkillsParams(PaginatedRequestParams):
83129
class 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

95153
class 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-
240244
def parse_directory_uri(uri: str) -> tuple[str, str, str]:
241245
"""Split a directory resource URI into `(scheme, netloc, path)`.
242246

0 commit comments

Comments
 (0)