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