|
1 | 1 | """Tests for the client-side Skills extension (SEP-2640, `mcp.client.skills`).""" |
2 | 2 |
|
3 | | -import base64 |
4 | 3 | import hashlib |
5 | 4 | from collections.abc import AsyncIterator |
6 | 5 | from contextlib import asynccontextmanager |
7 | 6 | from typing import Any |
8 | 7 |
|
9 | 8 | 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 |
19 | 10 |
|
20 | 11 | from mcp.client.client import Client |
21 | 12 | from mcp.client.skills import BoundSkills |
22 | 13 | from mcp.client.skills import Skills as ClientSkills |
23 | | -from mcp.server import Server |
24 | 14 | from mcp.server.context import ServerRequestContext |
25 | 15 | from mcp.server.extension import Extension, MethodBinding |
26 | 16 | from mcp.server.mcpserver import MCPServer |
27 | | -from mcp.server.mcpserver.resources import FunctionResource, TextResource |
| 17 | +from mcp.server.mcpserver.resources import TextResource |
28 | 18 | from mcp.server.skills import Skills |
29 | 19 | from mcp.shared.exceptions import MCPError |
30 | 20 | from mcp.shared.skills import ( |
|
38 | 28 | ReadDirectoryResult, |
39 | 29 | Skill, |
40 | 30 | SkillResource, |
| 31 | + verify_skill_resource, |
41 | 32 | ) |
42 | 33 |
|
43 | 34 | pytestmark = pytest.mark.anyio |
@@ -205,117 +196,22 @@ async def test_get_skill_propagates_the_servers_unknown_skill_error() -> None: |
205 | 196 | assert exc_info.value.code == INVALID_PARAMS |
206 | 197 |
|
207 | 198 |
|
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: |
210 | 200 | 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) |
213 | 202 | contents = result.contents[0] |
214 | 203 | assert isinstance(contents, TextResourceContents) |
215 | 204 | assert contents.text == _SKILL_CONTENT |
216 | 205 |
|
217 | 206 |
|
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.""" |
248 | 209 | 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) |
268 | 212 | 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()) |
319 | 215 |
|
320 | 216 |
|
321 | 217 | async def test_read_directory_follows_next_cursor_to_completion() -> None: |
|
0 commit comments