Skip to content

Commit c7c4a95

Browse files
committed
Revert "Verify skill resources through the client extension"
This reverts commit 5e4fb31.
1 parent 36264c2 commit c7c4a95

5 files changed

Lines changed: 44 additions & 163 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_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.
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.
7371

7472
!!! tip
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.
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`.
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: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from mcp import Client
44
from mcp.client.skills import Skills
5+
from mcp.shared.skills import verify_skill_resource
56
from mcp.types import TextResourceContents
67

78

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

1415
skill = await catalog.get_skill("skill://git-workflow/SKILL.md")
15-
result = await catalog.read_skill_resource(skill, skill.uri)
16+
result = await catalog.read_skill_uri(skill.uri)
1617
content = result.contents[0]
1718
if isinstance(content, TextResourceContents):
19+
verify_skill_resource(skill, skill.uri, content.text.encode())
1820
print(content.text)
1921

2022

‎src/mcp/client/skills.py‎

Lines changed: 15 additions & 34 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_resource`, and `read_directory` — tied to that connection:
6+
`read_skill_uri`, 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,15 +13,13 @@
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_resource` reads one file and verifies it against the held `Skill` entry.
16+
`read_skill_uri` is a thin `resources/read` alias that does neither — verify its
17+
result with `verify_skill_resource`.
1718
"""
1819

1920
from __future__ import annotations
2021

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

2624
from mcp.client.extension import ClientExtension
2725
from mcp.client.session import ClientSession
@@ -38,7 +36,6 @@
3836
ReadDirectoryResult,
3937
Skill,
4038
validate_directory_result,
41-
verify_skill_resource,
4239
)
4340

4441
__all__ = ["BoundSkills", "Skills"]
@@ -66,8 +63,9 @@ class BoundSkills:
6663
`get_skill`, and `read_directory` — check that the server advertises the
6764
extension and validate its response against the SEP-2640 conformance rules
6865
before returning; `list_skills` and `read_directory` also follow
69-
`nextCursor` to completion. `read_skill_resource` verifies a fetched file
70-
against the held skill's manifest.
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`.
7169
"""
7270

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

141-
async def read_skill_resource(self, skill: Skill, uri: str) -> ReadResourceResult:
142-
"""Read and verify one file against a held `Skill` entry.
139+
async def read_skill_uri(self, uri: str) -> ReadResourceResult:
140+
"""Read a skill file's content via `resources/read`.
143141
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.
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.
147146
148147
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.
151148
MCPError: If the server returns an error response.
152149
RuntimeError: If the server returns an `InputRequiredResult`; this
153-
method does not drive the input-required loop.
150+
alias does not drive the input-required loop.
154151
"""
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
152+
return await self._session.read_resource(uri)
172153

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

‎tests/client/test_skills.py‎

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

3-
import base64
43
import hashlib
54
from collections.abc import AsyncIterator
65
from contextlib import asynccontextmanager
76
from typing import Any
87

98
import pytest
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-
)
9+
from mcp_types import INVALID_PARAMS, Resource, TextResourceContents
1910

2011
from mcp.client.client import Client
2112
from mcp.client.skills import BoundSkills
2213
from mcp.client.skills import Skills as ClientSkills
23-
from mcp.server import Server
2414
from mcp.server.context import ServerRequestContext
2515
from mcp.server.extension import Extension, MethodBinding
2616
from mcp.server.mcpserver import MCPServer
27-
from mcp.server.mcpserver.resources import FunctionResource, TextResource
17+
from mcp.server.mcpserver.resources import TextResource
2818
from mcp.server.skills import Skills
2919
from mcp.shared.exceptions import MCPError
3020
from mcp.shared.skills import (
@@ -38,6 +28,7 @@
3828
ReadDirectoryResult,
3929
Skill,
4030
SkillResource,
31+
verify_skill_resource,
4132
)
4233

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

207198

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

217206

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-
)
225-
async with _skills(_server()) as skills:
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")
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."""
248209
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)
210+
skill = await skills.get_skill(_SKILL_URI)
211+
result = await skills.read_skill_uri(_SKILL_URI)
268212
contents = result.contents[0]
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")
213+
assert isinstance(contents, TextResourceContents)
214+
verify_skill_resource(skill, _SKILL_URI, contents.text.encode())
319215

320216

321217
async def test_read_directory_follows_next_cursor_to_completion() -> None:

‎tests/docs_src/test_skills.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
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
1011

1112
pytestmark = pytest.mark.anyio
1213

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

4950

50-
async def test_read_skill_resource_returns_verified_content() -> None:
51-
"""tutorial001_client: a fetched file matches the held skill's manifest."""
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."""
5254
skills = Skills()
5355
async with Client(tutorial001.mcp, extensions=[skills]) as client:
5456
catalog = client.extension(Skills)
5557
skill = await catalog.get_skill(tutorial001.SKILL_URI)
56-
result = await catalog.read_skill_resource(skill, skill.uri)
58+
result = await catalog.read_skill_uri(skill.uri)
5759
contents = result.contents[0]
5860
assert isinstance(contents, TextResourceContents)
59-
assert contents.text == tutorial001.SKILL_MD
61+
verify_skill_resource(skill, skill.uri, contents.text.encode())

0 commit comments

Comments
 (0)