diff --git a/src/mcp/server/experimental/request_context.py b/src/mcp/server/experimental/request_context.py index b5f35749c..3bf12179b 100644 --- a/src/mcp/server/experimental/request_context.py +++ b/src/mcp/server/experimental/request_context.py @@ -91,7 +91,7 @@ def validate_task_mode( error = ErrorData(code=METHOD_NOT_FOUND, message="This tool does not support task-augmented invocation") if error is not None and raise_error: - raise MCPError(code=error.code, message=error.message) + raise MCPError.from_error_data(error) return error diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index c48445366..5481372e1 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -795,12 +795,7 @@ async def _handle_request( await message.respond(response) else: # pragma: no cover - await message.respond( - types.ErrorData( - code=types.METHOD_NOT_FOUND, - message="Method not found", - ) - ) + await message.respond(types.ErrorData(code=types.METHOD_NOT_FOUND, message="Method not found")) logger.debug("Response sent") diff --git a/src/mcp/server/mcpserver/resources/resource_manager.py b/src/mcp/server/mcpserver/resources/resource_manager.py index a855fb5f5..589015688 100644 --- a/src/mcp/server/mcpserver/resources/resource_manager.py +++ b/src/mcp/server/mcpserver/resources/resource_manager.py @@ -82,10 +82,8 @@ def add_template( return template async def get_resource( - self, - uri: AnyUrl | str, - context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, - ) -> Resource | None: + self, uri: AnyUrl | str, context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None + ) -> Resource: """Get resource by URI, checking concrete resources first, then templates.""" uri_str = str(uri) logger.debug("Getting resource", extra={"uri": uri_str}) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 600f39245..fa63a4ef7 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -347,16 +347,18 @@ async def read_resource(self, uri: AnyUrl | str) -> Iterable[ReadResourceContent """Read a resource by URI.""" context = self.get_context() - resource = await self._resource_manager.get_resource(uri, context=context) - if not resource: # pragma: no cover + try: + resource = await self._resource_manager.get_resource(uri, context=context) + except ValueError: raise ResourceError(f"Unknown resource: {uri}") try: content = await resource.read() return [ReadResourceContents(content=content, mime_type=resource.mime_type, meta=resource.meta)] - except Exception as e: # pragma: no cover - logger.exception(f"Error reading resource {uri}") - raise ResourceError(str(e)) + except Exception as exc: + logger.exception(f"Error getting resource {uri}") + # If an exception happens when reading the resource, we should not leak the exception to the client. + raise ResourceError(f"Error reading resource {uri}") from exc def add_tool( self, diff --git a/tests/issues/test_141_resource_templates.py b/tests/issues/test_141_resource_templates.py index 57e8040df..f5c5081c3 100644 --- a/tests/issues/test_141_resource_templates.py +++ b/tests/issues/test_141_resource_templates.py @@ -2,6 +2,7 @@ from mcp import Client from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.exceptions import ResourceError from mcp.types import ( ListResourceTemplatesResult, TextResourceContents, @@ -54,10 +55,10 @@ def get_user_profile_missing(user_id: str) -> str: # pragma: no cover assert result_list[0].mime_type == "text/plain" # Verify invalid parameters raise error - with pytest.raises(ValueError, match="Unknown resource"): + with pytest.raises(ResourceError, match="Unknown resource"): await mcp.read_resource("resource://users/123/posts") # Missing post_id - with pytest.raises(ValueError, match="Unknown resource"): + with pytest.raises(ResourceError, match="Unknown resource"): await mcp.read_resource("resource://users/123/posts/456/extra") # Extra path component diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 76377c280..979dc580f 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest +from inline_snapshot import snapshot from pydantic import BaseModel from starlette.applications import Starlette from starlette.routing import Mount, Route @@ -22,15 +23,24 @@ BlobResourceContents, ContentBlock, EmbeddedResource, + GetPromptResult, Icon, ImageContent, + ListPromptsResult, + Prompt, + PromptArgument, + PromptMessage, + ReadResourceResult, + Resource, + ResourceTemplate, TextContent, TextResourceContents, ) +pytestmark = pytest.mark.anyio + class TestServer: - @pytest.mark.anyio async def test_create_server(self): mcp = MCPServer( title="MCPServer Server", @@ -50,7 +60,6 @@ async def test_create_server(self): assert len(mcp.icons) == 1 assert mcp.icons[0].src == "https://example.com/icon.png" - @pytest.mark.anyio async def test_sse_app_returns_starlette_app(self): """Test that sse_app returns a Starlette application with correct routes.""" mcp = MCPServer("test") @@ -68,7 +77,6 @@ async def test_sse_app_returns_starlette_app(self): assert sse_routes[0].path == "/sse" assert mount_routes[0].path == "/messages" - @pytest.mark.anyio async def test_non_ascii_description(self): """Test that MCPServer handles non-ASCII characters in descriptions correctly""" mcp = MCPServer() @@ -92,7 +100,6 @@ def hello_world(name: str = "世界") -> str: assert isinstance(content, TextContent) assert "¡Hola, 世界! 👋" == content.text - @pytest.mark.anyio async def test_add_tool_decorator(self): mcp = MCPServer() @@ -102,7 +109,6 @@ def sum(x: int, y: int) -> int: # pragma: no cover assert len(mcp._tool_manager.list_tools()) == 1 - @pytest.mark.anyio async def test_add_tool_decorator_incorrect_usage(self): mcp = MCPServer() @@ -112,7 +118,6 @@ async def test_add_tool_decorator_incorrect_usage(self): def sum(x: int, y: int) -> int: # pragma: no cover return x + y - @pytest.mark.anyio async def test_add_resource_decorator(self): mcp = MCPServer() @@ -122,7 +127,6 @@ def get_data(x: str) -> str: # pragma: no cover assert len(mcp._resource_manager._templates) == 1 - @pytest.mark.anyio async def test_add_resource_decorator_incorrect_usage(self): mcp = MCPServer() @@ -219,14 +223,12 @@ def mixed_content_tool_fn() -> list[ContentBlock]: class TestServerTools: - @pytest.mark.anyio async def test_add_tool(self): mcp = MCPServer() mcp.add_tool(tool_fn) mcp.add_tool(tool_fn) assert len(mcp._tool_manager.list_tools()) == 1 - @pytest.mark.anyio async def test_list_tools(self): mcp = MCPServer() mcp.add_tool(tool_fn) @@ -234,7 +236,6 @@ async def test_list_tools(self): tools = await client.list_tools() assert len(tools.tools) == 1 - @pytest.mark.anyio async def test_call_tool(self): mcp = MCPServer() mcp.add_tool(tool_fn) @@ -243,7 +244,6 @@ async def test_call_tool(self): assert not hasattr(result, "error") assert len(result.content) > 0 - @pytest.mark.anyio async def test_tool_exception_handling(self): mcp = MCPServer() mcp.add_tool(error_tool_fn) @@ -255,7 +255,6 @@ async def test_tool_exception_handling(self): assert "Test error" in content.text assert result.is_error is True - @pytest.mark.anyio async def test_tool_error_handling(self): mcp = MCPServer() mcp.add_tool(error_tool_fn) @@ -267,7 +266,6 @@ async def test_tool_error_handling(self): assert "Test error" in content.text assert result.is_error is True - @pytest.mark.anyio async def test_tool_error_details(self): """Test that exception details are properly formatted in the response""" mcp = MCPServer() @@ -280,7 +278,6 @@ async def test_tool_error_details(self): assert "Test error" in content.text assert result.is_error is True - @pytest.mark.anyio async def test_tool_return_value_conversion(self): mcp = MCPServer() mcp.add_tool(tool_fn) @@ -294,7 +291,6 @@ async def test_tool_return_value_conversion(self): assert result.structured_content is not None assert result.structured_content == {"result": 3} - @pytest.mark.anyio async def test_tool_image_helper(self, tmp_path: Path): # Create a test image image_path = tmp_path / "test.png" @@ -315,7 +311,6 @@ async def test_tool_image_helper(self, tmp_path: Path): # Check structured content - Image return type should NOT have structured output assert result.structured_content is None - @pytest.mark.anyio async def test_tool_audio_helper(self, tmp_path: Path): # Create a test audio audio_path = tmp_path / "test.wav" @@ -348,7 +343,6 @@ async def test_tool_audio_helper(self, tmp_path: Path): ("test.unknown", "application/octet-stream"), # Unknown extension fallback ], ) - @pytest.mark.anyio async def test_tool_audio_suffix_detection(self, tmp_path: Path, filename: str, expected_mime_type: str): """Test that Audio helper correctly detects MIME types from file suffixes""" mcp = MCPServer() @@ -369,7 +363,6 @@ async def test_tool_audio_suffix_detection(self, tmp_path: Path, filename: str, decoded = base64.b64decode(content.data) assert decoded == b"fake audio data" - @pytest.mark.anyio async def test_tool_mixed_content(self): mcp = MCPServer() mcp.add_tool(mixed_content_tool_fn) @@ -400,7 +393,6 @@ async def test_tool_mixed_content(self): for key, value in expected.items(): assert structured_result[i][key] == value - @pytest.mark.anyio async def test_tool_mixed_list_with_audio_and_image(self, tmp_path: Path): """Test that lists containing Image objects and other types are handled correctly""" @@ -453,7 +445,6 @@ def mixed_list_fn() -> list: # type: ignore # Check structured content - untyped list with Image objects should NOT have structured output assert result.structured_content is None - @pytest.mark.anyio async def test_tool_structured_output_basemodel(self): """Test tool with structured output returning BaseModel""" @@ -488,7 +479,6 @@ def get_user(user_id: int) -> UserOutput: assert isinstance(result.content[0], TextContent) assert '"name": "John Doe"' in result.content[0].text - @pytest.mark.anyio async def test_tool_structured_output_primitive(self): """Test tool with structured output returning primitive type""" @@ -515,7 +505,6 @@ def calculate_sum(a: int, b: int) -> int: assert result.structured_content is not None assert result.structured_content == {"result": 12} - @pytest.mark.anyio async def test_tool_structured_output_list(self): """Test tool with structured output returning list""" @@ -532,7 +521,6 @@ def get_numbers() -> list[int]: assert result.structured_content is not None assert result.structured_content == {"result": [1, 2, 3, 4, 5]} - @pytest.mark.anyio async def test_tool_structured_output_server_side_validation_error(self): """Test that server-side validation errors are handled properly""" @@ -549,7 +537,6 @@ def get_numbers() -> list[int]: assert len(result.content) == 1 assert isinstance(result.content[0], TextContent) - @pytest.mark.anyio async def test_tool_structured_output_dict_str_any(self): """Test tool with dict[str, Any] structured output""" @@ -591,7 +578,6 @@ def get_metadata() -> dict[str, Any]: } assert result.structured_content == expected - @pytest.mark.anyio async def test_tool_structured_output_dict_str_typed(self): """Test tool with dict[str, T] structured output for specific T""" @@ -615,7 +601,6 @@ def get_settings() -> dict[str, str]: assert result.is_error is False assert result.structured_content == {"theme": "dark", "language": "en", "timezone": "UTC"} - @pytest.mark.anyio async def test_remove_tool(self): """Test removing a tool from the server.""" mcp = MCPServer() @@ -630,7 +615,6 @@ async def test_remove_tool(self): # Verify tool is removed assert len(mcp._tool_manager.list_tools()) == 0 - @pytest.mark.anyio async def test_remove_nonexistent_tool(self): """Test that removing a non-existent tool raises ToolError.""" mcp = MCPServer() @@ -638,7 +622,6 @@ async def test_remove_nonexistent_tool(self): with pytest.raises(ToolError, match="Unknown tool: nonexistent"): mcp.remove_tool("nonexistent") - @pytest.mark.anyio async def test_remove_tool_and_list(self): """Test that a removed tool doesn't appear in list_tools.""" mcp = MCPServer() @@ -662,7 +645,6 @@ async def test_remove_tool_and_list(self): assert len(tools.tools) == 1 assert tools.tools[0].name == "error_tool_fn" - @pytest.mark.anyio async def test_remove_tool_and_call(self): """Test that calling a removed tool fails appropriately.""" mcp = MCPServer() @@ -689,7 +671,6 @@ async def test_remove_tool_and_call(self): class TestServerResources: - @pytest.mark.anyio async def test_text_resource(self): mcp = MCPServer() @@ -699,16 +680,32 @@ def get_text(): resource = FunctionResource(uri="resource://test", name="test", fn=get_text) mcp.add_resource(resource) - async with Client(mcp) as client: - result = await client.read_resource("resource://test") - async with Client(mcp) as client: result = await client.read_resource("resource://test") assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Hello, world!" - @pytest.mark.anyio + async def test_read_unknown_resource(self): + """Test that reading an unknown resource raises MCPError.""" + mcp = MCPServer() + + async with Client(mcp) as client: + with pytest.raises(MCPError, match="Unknown resource: unknown://missing"): + await client.read_resource("unknown://missing") + + async def test_read_resource_error(self): + """Test that resource read errors are properly wrapped in MCPError.""" + mcp = MCPServer() + + @mcp.resource("resource://failing") + def failing_resource(): + raise ValueError("Resource read failed") + + async with Client(mcp) as client: + with pytest.raises(MCPError, match="Error reading resource resource://failing"): + await client.read_resource("resource://failing") + async def test_binary_resource(self): mcp = MCPServer() @@ -723,16 +720,12 @@ def get_binary(): ) mcp.add_resource(resource) - async with Client(mcp) as client: - result = await client.read_resource("resource://binary") - async with Client(mcp) as client: result = await client.read_resource("resource://binary") assert isinstance(result.contents[0], BlobResourceContents) assert result.contents[0].blob == base64.b64encode(b"Binary data").decode() - @pytest.mark.anyio async def test_file_resource_text(self, tmp_path: Path): mcp = MCPServer() @@ -743,16 +736,12 @@ async def test_file_resource_text(self, tmp_path: Path): resource = FileResource(uri="file://test.txt", name="test.txt", path=text_file) mcp.add_resource(resource) - async with Client(mcp) as client: - result = await client.read_resource("file://test.txt") - async with Client(mcp) as client: result = await client.read_resource("file://test.txt") assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Hello from file!" - @pytest.mark.anyio async def test_file_resource_binary(self, tmp_path: Path): mcp = MCPServer() @@ -768,16 +757,12 @@ async def test_file_resource_binary(self, tmp_path: Path): ) mcp.add_resource(resource) - async with Client(mcp) as client: - result = await client.read_resource("file://test.bin") - async with Client(mcp) as client: result = await client.read_resource("file://test.bin") assert isinstance(result.contents[0], BlobResourceContents) assert result.contents[0].blob == base64.b64encode(b"Binary file data").decode() - @pytest.mark.anyio async def test_function_resource(self): mcp = MCPServer() @@ -797,7 +782,6 @@ def get_data() -> str: # pragma: no cover class TestServerResourceTemplates: - @pytest.mark.anyio async def test_resource_with_params(self): """Test that a resource with function parameters raises an error if the URI parameters don't match""" @@ -809,7 +793,6 @@ async def test_resource_with_params(self): def get_data_fn(param: str) -> str: # pragma: no cover return f"Data: {param}" - @pytest.mark.anyio async def test_resource_with_uri_params(self): """Test that a resource with URI parameters is automatically a template""" mcp = MCPServer() @@ -820,7 +803,6 @@ async def test_resource_with_uri_params(self): def get_data() -> str: # pragma: no cover return "Data" - @pytest.mark.anyio async def test_resource_with_untyped_params(self): """Test that a resource with untyped parameters raises an error""" mcp = MCPServer() @@ -829,7 +811,6 @@ async def test_resource_with_untyped_params(self): def get_data(param) -> str: # type: ignore # pragma: no cover return "Data" - @pytest.mark.anyio async def test_resource_matching_params(self): """Test that a resource with matching URI and function parameters works""" mcp = MCPServer() @@ -838,16 +819,12 @@ async def test_resource_matching_params(self): def get_data(name: str) -> str: return f"Data for {name}" - async with Client(mcp) as client: - result = await client.read_resource("resource://test/data") - async with Client(mcp) as client: result = await client.read_resource("resource://test/data") assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Data for test" - @pytest.mark.anyio async def test_resource_mismatched_params(self): """Test that mismatched parameters raise an error""" mcp = MCPServer() @@ -858,7 +835,6 @@ async def test_resource_mismatched_params(self): def get_data(user: str) -> str: # pragma: no cover return f"Data for {user}" - @pytest.mark.anyio async def test_resource_multiple_params(self): """Test that multiple parameters work correctly""" mcp = MCPServer() @@ -867,16 +843,12 @@ async def test_resource_multiple_params(self): def get_data(org: str, repo: str) -> str: return f"Data for {org}/{repo}" - async with Client(mcp) as client: - result = await client.read_resource("resource://cursor/myrepo/data") - async with Client(mcp) as client: result = await client.read_resource("resource://cursor/myrepo/data") assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Data for cursor/myrepo" - @pytest.mark.anyio async def test_resource_multiple_mismatched_params(self): """Test that mismatched parameters raise an error""" mcp = MCPServer() @@ -894,16 +866,12 @@ def get_data_mismatched(org: str, repo_2: str) -> str: # pragma: no cover def get_static_data() -> str: return "Static data" - async with Client(mcp) as client: - result = await client.read_resource("resource://static") - async with Client(mcp) as client: result = await client.read_resource("resource://static") assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Static data" - @pytest.mark.anyio async def test_template_to_resource_conversion(self): """Test that templates are properly converted to resources when accessed""" mcp = MCPServer() @@ -922,7 +890,6 @@ def get_data(name: str) -> str: result = await resource.read() assert result == "Data for test" - @pytest.mark.anyio async def test_resource_template_includes_mime_type(self): """Test that list resource templates includes the correct mimeType.""" mcp = MCPServer() @@ -932,20 +899,21 @@ def get_csv(user: str) -> str: return f"csv for {user}" templates = await mcp.list_resource_templates() - assert len(templates) == 1 - template = templates[0] - - assert hasattr(template, "mime_type") - assert template.mime_type == "text/csv" - - async with Client(mcp) as client: - result = await client.read_resource("resource://bob/csv") + assert templates == snapshot( + [ + ResourceTemplate( + name="get_csv", uri_template="resource://{user}/csv", description="", mime_type="text/csv" + ) + ] + ) async with Client(mcp) as client: result = await client.read_resource("resource://bob/csv") - - assert isinstance(result.contents[0], TextResourceContents) - assert result.contents[0].text == "csv for bob" + assert result == snapshot( + ReadResourceResult( + contents=[TextResourceContents(uri="resource://bob/csv", mime_type="text/csv", text="csv for bob")] + ) + ) class TestServerResourceMetadata: @@ -955,74 +923,76 @@ class TestServerResourceMetadata: Note: read_resource does NOT pass meta to protocol response (lowlevel/server.py only extracts content/mime_type). """ - @pytest.mark.anyio async def test_resource_decorator_with_metadata(self): """Test that @resource decorator accepts and passes meta parameter.""" # Tests static resource flow: decorator -> FunctionResource -> list_resources (server.py:544,635,361) mcp = MCPServer() - metadata = {"ui": {"component": "file-viewer"}, "priority": "high"} - - @mcp.resource("resource://config", meta=metadata) - def get_config() -> str: # pragma: no cover - return '{"debug": false}' + @mcp.resource("resource://config", meta={"ui": {"component": "file-viewer"}, "priority": "high"}) + def get_config() -> str: ... # pragma: no branch resources = await mcp.list_resources() - assert len(resources) == 1 - assert resources[0].meta is not None - assert resources[0].meta == metadata - assert resources[0].meta["ui"]["component"] == "file-viewer" - assert resources[0].meta["priority"] == "high" + assert resources == snapshot( + [ + Resource( + name="get_config", + uri="resource://config", + description="", + mime_type="text/plain", + meta={"ui": {"component": "file-viewer"}, "priority": "high"}, # type: ignore[reportCallIssue] + ) + ] + ) - @pytest.mark.anyio async def test_resource_template_decorator_with_metadata(self): """Test that @resource decorator passes meta to templates.""" # Tests template resource flow: decorator -> add_template() -> list_resource_templates (server.py:544,622,377) mcp = MCPServer() - metadata = {"api_version": "v2", "deprecated": False} - - @mcp.resource("resource://{city}/weather", meta=metadata) - def get_weather(city: str) -> str: # pragma: no cover - return f"Weather for {city}" + @mcp.resource("resource://{city}/weather", meta={"api_version": "v2", "deprecated": False}) + def get_weather(city: str) -> str: ... # pragma: no branch templates = await mcp.list_resource_templates() - assert len(templates) == 1 - assert templates[0].meta is not None - assert templates[0].meta == metadata - assert templates[0].meta["api_version"] == "v2" + assert templates == snapshot( + [ + ResourceTemplate( + name="get_weather", + uri_template="resource://{city}/weather", + description="", + mime_type="text/plain", + meta={"api_version": "v2", "deprecated": False}, # type: ignore[reportCallIssue] + ) + ] + ) - @pytest.mark.anyio async def test_read_resource_returns_meta(self): """Test that read_resource includes meta in response.""" # Tests end-to-end: Resource.meta -> ReadResourceContents.meta -> protocol _meta (lowlevel/server.py:341,371) mcp = MCPServer() - metadata = {"version": "1.0", "category": "config"} - - @mcp.resource("resource://data", meta=metadata) + @mcp.resource("resource://data", meta={"version": "1.0", "category": "config"}) def get_data() -> str: return "test data" async with Client(mcp) as client: result = await client.read_resource("resource://data") - - async with Client(mcp) as client: - result = await client.read_resource("resource://data") - - # Verify content and metadata in protocol response - assert isinstance(result.contents[0], TextResourceContents) - assert result.contents[0].text == "test data" - assert result.contents[0].meta is not None - assert result.contents[0].meta == metadata - assert result.contents[0].meta["version"] == "1.0" - assert result.contents[0].meta["category"] == "config" + assert result == snapshot( + ReadResourceResult( + contents=[ + TextResourceContents( + uri="resource://data", + mime_type="text/plain", + meta={"version": "1.0", "category": "config"}, # type: ignore[reportUnknownMemberType] + text="test data", + ) + ] + ) + ) class TestContextInjection: """Test context injection in tools, resources, and prompts.""" - @pytest.mark.anyio async def test_context_detection(self): """Test that context parameters are properly detected.""" mcp = MCPServer() @@ -1033,7 +1003,6 @@ def tool_with_context(x: int, ctx: Context[ServerSession, None]) -> str: # prag tool = mcp._tool_manager.add_tool(tool_with_context) assert tool.context_kwarg == "ctx" - @pytest.mark.anyio async def test_context_injection(self): """Test that context is properly injected into tool calls.""" mcp = MCPServer() @@ -1051,7 +1020,6 @@ def tool_with_context(x: int, ctx: Context[ServerSession, None]) -> str: assert "Request" in content.text assert "42" in content.text - @pytest.mark.anyio async def test_async_context(self): """Test that context works in async functions.""" mcp = MCPServer() @@ -1069,7 +1037,6 @@ async def async_tool(x: int, ctx: Context[ServerSession, None]) -> str: assert "Async request" in content.text assert "42" in content.text - @pytest.mark.anyio async def test_context_logging(self): """Test that context logging methods work.""" mcp = MCPServer() @@ -1092,32 +1059,11 @@ async def logging_tool(msg: str, ctx: Context[ServerSession, None]) -> str: assert "Logged messages for test" in content.text assert mock_log.call_count == 4 - mock_log.assert_any_call( - level="debug", - data="Debug message", - logger=None, - related_request_id="1", - ) - mock_log.assert_any_call( - level="info", - data="Info message", - logger=None, - related_request_id="1", - ) - mock_log.assert_any_call( - level="warning", - data="Warning message", - logger=None, - related_request_id="1", - ) - mock_log.assert_any_call( - level="error", - data="Error message", - logger=None, - related_request_id="1", - ) + mock_log.assert_any_call(level="debug", data="Debug message", logger=None, related_request_id="1") + mock_log.assert_any_call(level="info", data="Info message", logger=None, related_request_id="1") + mock_log.assert_any_call(level="warning", data="Warning message", logger=None, related_request_id="1") + mock_log.assert_any_call(level="error", data="Error message", logger=None, related_request_id="1") - @pytest.mark.anyio async def test_optional_context(self): """Test that context is optional.""" mcp = MCPServer() @@ -1133,7 +1079,6 @@ def no_context(x: int) -> int: assert isinstance(content, TextContent) assert content.text == "42" - @pytest.mark.anyio async def test_context_resource_access(self): """Test that context can access resources.""" mcp = MCPServer() @@ -1157,7 +1102,6 @@ async def tool_with_resource(ctx: Context[ServerSession, None]) -> str: assert isinstance(content, TextContent) assert "Read resource: resource data" in content.text - @pytest.mark.anyio async def test_resource_with_context(self): """Test that resources can receive context parameter.""" mcp = MCPServer() @@ -1175,11 +1119,6 @@ def resource_with_context(name: str, ctx: Context[ServerSession, None]) -> str: assert hasattr(template, "context_kwarg") assert template.context_kwarg == "ctx" - # Test via client - - async with Client(mcp) as client: - result = await client.read_resource("resource://context/test") - async with Client(mcp) as client: result = await client.read_resource("resource://context/test") @@ -1189,7 +1128,6 @@ def resource_with_context(name: str, ctx: Context[ServerSession, None]) -> str: # Should have either request_id or indication that context was injected assert "Resource test - context injected" == content.text - @pytest.mark.anyio async def test_resource_without_context(self): """Test that resources without context work normally.""" mcp = MCPServer() @@ -1205,20 +1143,18 @@ def resource_no_context(name: str) -> str: template = templates[0] assert template.context_kwarg is None - # Test via client - - async with Client(mcp) as client: - result = await client.read_resource("resource://nocontext/test") - async with Client(mcp) as client: result = await client.read_resource("resource://nocontext/test") + assert result == snapshot( + ReadResourceResult( + contents=[ + TextResourceContents( + uri="resource://nocontext/test", mime_type="text/plain", text="Resource test works" + ) + ] + ) + ) - assert len(result.contents) == 1 - content = result.contents[0] - assert isinstance(content, TextResourceContents) - assert content.text == "Resource test works" - - @pytest.mark.anyio async def test_resource_context_custom_name(self): """Test resource context with custom parameter name.""" mcp = MCPServer() @@ -1235,20 +1171,18 @@ def resource_custom_ctx(id: str, my_ctx: Context[ServerSession, None]) -> str: template = templates[0] assert template.context_kwarg == "my_ctx" - # Test via client - - async with Client(mcp) as client: - result = await client.read_resource("resource://custom/123") - async with Client(mcp) as client: result = await client.read_resource("resource://custom/123") + assert result == snapshot( + ReadResourceResult( + contents=[ + TextResourceContents( + uri="resource://custom/123", mime_type="text/plain", text="Resource 123 with context" + ) + ] + ) + ) - assert len(result.contents) == 1 - content = result.contents[0] - assert isinstance(content, TextResourceContents) - assert "Resource 123 with context" in content.text - - @pytest.mark.anyio async def test_prompt_with_context(self): """Test that prompts can receive context parameter.""" mcp = MCPServer() @@ -1259,10 +1193,6 @@ def prompt_with_context(text: str, ctx: Context[ServerSession, None]) -> str: assert ctx is not None return f"Prompt '{text}' - context injected" - # Check if prompt has context parameter detection - prompts = mcp._prompt_manager.list_prompts() - assert len(prompts) == 1 - # Test via client async with Client(mcp) as client: # Try calling without passing ctx explicitly @@ -1273,7 +1203,6 @@ def prompt_with_context(text: str, ctx: Context[ServerSession, None]) -> str: assert isinstance(content, TextContent) assert "Prompt 'test' - context injected" in content.text - @pytest.mark.anyio async def test_prompt_without_context(self): """Test that prompts without context work normally.""" mcp = MCPServer() @@ -1296,7 +1225,6 @@ def prompt_no_context(text: str) -> str: class TestServerPrompts: """Test prompt functionality in MCPServer server.""" - @pytest.mark.anyio async def test_prompt_decorator(self): """Test that the prompt decorator registers prompts correctly.""" mcp = MCPServer() @@ -1313,7 +1241,6 @@ def fn() -> str: assert isinstance(content[0].content, TextContent) assert content[0].content.text == "Hello, world!" - @pytest.mark.anyio async def test_prompt_decorator_with_name(self): """Test prompt decorator with custom name.""" mcp = MCPServer() @@ -1329,7 +1256,6 @@ def fn() -> str: assert isinstance(content[0].content, TextContent) assert content[0].content.text == "Hello, world!" - @pytest.mark.anyio async def test_prompt_decorator_with_description(self): """Test prompt decorator with custom description.""" mcp = MCPServer() @@ -1351,32 +1277,32 @@ def test_prompt_decorator_error(self): with pytest.raises(TypeError, match="decorator was used incorrectly"): @mcp.prompt # type: ignore - def fn() -> str: # pragma: no cover - return "Hello, world!" + def fn() -> str: ... # pragma: no branch - @pytest.mark.anyio async def test_list_prompts(self): """Test listing prompts through MCP protocol.""" mcp = MCPServer() @mcp.prompt() - def fn(name: str, optional: str = "default") -> str: # pragma: no cover - return f"Hello, {name}!" + def fn(name: str, optional: str = "default") -> str: ... # pragma: no branch async with Client(mcp) as client: result = await client.list_prompts() - assert result.prompts is not None - assert len(result.prompts) == 1 - prompt = result.prompts[0] - assert prompt.name == "fn" - assert prompt.arguments is not None - assert len(prompt.arguments) == 2 - assert prompt.arguments[0].name == "name" - assert prompt.arguments[0].required is True - assert prompt.arguments[1].name == "optional" - assert prompt.arguments[1].required is False - - @pytest.mark.anyio + assert result == snapshot( + ListPromptsResult( + prompts=[ + Prompt( + name="fn", + description="", + arguments=[ + PromptArgument(name="name", required=True), + PromptArgument(name="optional", required=False), + ], + ) + ] + ) + ) + async def test_get_prompt(self): """Test getting a prompt through MCP protocol.""" mcp = MCPServer() @@ -1387,14 +1313,13 @@ def fn(name: str) -> str: async with Client(mcp) as client: result = await client.get_prompt("fn", {"name": "World"}) - assert len(result.messages) == 1 - message = result.messages[0] - assert message.role == "user" - content = message.content - assert isinstance(content, TextContent) - assert content.text == "Hello, World!" + assert result == snapshot( + GetPromptResult( + description="", + messages=[PromptMessage(role="user", content=TextContent(text="Hello, World!"))], + ) + ) - @pytest.mark.anyio async def test_get_prompt_with_description(self): """Test getting a prompt through MCP protocol.""" mcp = MCPServer() @@ -1407,20 +1332,6 @@ def fn(name: str) -> str: result = await client.get_prompt("fn", {"name": "World"}) assert result.description == "Test prompt description" - @pytest.mark.anyio - async def test_get_prompt_without_description(self): - """Test getting a prompt without description returns empty string.""" - mcp = MCPServer() - - @mcp.prompt() - def fn(name: str) -> str: - return f"Hello, {name}!" - - async with Client(mcp) as client: - result = await client.get_prompt("fn", {"name": "World"}) - assert result.description == "" - - @pytest.mark.anyio async def test_get_prompt_with_docstring_description(self): """Test prompt uses docstring as description when not explicitly provided.""" mcp = MCPServer() @@ -1432,9 +1343,13 @@ def fn(name: str) -> str: async with Client(mcp) as client: result = await client.get_prompt("fn", {"name": "World"}) - assert result.description == "This is the function docstring." + assert result == snapshot( + GetPromptResult( + description="This is the function docstring.", + messages=[PromptMessage(role="user", content=TextContent(text="Hello, World!"))], + ) + ) - @pytest.mark.anyio async def test_get_prompt_with_resource(self): """Test getting a prompt that returns resource content.""" mcp = MCPServer() @@ -1444,42 +1359,42 @@ def fn() -> Message: return UserMessage( content=EmbeddedResource( type="resource", - resource=TextResourceContents( - uri="file://file.txt", - text="File contents", - mime_type="text/plain", - ), + resource=TextResourceContents(uri="file://file.txt", text="File contents", mime_type="text/plain"), ) ) async with Client(mcp) as client: result = await client.get_prompt("fn") - assert len(result.messages) == 1 - message = result.messages[0] - assert message.role == "user" - content = message.content - assert isinstance(content, EmbeddedResource) - resource = content.resource - assert isinstance(resource, TextResourceContents) - assert resource.text == "File contents" - assert resource.mime_type == "text/plain" + assert result == snapshot( + GetPromptResult( + description="", + messages=[ + PromptMessage( + role="user", + content=EmbeddedResource( + resource=TextResourceContents( + uri="file://file.txt", mime_type="text/plain", text="File contents" + ) + ), + ) + ], + ) + ) - @pytest.mark.anyio async def test_get_unknown_prompt(self): """Test error when getting unknown prompt.""" mcp = MCPServer() + async with Client(mcp) as client: with pytest.raises(MCPError, match="Unknown prompt"): await client.get_prompt("unknown") - @pytest.mark.anyio async def test_get_prompt_missing_args(self): """Test error when required arguments are missing.""" mcp = MCPServer() @mcp.prompt() - def prompt_fn(name: str) -> str: # pragma: no cover - return f"Hello, {name}!" + def prompt_fn(name: str) -> str: ... # pragma: no branch async with Client(mcp) as client: with pytest.raises(MCPError, match="Missing required arguments"):