Skip to content

Commit 662ce93

Browse files
author
vijay
committed
modified the docs and added check for de-dup across pages
1 parent 7ca165a commit 662ce93

6 files changed

Lines changed: 103 additions & 16 deletions

File tree

‎docs/advanced/skills.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,12 @@ a `ReadResourceResult` (text or blob contents) and validates nothing itself —
8484
never grant the frontmatter's `allowed-tools` field (or any other permission-widening field)
8585
without explicit per-skill user approval.
8686

87-
Both are host responsibilities the SDK cannot discharge for you — read the SEP's
87+
A digest match confirms the *bytes*, not the *frontmatter*: it doesn't prove the
88+
`frontmatter` the server advertised in `skills/get` matches the frontmatter inside the fetched
89+
`SKILL.md`. If you act on `skill.frontmatter` — especially `allowed-tools` — parse the fetched
90+
file and compare its frontmatter yourself.
91+
92+
These are host responsibilities the SDK cannot discharge for you — read the SEP's
8893
[Security Implications](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640)
8994
section before building a host on top of this extension.
9095

‎src/mcp/client/skills.py‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,25 +94,33 @@ async def list_skills(self, params: ListSkillsParams | None = None) -> list[Skil
9494
9595
Raises:
9696
ValueError: If the server doesn't advertise the Skills extension, its
97-
response is not SEP-2640 conformant, or it repeats a pagination cursor.
97+
response is not SEP-2640 conformant, it repeats a pagination
98+
cursor, or it lists the same skill URI on two pages.
9899
MCPError: If the server returns an error response.
99100
"""
100101
self._require_extension()
101102
base = params if params is not None else ListSkillsParams()
102103
cursor = base.cursor
103104
skills: list[Skill] = []
104105
seen_cursors: set[str] = {cursor} if cursor is not None else set()
106+
seen_uris: set[str] = set()
105107
while True:
106108
# `send_request` parses each page into `ListSkillsResult`, whose validators reject a
107109
# non-conformant skill or a duplicate URI — no separate conformance call is needed.
108110
page = await self._session.send_request(
109111
ListSkillsRequest(params=base.model_copy(update={"cursor": cursor})), ListSkillsResult
110112
)
113+
# A server stuck repeating its cursor is a loop; catch that before the content checks.
114+
if page.next_cursor is not None and page.next_cursor in seen_cursors:
115+
raise ValueError(f"server repeated skills/list pagination cursor {page.next_cursor!r}")
116+
# Per-page validation can't catch a URI repeated *across* pages, so track that here.
117+
for skill in page.skills:
118+
if skill.uri in seen_uris:
119+
raise ValueError(f"server listed skill {skill.uri!r} on more than one skills/list page")
120+
seen_uris.add(skill.uri)
111121
skills.extend(page.skills)
112122
if page.next_cursor is None:
113123
return skills
114-
if page.next_cursor in seen_cursors:
115-
raise ValueError(f"server repeated skills/list pagination cursor {page.next_cursor!r}")
116124
seen_cursors.add(page.next_cursor)
117125
cursor = page.next_cursor
118126

@@ -156,25 +164,32 @@ async def read_directory(self, uri: str, params: ReadDirectoryParams | None = No
156164
157165
Raises:
158166
ValueError: If the server doesn't advertise the `directoryRead`
159-
setting, its response is not a valid child listing of `uri`, or
160-
it repeats a pagination cursor.
167+
setting, its response is not a valid child listing of `uri`, it
168+
repeats a pagination cursor, or it lists the same child on two pages.
161169
MCPError: If the server returns an error response.
162170
"""
163171
self._require_extension(directory_read=True)
164172
base = params if params is not None else ReadDirectoryParams(uri=uri)
165173
cursor = base.cursor
166174
resources: list[Resource] = []
167175
seen_cursors: set[str] = {cursor} if cursor is not None else set()
176+
seen_uris: set[str] = set()
168177
while True:
169178
page = await self._session.send_request(
170179
ReadDirectoryRequest(params=base.model_copy(update={"uri": uri, "cursor": cursor})),
171180
ReadDirectoryResult,
172181
)
182+
# A server stuck repeating its cursor is a loop; catch that before the content checks.
183+
if page.next_cursor is not None and page.next_cursor in seen_cursors:
184+
raise ValueError(f"server repeated resources/directory/read pagination cursor {page.next_cursor!r}")
173185
validate_directory_result(uri, page)
186+
# `validate_directory_result` dedupes within a page; catch a child repeated across pages.
187+
for resource in page.resources:
188+
if resource.uri in seen_uris:
189+
raise ValueError(f"server listed child {resource.uri!r} on more than one directory page")
190+
seen_uris.add(resource.uri)
174191
resources.extend(page.resources)
175192
if page.next_cursor is None:
176193
return resources
177-
if page.next_cursor in seen_cursors:
178-
raise ValueError(f"server repeated resources/directory/read pagination cursor {page.next_cursor!r}")
179194
seen_cursors.add(page.next_cursor)
180195
cursor = page.next_cursor

‎src/mcp/server/skills.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ class Skills(Extension):
7474
setting and serves `resources/directory/read`; omitting it advertises
7575
neither. Handlers run per request, so a catalog that changes over time
7676
(or is too large to enumerate) can return a partial or empty listing.
77+
78+
Handler error contract: raise `MCPError` to return a specific error to the
79+
caller (e.g. `INVALID_PARAMS` from `get_skill` for a URI it doesn't serve).
80+
Any other failure — a raised exception, or a result that isn't SEP-2640
81+
conformant — is treated as a server fault and reported as `INTERNAL_ERROR`,
82+
never the caller's bad params.
7783
"""
7884

7985
identifier = EXTENSION_ID

‎src/mcp/shared/skills.py‎

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,32 @@
1616
from pydantic import AfterValidator, BaseModel, ConfigDict, Field, model_validator
1717
from pydantic.alias_generators import to_camel
1818

19+
__all__ = [
20+
"EXTENSION_ID",
21+
"METHOD_LIST",
22+
"METHOD_GET",
23+
"METHOD_READ_DIRECTORY",
24+
"MAX_RESOURCES_PER_SKILL",
25+
"MAX_TOTAL_SIZE",
26+
"Frontmatter",
27+
"SkillResource",
28+
"SkillResources",
29+
"Skill",
30+
"ListSkillsParams",
31+
"ListSkillsResult",
32+
"GetSkillParams",
33+
"GetSkillResult",
34+
"ReadDirectoryParams",
35+
"ReadDirectoryResult",
36+
"ListSkillsRequest",
37+
"GetSkillRequest",
38+
"ReadDirectoryRequest",
39+
"skill_name_from_uri",
40+
"parse_directory_uri",
41+
"validate_directory_result",
42+
"verify_skill_resource",
43+
]
44+
1945
EXTENSION_ID = "io.modelcontextprotocol/skills"
2046
"""The Skills extension identifier, advertised under `ServerCapabilities.extensions`."""
2147

@@ -83,8 +109,9 @@ class Skill(_SkillModel):
83109
SEP-2640 conformance is intrinsic: constructing (or parsing) a `Skill`
84110
validates the frontmatter `name`/`description`, and — unless `resources` is
85111
`"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.
112+
with no duplicates and `SKILL.md` present. The `MAX_RESOURCES_PER_SKILL`
113+
(512-entry) and `MAX_TOTAL_SIZE` (16-MiB) limits are SHOULD NOT thresholds,
114+
not MUST NOT, so an over-limit manifest is accepted.
88115
"""
89116

90117
uri: str
@@ -287,16 +314,16 @@ def validate_directory_result(uri: str, result: ReadDirectoryResult) -> None:
287314

288315

289316
def verify_skill_resource(skill: Skill, uri: str, content: bytes) -> None:
290-
"""Verify `content` (the bytes read from `uri`) against `skill`'s held manifest.
317+
"""Check that `content` (the bytes read from `uri`) matches `skill`'s manifest entry.
291318
292-
Implements the SEP-2640 Integrity and verification requirement: a host
293-
MUST verify a retrieved file's bytes against its manifest entry before
294-
using them. Not applicable to a skill whose `resources` is `"dynamic"`,
295-
which offers no digest to verify against.
319+
Recomputes the size and SHA-256 digest of `content` and compares them to the
320+
entry `skill` holds for `uri` — the byte-integrity check SEP-2640 requires
321+
before a host trusts a fetched file. A `"dynamic"` skill carries no digests,
322+
so it has nothing to verify against.
296323
297324
Raises:
298325
ValueError: If `uri` is not one of `skill`'s resources, `skill.resources`
299-
is `"dynamic"`, or `content` does not match the entry's `size`/`digest`.
326+
is `"dynamic"`, or `content`'s size or digest doesn't match the entry.
300327
"""
301328
if skill.resources == "dynamic":
302329
raise ValueError(f"skill {skill.uri!r} has dynamic resources and cannot be integrity-verified")

‎tests/client/test_skills.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,21 @@ async def test_list_skills_raises_on_a_server_that_repeats_its_cursor() -> None:
160160
await skills.list_skills()
161161

162162

163+
async def test_list_skills_rejects_a_skill_repeated_across_pages() -> None:
164+
"""SEP-2640 pagination: a page's own validation dedupes within that page, so the client also
165+
rejects a skill URI the server hands back on a second page rather than returning it twice."""
166+
167+
async def handler(ctx: ServerRequestContext[Any, Any], params: ListSkillsParams) -> ListSkillsResult:
168+
if params.cursor is None:
169+
return ListSkillsResult(skills=[_skill()], next_cursor="page-2")
170+
return ListSkillsResult(skills=[_skill()], next_cursor=None)
171+
172+
server = MCPServer("catalog", extensions=[Skills(list_skills=handler, get_skill=_get_skill)])
173+
async with _skills(server) as skills:
174+
with pytest.raises(ValueError, match="more than one skills/list page"):
175+
await skills.list_skills()
176+
177+
163178
async def test_list_skills_requires_the_extension_to_be_advertised() -> None:
164179
"""No `Skills` extension at all: the server never advertises `io.modelcontextprotocol/skills`."""
165180
async with _skills(MCPServer("plain")) as skills:
@@ -230,6 +245,24 @@ async def test_read_directory_raises_on_a_server_that_repeats_its_cursor() -> No
230245
await skills.read_directory("skill://git-workflow/references")
231246

232247

248+
async def test_read_directory_rejects_a_child_repeated_across_pages() -> None:
249+
"""The client dedupes directory children across pages, not just within a single page."""
250+
child = Resource(uri="skill://git-workflow/references/A.md", name="A.md")
251+
252+
async def handler(ctx: ServerRequestContext[Any, Any], params: ReadDirectoryParams) -> ReadDirectoryResult:
253+
if params.cursor is None:
254+
return ReadDirectoryResult(resources=[child], next_cursor="page-2")
255+
return ReadDirectoryResult(resources=[child], next_cursor=None)
256+
257+
server = MCPServer(
258+
"catalog",
259+
extensions=[Skills(list_skills=_paginated_list_handler(), get_skill=_get_skill, read_directory=handler)],
260+
)
261+
async with _skills(server) as skills:
262+
with pytest.raises(ValueError, match="more than one directory page"):
263+
await skills.read_directory("skill://git-workflow/references")
264+
265+
233266
async def test_get_skill_rejects_a_mismatched_uri_from_a_non_conformant_server() -> None:
234267
server = MCPServer("catalog", extensions=[_NonConformantGetSkill()])
235268
async with _skills(server) as skills:

‎tests/server/test_skills.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ async def test_skills_get_rejects_a_skill_mutated_after_the_result_is_built() ->
271271

272272
async def mutating_get(ctx: ServerRequestContext[Any, Any], params: GetSkillParams) -> GetSkillResult:
273273
result = GetSkillResult(skill=_git_workflow_skill())
274+
assert isinstance(result.skill.resources, list)
274275
result.skill.resources.clear()
275276
return result
276277

0 commit comments

Comments
 (0)