"""Resource interactions against the low-level Server, driven through the public Client API.""" import base64 import anyio import mcp_types as types import pytest from inline_snapshot import snapshot from mcp_types import ( METHOD_NOT_FOUND, Annotations, BlobResourceContents, CallToolResult, EmptyResult, ErrorData, Icon, ListResourcesResult, ListResourceTemplatesResult, ReadResourceResult, Resource, ResourceTemplate, ResourceUpdatedNotification, ResourceUpdatedNotificationParams, TextContent, TextResourceContents, ) from mcp import MCPError from mcp.server import Server, ServerRequestContext from tests.interaction._connect import Connect from tests.interaction._helpers import IncomingMessage from tests.interaction._requirements import requirement pytestmark = pytest.mark.anyio @requirement("resources:list:basic") @requirement("resources:annotations") async def test_list_resources_returns_registered_resources(connect: Connect) -> None: """Listed resources reach the client with their URIs, names, and optional descriptive fields intact. The fully-populated entry includes annotations, so the snapshot also proves they round-trip. The SDK's Annotations model omits the schema's lastModified field (see the divergence on resources:annotations); the input is built via model_validate with lastModified set so the snapshot pins the drop and will fail once the SDK adds the field. """ async def list_resources( ctx: ServerRequestContext, params: types.PaginatedRequestParams | None ) -> ListResourcesResult: return ListResourcesResult( resources=[ Resource(uri="memo://minimal", name="minimal"), Resource( uri="file:///project/README.md", name="readme", title="Project README", description="The project's front page.", mime_type="text/markdown", size=1024, annotations=Annotations.model_validate( {"audience": ["user", "assistant"], "priority": 0.8, "lastModified": "2025-01-01T00:00:00Z"} ), icons=[Icon(src="https://example.com/readme.png", mime_type="image/png", sizes=["48x48"])], ), ] ) server = Server("library", on_list_resources=list_resources) async with connect(server) as client: result = await client.list_resources() assert result == snapshot( ListResourcesResult( resources=[ Resource(uri="memo://minimal", name="minimal"), Resource( uri="file:///project/README.md", name="readme", title="Project README", description="The project's front page.", mime_type="text/markdown", size=1024, annotations=Annotations( audience=["user", "assistant"], priority=0.8, last_modified="2025-01-01T00:00:00Z" ), icons=[Icon(src="https://example.com/readme.png", mime_type="image/png", sizes=["48x48"])], ), ] ) ) @requirement("resources:read:text") async def test_read_resource_text(connect: Connect) -> None: """Reading a text resource returns its contents with the URI, MIME type, and text supplied by the handler.""" async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult: return ReadResourceResult( contents=[TextResourceContents(uri=params.uri, mime_type="text/plain", text="Hello, world!")] ) server = Server("library", on_read_resource=read_resource) async with connect(server) as client: result = await client.read_resource("file:///greeting.txt") assert result == snapshot( ReadResourceResult( contents=[TextResourceContents(uri="file:///greeting.txt", mime_type="text/plain", text="Hello, world!")] ) ) @requirement("resources:read:blob") async def test_read_resource_binary(connect: Connect) -> None: """Reading a binary resource returns its contents base64-encoded in the blob field.""" async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult: return ReadResourceResult( contents=[ BlobResourceContents( uri=params.uri, mime_type="image/png", blob=base64.b64encode(b"\x89PNG").decode(), ) ] ) server = Server("library", on_read_resource=read_resource) async with connect(server) as client: result = await client.read_resource("file:///pixel.png") assert result == snapshot( ReadResourceResult( contents=[BlobResourceContents(uri="file:///pixel.png", mime_type="image/png", blob="iVBORw==")] ) ) @requirement("resources:read:unknown-uri") async def test_read_resource_unknown_uri_is_protocol_error(connect: Connect) -> None: """A handler that rejects an unrecognised URI with MCPError produces a JSON-RPC error. The spec reserves -32002 for resource-not-found; the code is the handler's choice and reaches the client verbatim. """ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult: raise MCPError(code=-32002, message=f"Resource not found: {params.uri}") server = Server("library", on_read_resource=read_resource) async with connect(server) as client: with pytest.raises(MCPError) as exc_info: await client.read_resource("file:///missing.txt") assert exc_info.value.error == snapshot(ErrorData(code=-32002, message="Resource not found: file:///missing.txt")) @requirement("resources:templates:list") async def test_list_resource_templates_returns_registered_templates(connect: Connect) -> None: """Listed resource templates reach the client with their URI templates and descriptive fields intact.""" async def list_resource_templates( ctx: ServerRequestContext, params: types.PaginatedRequestParams | None ) -> ListResourceTemplatesResult: return ListResourceTemplatesResult( resource_templates=[ ResourceTemplate(uri_template="users://{user_id}", name="user"), ResourceTemplate( uri_template="logs://{service}/{date}", name="service_logs", title="Service logs", description="One day of logs for one service.", mime_type="text/plain", icons=[Icon(src="https://example.com/logs.png", mime_type="image/png", sizes=["48x48"])], ), ] ) server = Server("library", on_list_resource_templates=list_resource_templates) async with connect(server) as client: result = await client.list_resource_templates() assert result == snapshot( ListResourceTemplatesResult( resource_templates=[ ResourceTemplate(uri_template="users://{user_id}", name="user"), ResourceTemplate( uri_template="logs://{service}/{date}", name="service_logs", title="Service logs", description="One day of logs for one service.", mime_type="text/plain", icons=[Icon(src="https://example.com/logs.png", mime_type="image/png", sizes=["48x48"])], ), ] ) ) @pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning") @requirement("resources:subscribe") async def test_subscribe_resource_delivers_uri_to_handler(connect: Connect) -> None: """Subscribing to a resource delivers the URI to the server's subscribe handler and returns an empty result.""" async def subscribe_resource(ctx: ServerRequestContext, params: types.SubscribeRequestParams) -> EmptyResult: assert params.uri == "file:///watched.txt" return EmptyResult() server = Server("library", on_subscribe_resource=subscribe_resource) async with connect(server) as client: result = await client.subscribe_resource("file:///watched.txt") # pyright: ignore[reportDeprecated] assert result == snapshot(EmptyResult()) @pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning") @requirement("resources:subscribe:capability-required") async def test_subscribe_without_a_subscribe_handler_is_method_not_found(connect: Connect) -> None: """Subscribing to a server that registered no subscribe handler is rejected with METHOD_NOT_FOUND. The rejection comes from no handler being registered, not from any capability check; see the divergence on lifecycle:capability:server-not-advertised. """ async def list_resources( ctx: ServerRequestContext, params: types.PaginatedRequestParams | None ) -> ListResourcesResult: """Registered only so the resources capability is advertised; never called.""" raise NotImplementedError server = Server("library", on_list_resources=list_resources) async with connect(server) as client: with pytest.raises(MCPError) as exc_info: await client.subscribe_resource("file:///watched.txt") # pyright: ignore[reportDeprecated] assert exc_info.value.error == snapshot( ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="resources/subscribe") ) @pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning") @requirement("resources:unsubscribe") async def test_unsubscribe_resource_delivers_uri_to_handler(connect: Connect) -> None: """Unsubscribing from a resource delivers the URI to the server's unsubscribe handler.""" async def unsubscribe_resource(ctx: ServerRequestContext, params: types.UnsubscribeRequestParams) -> EmptyResult: assert params.uri == "file:///watched.txt" return EmptyResult() server = Server("library", on_unsubscribe_resource=unsubscribe_resource) async with connect(server) as client: result = await client.unsubscribe_resource("file:///watched.txt") # pyright: ignore[reportDeprecated] assert result == snapshot(EmptyResult()) @requirement("resources:updated-notification") async def test_resource_updated_notification_reaches_client(connect: Connect) -> None: """A resources/updated notification sent during a tool call reaches the client with the resource URI. ``send_resource_updated`` does not take a ``related_request_id``, so over streamable HTTP the notification routes to the standalone GET stream and is not guaranteed to arrive before the tool result; the test waits on an event the collector sets. The collector records every message the handler receives, so the assertion also proves nothing else was delivered. """ received: list[IncomingMessage] = [] seen = anyio.Event() async def collect(message: IncomingMessage) -> None: received.append(message) seen.set() async def list_tools( ctx: ServerRequestContext, params: types.PaginatedRequestParams | None ) -> types.ListToolsResult: return types.ListToolsResult(tools=[types.Tool(name="touch", input_schema={"type": "object"})]) async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: assert params.name == "touch" await ctx.session.send_resource_updated("file:///watched.txt") return CallToolResult(content=[TextContent(text="touched")]) async def list_resources( ctx: ServerRequestContext, params: types.PaginatedRequestParams | None ) -> ListResourcesResult: """Registered so the resources capability is advertised; the client never lists resources.""" raise NotImplementedError async def subscribe_resource(ctx: ServerRequestContext, params: types.SubscribeRequestParams) -> EmptyResult: """Registered so the resources subscribe sub-capability is advertised; the client never subscribes.""" raise NotImplementedError server = Server( "library", on_list_tools=list_tools, on_call_tool=call_tool, on_list_resources=list_resources, on_subscribe_resource=subscribe_resource, ) async with connect(server, message_handler=collect) as client: await client.call_tool("touch", {}) with anyio.fail_after(5): await seen.wait() assert received == snapshot( [ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri="file:///watched.txt"))] )