Skip to content

Commit 5e4fb31

Browse files
committed
Verify skill resources through the client extension
1 parent aea9f96 commit 5e4fb31

5 files changed

Lines changed: 163 additions & 44 deletions

File tree

‎docs/advanced/skills.md‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,17 +66,17 @@ These three validate the server's response against the SEP-2640 conformance rule
6666
it. A name that doesn't match its URI, a digest in the wrong shape, or an incomplete manifest raises
6767
`ValueError` rather than reaching your code.
6868

69-
`read_skill_uri` is the exception. It's a thin, discoverable alias for `resources/read` that returns
70-
a `ReadResourceResult` (text or blob contents) and validates nothing itself — that's the next step.
69+
`read_skill_resource(skill, uri)` fetches a file and verifies it against the manifest in the `Skill`
70+
entry you pass. It returns a `ReadResourceResult` with text or blob content only after the URI,
71+
size, and SHA-256 digest match. Keep that entry while you act on the skill so every file is checked
72+
against the same manifest.
7173

7274
!!! tip
73-
`verify_skill_resource(skill, uri, content)` checks a file's bytes — size, then SHA-256
74-
digest — against the entry you hold for it. Call it after `read_skill_uri` and *before* you
75-
treat the content as trustworthy.
76-
77-
`resources/read` returns whatever bytes the server sends *right now*; verification is what ties
78-
those bytes back to the manifest you already validated. It applies to a static manifest only —
79-
a `"dynamic"` skill carries no digests, so calling it on one raises `ValueError`.
75+
`read_skill_resource` checks the bytes returned by `resources/read` against the held manifest.
76+
A `"dynamic"` skill carries no digests, so this method raises `ValueError` for one. You can use
77+
`Client.read_resource` to fetch dynamic content, but it cannot be integrity-verified.
78+
If you fetch or cache bytes outside this method, `mcp.shared.skills.verify_skill_resource`
79+
performs the same check on those bytes.
8080

8181
!!! warning
8282
Skill content is untrusted model input, exactly like any other server-provided text. SEP-2640

‎docs_src/skills/tutorial001_client.py‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from mcp import Client
44
from mcp.client.skills import Skills
5-
from mcp.shared.skills import verify_skill_resource
65
from mcp.types import TextResourceContents
76

87

@@ -13,10 +12,9 @@ async def main() -> None:
1312
print(skill.uri, skill.frontmatter["description"])
1413

1514
skill = await catalog.get_skill("skill://git-workflow/SKILL.md")
16-
result = await catalog.read_skill_uri(skill.uri)
15+
result = await catalog.read_skill_resource(skill, skill.uri)
1716
content = result.contents[0]
1817
if isinstance(content, TextResourceContents):
19-
verify_skill_resource(skill, skill.uri, content.text.encode())
2018
print(content.text)
2119

2220

‎src/mcp/client/skills.py‎

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
`Skills` is an opt-in [`ClientExtension`](../advanced/extensions.md) for talking
44
to a skills catalog server. Register it with `Client(extensions=[Skills()])`,
55
then call `client.extension(Skills)` for the SEP-2640 verbs — `list_skills`, `get_skill`,
6-
`read_skill_uri`, and `read_directory` — tied to that connection:
6+
`read_skill_resource`, and `read_directory` — tied to that connection:
77
88
async with Client("http://localhost:8000/mcp", extensions=[Skills()]) as client:
99
for skill in await client.extension(Skills).list_skills():
@@ -13,13 +13,15 @@
1313
`get_skill`, and `read_directory` — check that the server advertises the
1414
extension and validate its response; `list_skills` and `read_directory` follow
1515
`nextCursor` to completion, so one call returns every page's results.
16-
`read_skill_uri` is a thin `resources/read` alias that does neither — verify its
17-
result with `verify_skill_resource`.
16+
`read_skill_resource` reads one file and verifies it against the held `Skill` entry.
1817
"""
1918

2019
from __future__ import annotations
2120

22-
from mcp_types import ReadResourceResult, Resource
21+
import base64
22+
import binascii
23+
24+
from mcp_types import ReadResourceResult, Resource, TextResourceContents
2325

2426
from mcp.client.extension import ClientExtension
2527
from mcp.client.session import ClientSession
@@ -36,6 +38,7 @@
3638
ReadDirectoryResult,
3739
Skill,
3840
validate_directory_result,
41+
verify_skill_resource,
3942
)
4043

4144
__all__ = ["BoundSkills", "Skills"]
@@ -63,9 +66,8 @@ class BoundSkills:
6366
`get_skill`, and `read_directory` — check that the server advertises the
6467
extension and validate its response against the SEP-2640 conformance rules
6568
before returning; `list_skills` and `read_directory` also follow
66-
`nextCursor` to completion. `read_skill_uri` is the exception: a thin
67-
`resources/read` alias that neither checks advertisement nor validates —
68-
pair it with `verify_skill_resource`.
69+
`nextCursor` to completion. `read_skill_resource` verifies a fetched file
70+
against the held skill's manifest.
6971
"""
7072

7173
def __init__(self, session: ClientSession) -> None:
@@ -136,20 +138,37 @@ async def get_skill(self, uri: str) -> Skill:
136138
raise ValueError(f"server returned skill {result.skill.uri!r} for requested {uri!r}")
137139
return result.skill
138140

139-
async def read_skill_uri(self, uri: str) -> ReadResourceResult:
140-
"""Read a skill file's content via `resources/read`.
141+
async def read_skill_resource(self, skill: Skill, uri: str) -> ReadResourceResult:
142+
"""Read and verify one file against a held `Skill` entry.
141143
142-
A thin, discoverable alias: works for any `skill://` (or other-scheme)
143-
file regardless of whether the skill was ever enumerated. Verify the
144-
result against a held `Skill` entry with `verify_skill_resource` before
145-
treating it as trusted content — this call does not verify anything itself.
144+
Pass the entry obtained from `list_skills` or `get_skill`. A dynamic
145+
skill has no manifest to verify; use `Client.read_resource` if you
146+
choose to read its unverified content.
146147
147148
Raises:
149+
ValueError: If the file is not in the manifest, or the server's
150+
content does not match its URI, size, or digest.
148151
MCPError: If the server returns an error response.
149152
RuntimeError: If the server returns an `InputRequiredResult`; this
150-
alias does not drive the input-required loop.
153+
method does not drive the input-required loop.
151154
"""
152-
return await self._session.read_resource(uri)
155+
if skill.resources == "dynamic":
156+
raise ValueError(f"skill {skill.uri!r} has dynamic resources and cannot be integrity-verified")
157+
if not any(resource.uri == uri for resource in skill.resources):
158+
raise ValueError(f"{uri!r} is not in skill {skill.uri!r}'s held manifest")
159+
result = await self._session.read_resource(uri)
160+
if len(result.contents) != 1 or result.contents[0].uri != uri:
161+
raise ValueError(f"resource {uri!r} did not return exactly one matching content item")
162+
content = result.contents[0]
163+
if isinstance(content, TextResourceContents):
164+
data = content.text.encode("utf-8")
165+
else:
166+
try:
167+
data = base64.b64decode(content.blob, validate=True)
168+
except binascii.Error as exc:
169+
raise ValueError(f"resource {uri!r} returned invalid base64 content") from exc
170+
verify_skill_resource(skill, uri, data)
171+
return result
153172

154173
async def read_directory(self, uri: str, params: ReadDirectoryParams | None = None) -> list[Resource]:
155174
"""Call `resources/directory/read` for `uri`, following `nextCursor` to completion.

‎tests/client/test_skills.py‎

Lines changed: 115 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
11
"""Tests for the client-side Skills extension (SEP-2640, `mcp.client.skills`)."""
22

3+
import base64
34
import hashlib
45
from collections.abc import AsyncIterator
56
from contextlib import asynccontextmanager
67
from typing import Any
78

89
import pytest
9-
from mcp_types import INVALID_PARAMS, Resource, TextResourceContents
10+
from inline_snapshot import snapshot
11+
from mcp_types import (
12+
INVALID_PARAMS,
13+
BlobResourceContents,
14+
ReadResourceRequestParams,
15+
ReadResourceResult,
16+
Resource,
17+
TextResourceContents,
18+
)
1019

1120
from mcp.client.client import Client
1221
from mcp.client.skills import BoundSkills
1322
from mcp.client.skills import Skills as ClientSkills
23+
from mcp.server import Server
1424
from mcp.server.context import ServerRequestContext
1525
from mcp.server.extension import Extension, MethodBinding
1626
from mcp.server.mcpserver import MCPServer
17-
from mcp.server.mcpserver.resources import TextResource
27+
from mcp.server.mcpserver.resources import FunctionResource, TextResource
1828
from mcp.server.skills import Skills
1929
from mcp.shared.exceptions import MCPError
2030
from mcp.shared.skills import (
@@ -28,7 +38,6 @@
2838
ReadDirectoryResult,
2939
Skill,
3040
SkillResource,
31-
verify_skill_resource,
3241
)
3342

3443
pytestmark = pytest.mark.anyio
@@ -196,22 +205,117 @@ async def test_get_skill_propagates_the_servers_unknown_skill_error() -> None:
196205
assert exc_info.value.code == INVALID_PARAMS
197206

198207

199-
async def test_read_skill_uri_reads_the_registered_resource() -> None:
208+
async def test_read_skill_resource_returns_verified_text() -> None:
209+
"""SDK-defined: a matching manifest allows a fetched text file."""
200210
async with _skills(_server()) as skills:
201-
result = await skills.read_skill_uri(_SKILL_URI)
211+
skill = await skills.get_skill(_SKILL_URI)
212+
result = await skills.read_skill_resource(skill, _SKILL_URI)
202213
contents = result.contents[0]
203214
assert isinstance(contents, TextResourceContents)
204215
assert contents.text == _SKILL_CONTENT
205216

206217

207-
async def test_read_skill_uri_content_verifies_against_the_held_skill() -> None:
208-
"""End-to-end: `skills/get`'s digest and `resources/read`'s bytes agree."""
218+
async def test_read_skill_resource_rejects_a_changed_file() -> None:
219+
"""SEP-2640: a host rejects content that differs from the held manifest."""
220+
stale = Skill(
221+
uri=_SKILL_URI,
222+
frontmatter={"name": "git-workflow", "description": "d"},
223+
resources=[SkillResource(uri=_SKILL_URI, digest="sha256:" + "0" * 64, size=len(_SKILL_CONTENT))],
224+
)
209225
async with _skills(_server()) as skills:
210-
skill = await skills.get_skill(_SKILL_URI)
211-
result = await skills.read_skill_uri(_SKILL_URI)
226+
with pytest.raises(ValueError) as exc_info:
227+
await skills.read_skill_resource(stale, _SKILL_URI)
228+
assert str(exc_info.value) == snapshot(
229+
"resource 'skill://git-workflow/SKILL.md' has digest "
230+
"'sha256:7187b725da92d7447b6f50e21d0bf4f3b9db3387acb39239c4a4edc193ddcd4b', "
231+
"expected 'sha256:0000000000000000000000000000000000000000000000000000000000000000'"
232+
)
233+
234+
235+
async def test_read_skill_resource_rejects_an_unlisted_uri() -> None:
236+
"""SEP-2640: the host must not fetch a URI absent from the held manifest."""
237+
async with _skills(_server()) as skills:
238+
with pytest.raises(ValueError) as exc_info:
239+
await skills.read_skill_resource(_skill(), "skill://git-workflow/unlisted.md")
240+
assert str(exc_info.value) == snapshot(
241+
"'skill://git-workflow/unlisted.md' is not in skill 'skill://git-workflow/SKILL.md''s held manifest"
242+
)
243+
244+
245+
async def test_read_skill_resource_rejects_a_dynamic_skill() -> None:
246+
"""SDK-defined: a dynamic skill has no digest to verify against."""
247+
dynamic = Skill(uri=_SKILL_URI, frontmatter={"name": "git-workflow", "description": "d"}, resources="dynamic")
248+
async with _skills(_server()) as skills:
249+
with pytest.raises(ValueError) as exc_info:
250+
await skills.read_skill_resource(dynamic, _SKILL_URI)
251+
assert str(exc_info.value) == snapshot(
252+
"skill 'skill://git-workflow/SKILL.md' has dynamic resources and cannot be integrity-verified"
253+
)
254+
255+
256+
async def test_read_skill_resource_returns_verified_binary_content() -> None:
257+
"""SDK-defined: binary resources are decoded before digest verification."""
258+
data = b"\x00\x01skill\xff"
259+
server = MCPServer("catalog")
260+
server.add_resource(FunctionResource(uri=_SKILL_URI, name="SKILL.md", fn=lambda: data))
261+
skill = Skill(
262+
uri=_SKILL_URI,
263+
frontmatter={"name": "git-workflow", "description": "d"},
264+
resources=[SkillResource(uri=_SKILL_URI, digest=f"sha256:{hashlib.sha256(data).hexdigest()}", size=len(data))],
265+
)
266+
async with _skills(server) as skills:
267+
result = await skills.read_skill_resource(skill, _SKILL_URI)
212268
contents = result.contents[0]
213-
assert isinstance(contents, TextResourceContents)
214-
verify_skill_resource(skill, _SKILL_URI, contents.text.encode())
269+
assert isinstance(contents, BlobResourceContents)
270+
assert contents.blob == base64.b64encode(data).decode("ascii")
271+
272+
273+
@pytest.mark.parametrize(
274+
"contents",
275+
[
276+
[],
277+
[TextResourceContents(uri="skill://other/SKILL.md", text=_SKILL_CONTENT)],
278+
[TextResourceContents(uri=_SKILL_URI, text=_SKILL_CONTENT)] * 2,
279+
],
280+
)
281+
async def test_read_skill_resource_rejects_an_invalid_resource_response(
282+
contents: list[TextResourceContents | BlobResourceContents],
283+
) -> None:
284+
"""SDK-defined: malformed peer responses cannot become verified content.
285+
286+
A low-level server is needed because `MCPServer` always produces conformant resource responses.
287+
"""
288+
289+
async def handler(ctx: ServerRequestContext[Any, Any], params: ReadResourceRequestParams) -> ReadResourceResult:
290+
assert params.uri == _SKILL_URI
291+
return ReadResourceResult(contents=contents)
292+
293+
server = Server("catalog")
294+
server.add_request_handler("resources/read", ReadResourceRequestParams, handler)
295+
async with Client(server, extensions=[ClientSkills()]) as client:
296+
with pytest.raises(ValueError) as exc_info:
297+
await client.extension(ClientSkills).read_skill_resource(_skill(), _SKILL_URI)
298+
assert str(exc_info.value) == snapshot(
299+
"resource 'skill://git-workflow/SKILL.md' did not return exactly one matching content item"
300+
)
301+
302+
303+
async def test_read_skill_resource_rejects_invalid_base64() -> None:
304+
"""SDK-defined: a malformed binary response cannot become verified content.
305+
306+
A low-level server is needed because `MCPServer` always base64-encodes binary resources.
307+
"""
308+
309+
async def handler(ctx: ServerRequestContext[Any, Any], params: ReadResourceRequestParams) -> ReadResourceResult:
310+
assert params.uri == _SKILL_URI
311+
return ReadResourceResult(contents=[BlobResourceContents(uri=_SKILL_URI, blob="??")])
312+
313+
server = Server("catalog")
314+
server.add_request_handler("resources/read", ReadResourceRequestParams, handler)
315+
async with Client(server, extensions=[ClientSkills()]) as client:
316+
with pytest.raises(ValueError) as exc_info:
317+
await client.extension(ClientSkills).read_skill_resource(_skill(), _SKILL_URI)
318+
assert str(exc_info.value) == snapshot("resource 'skill://git-workflow/SKILL.md' returned invalid base64 content")
215319

216320

217321
async def test_read_directory_follows_next_cursor_to_completion() -> None:

‎tests/docs_src/test_skills.py‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from mcp import Client
88
from mcp.client.skills import Skills
99
from mcp.shared.exceptions import MCPError
10-
from mcp.shared.skills import verify_skill_resource
1110

1211
pytestmark = pytest.mark.anyio
1312

@@ -48,14 +47,13 @@ async def test_the_skill_file_is_served_as_an_ordinary_resource() -> None:
4847
assert contents.text == tutorial001.SKILL_MD
4948

5049

51-
async def test_read_skill_uri_content_verifies_against_the_held_skill() -> None:
52-
"""tutorial001_client: fetch the entry, read the file, verify the bytes against it -
53-
the digest/size check `verify_skill_resource` performs."""
50+
async def test_read_skill_resource_returns_verified_content() -> None:
51+
"""tutorial001_client: a fetched file matches the held skill's manifest."""
5452
skills = Skills()
5553
async with Client(tutorial001.mcp, extensions=[skills]) as client:
5654
catalog = client.extension(Skills)
5755
skill = await catalog.get_skill(tutorial001.SKILL_URI)
58-
result = await catalog.read_skill_uri(skill.uri)
56+
result = await catalog.read_skill_resource(skill, skill.uri)
5957
contents = result.contents[0]
6058
assert isinstance(contents, TextResourceContents)
61-
verify_skill_resource(skill, skill.uri, contents.text.encode())
59+
assert contents.text == tutorial001.SKILL_MD

0 commit comments

Comments
 (0)