import base64 from pathlib import Path from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from inline_snapshot import snapshot from pydantic import BaseModel from starlette.applications import Starlette from starlette.routing import Mount, Route from mcp.client import Client from mcp.server.context import ServerRequestContext from mcp.server.experimental.request_context import Experimental from mcp.server.mcpserver import Context, MCPServer from mcp.server.mcpserver.exceptions import ToolError from mcp.server.mcpserver.prompts.base import Message, UserMessage from mcp.server.mcpserver.resources import FileResource, FunctionResource from mcp.server.mcpserver.utilities.types import Audio, Image from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.exceptions import MCPError from mcp.types import ( AudioContent, BlobResourceContents, Completion, CompletionArgument, CompletionContext, ContentBlock, EmbeddedResource, GetPromptResult, Icon, ImageContent, ListPromptsResult, Prompt, PromptArgument, PromptMessage, PromptReference, ReadResourceResult, Resource, ResourceTemplate, TextContent, TextResourceContents, ) pytestmark = pytest.mark.anyio class TestServer: async def test_create_server(self): mcp = MCPServer( title="MCPServer Server", description="Server description", instructions="Server instructions", website_url="https://example.com/mcp_server", version="1.0", icons=[Icon(src="https://example.com/icon.png", mime_type="image/png", sizes=["48x48", "96x96"])], ) assert mcp.name == "mcp-server" assert mcp.title == "MCPServer Server" assert mcp.description == "Server description" assert mcp.instructions == "Server instructions" assert mcp.website_url == "https://example.com/mcp_server" assert mcp.version == "1.0" assert isinstance(mcp.icons, list) assert len(mcp.icons) == 1 assert mcp.icons[0].src == "https://example.com/icon.png" async def test_sse_app_returns_starlette_app(self): """Test that sse_app returns a Starlette application with correct routes.""" mcp = MCPServer("test") # Use host="0.0.0.0" to avoid auto DNS protection app = mcp.sse_app(host="0.0.0.0") assert isinstance(app, Starlette) # Verify routes exist sse_routes = [r for r in app.routes if isinstance(r, Route)] mount_routes = [r for r in app.routes if isinstance(r, Mount)] assert len(sse_routes) == 1, "Should have one SSE route" assert len(mount_routes) == 1, "Should have one mount route" assert sse_routes[0].path == "/sse" assert mount_routes[0].path == "/messages" async def test_non_ascii_description(self): """Test that MCPServer handles non-ASCII characters in descriptions correctly""" mcp = MCPServer() @mcp.tool(description=("🌟 This tool uses emojis and UTF-8 characters: á é í ó ú ñ 漢字 🎉")) def hello_world(name: str = "世界") -> str: return f"¡Hola, {name}! 👋" async with Client(mcp) as client: tools = await client.list_tools() assert len(tools.tools) == 1 tool = tools.tools[0] assert tool.description is not None assert "🌟" in tool.description assert "漢字" in tool.description assert "🎉" in tool.description result = await client.call_tool("hello_world", {}) assert len(result.content) == 1 content = result.content[0] assert isinstance(content, TextContent) assert "¡Hola, 世界! 👋" == content.text async def test_add_tool_decorator(self): mcp = MCPServer() @mcp.tool() def sum(x: int, y: int) -> int: # pragma: no cover return x + y assert len(mcp._tool_manager.list_tools()) == 1 async def test_add_tool_decorator_incorrect_usage(self): mcp = MCPServer() with pytest.raises(TypeError, match="The @tool decorator was used incorrectly"): @mcp.tool # Missing parentheses #type: ignore def sum(x: int, y: int) -> int: # pragma: no cover return x + y async def test_add_resource_decorator(self): mcp = MCPServer() @mcp.resource("r://{x}") def get_data(x: str) -> str: # pragma: no cover return f"Data: {x}" assert len(mcp._resource_manager._templates) == 1 async def test_add_resource_decorator_incorrect_usage(self): mcp = MCPServer() with pytest.raises(TypeError, match="The @resource decorator was used incorrectly"): @mcp.resource # Missing parentheses #type: ignore def get_data(x: str) -> str: # pragma: no cover return f"Data: {x}" class TestDnsRebindingProtection: """Tests for automatic DNS rebinding protection on localhost. DNS rebinding protection is now configured in sse_app() and streamable_http_app() based on the host parameter passed to those methods. """ def test_auto_enabled_for_127_0_0_1_sse(self): """DNS rebinding protection should auto-enable for host=127.0.0.1 in SSE app.""" mcp = MCPServer() # Call sse_app with host=127.0.0.1 to trigger auto-config # We can't directly inspect the transport_security, but we can verify # the app is created without error app = mcp.sse_app(host="127.0.0.1") assert app is not None def test_auto_enabled_for_127_0_0_1_streamable_http(self): """DNS rebinding protection should auto-enable for host=127.0.0.1 in StreamableHTTP app.""" mcp = MCPServer() app = mcp.streamable_http_app(host="127.0.0.1") assert app is not None def test_auto_enabled_for_localhost_sse(self): """DNS rebinding protection should auto-enable for host=localhost in SSE app.""" mcp = MCPServer() app = mcp.sse_app(host="localhost") assert app is not None def test_auto_enabled_for_ipv6_localhost_sse(self): """DNS rebinding protection should auto-enable for host=::1 (IPv6 localhost) in SSE app.""" mcp = MCPServer() app = mcp.sse_app(host="::1") assert app is not None def test_not_auto_enabled_for_other_hosts_sse(self): """DNS rebinding protection should NOT auto-enable for other hosts in SSE app.""" mcp = MCPServer() app = mcp.sse_app(host="0.0.0.0") assert app is not None def test_explicit_settings_not_overridden_sse(self): """Explicit transport_security settings should not be overridden in SSE app.""" custom_settings = TransportSecuritySettings( enable_dns_rebinding_protection=False, ) mcp = MCPServer() # Explicit transport_security passed to sse_app should be used as-is app = mcp.sse_app(host="127.0.0.1", transport_security=custom_settings) assert app is not None def test_explicit_settings_not_overridden_streamable_http(self): """Explicit transport_security settings should not be overridden in StreamableHTTP app.""" custom_settings = TransportSecuritySettings( enable_dns_rebinding_protection=False, ) mcp = MCPServer() # Explicit transport_security passed to streamable_http_app should be used as-is app = mcp.streamable_http_app(host="127.0.0.1", transport_security=custom_settings) assert app is not None def tool_fn(x: int, y: int) -> int: return x + y def error_tool_fn() -> None: raise ValueError("Test error") def image_tool_fn(path: str) -> Image: return Image(path) def audio_tool_fn(path: str) -> Audio: return Audio(path) def mixed_content_tool_fn() -> list[ContentBlock]: return [ TextContent(type="text", text="Hello"), ImageContent(type="image", data="abc", mime_type="image/png"), AudioContent(type="audio", data="def", mime_type="audio/wav"), ] class TestServerTools: 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 async def test_list_tools(self): mcp = MCPServer() mcp.add_tool(tool_fn) async with Client(mcp) as client: tools = await client.list_tools() assert len(tools.tools) == 1 async def test_call_tool(self): mcp = MCPServer() mcp.add_tool(tool_fn) async with Client(mcp) as client: result = await client.call_tool("my_tool", {"arg1": "value"}) assert not hasattr(result, "error") assert len(result.content) > 0 async def test_tool_exception_handling(self): mcp = MCPServer() mcp.add_tool(error_tool_fn) async with Client(mcp) as client: result = await client.call_tool("error_tool_fn", {}) assert len(result.content) == 1 content = result.content[0] assert isinstance(content, TextContent) assert "Test error" in content.text assert result.is_error is True async def test_tool_error_handling(self): mcp = MCPServer() mcp.add_tool(error_tool_fn) async with Client(mcp) as client: result = await client.call_tool("error_tool_fn", {}) assert len(result.content) == 1 content = result.content[0] assert isinstance(content, TextContent) assert "Test error" in content.text assert result.is_error is True async def test_tool_error_details(self): """Test that exception details are properly formatted in the response""" mcp = MCPServer() mcp.add_tool(error_tool_fn) async with Client(mcp) as client: result = await client.call_tool("error_tool_fn", {}) content = result.content[0] assert isinstance(content, TextContent) assert isinstance(content.text, str) assert "Test error" in content.text assert result.is_error is True async def test_tool_return_value_conversion(self): mcp = MCPServer() mcp.add_tool(tool_fn) async with Client(mcp) as client: result = await client.call_tool("tool_fn", {"x": 1, "y": 2}) assert len(result.content) == 1 content = result.content[0] assert isinstance(content, TextContent) assert content.text == "3" # Check structured content - int return type should have structured output assert result.structured_content is not None assert result.structured_content == {"result": 3} async def test_tool_image_helper(self, tmp_path: Path): # Create a test image image_path = tmp_path / "test.png" image_path.write_bytes(b"fake png data") mcp = MCPServer() mcp.add_tool(image_tool_fn) async with Client(mcp) as client: result = await client.call_tool("image_tool_fn", {"path": str(image_path)}) assert len(result.content) == 1 content = result.content[0] assert isinstance(content, ImageContent) assert content.type == "image" assert content.mime_type == "image/png" # Verify base64 encoding decoded = base64.b64decode(content.data) assert decoded == b"fake png data" # Check structured content - Image return type should NOT have structured output assert result.structured_content is None async def test_tool_audio_helper(self, tmp_path: Path): # Create a test audio audio_path = tmp_path / "test.wav" audio_path.write_bytes(b"fake wav data") mcp = MCPServer() mcp.add_tool(audio_tool_fn) async with Client(mcp) as client: result = await client.call_tool("audio_tool_fn", {"path": str(audio_path)}) assert len(result.content) == 1 content = result.content[0] assert isinstance(content, AudioContent) assert content.type == "audio" assert content.mime_type == "audio/wav" # Verify base64 encoding decoded = base64.b64decode(content.data) assert decoded == b"fake wav data" # Check structured content - Image return type should NOT have structured output assert result.structured_content is None @pytest.mark.parametrize( "filename,expected_mime_type", [ ("test.wav", "audio/wav"), ("test.mp3", "audio/mpeg"), ("test.ogg", "audio/ogg"), ("test.flac", "audio/flac"), ("test.aac", "audio/aac"), ("test.m4a", "audio/mp4"), ("test.unknown", "application/octet-stream"), # Unknown extension fallback ], ) 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() mcp.add_tool(audio_tool_fn) # Create a test audio file with the specific extension audio_path = tmp_path / filename audio_path.write_bytes(b"fake audio data") async with Client(mcp) as client: result = await client.call_tool("audio_tool_fn", {"path": str(audio_path)}) assert len(result.content) == 1 content = result.content[0] assert isinstance(content, AudioContent) assert content.type == "audio" assert content.mime_type == expected_mime_type # Verify base64 encoding decoded = base64.b64decode(content.data) assert decoded == b"fake audio data" async def test_tool_mixed_content(self): mcp = MCPServer() mcp.add_tool(mixed_content_tool_fn) async with Client(mcp) as client: result = await client.call_tool("mixed_content_tool_fn", {}) assert len(result.content) == 3 content1, content2, content3 = result.content assert isinstance(content1, TextContent) assert content1.text == "Hello" assert isinstance(content2, ImageContent) assert content2.mime_type == "image/png" assert content2.data == "abc" assert isinstance(content3, AudioContent) assert content3.mime_type == "audio/wav" assert content3.data == "def" assert result.structured_content is not None assert "result" in result.structured_content structured_result = result.structured_content["result"] assert len(structured_result) == 3 expected_content = [ {"type": "text", "text": "Hello"}, {"type": "image", "data": "abc", "mimeType": "image/png"}, {"type": "audio", "data": "def", "mimeType": "audio/wav"}, ] for i, expected in enumerate(expected_content): for key, value in expected.items(): assert structured_result[i][key] == value 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""" # Create a test image image_path = tmp_path / "test.png" image_path.write_bytes(b"test image data") # Create a test audio audio_path = tmp_path / "test.wav" audio_path.write_bytes(b"test audio data") # TODO(Marcelo): It seems if we add the proper type hint, it generates an invalid JSON schema. # We need to fix this. def mixed_list_fn() -> list: # type: ignore return [ # type: ignore "text message", Image(image_path), Audio(audio_path), {"key": "value"}, TextContent(type="text", text="direct content"), ] mcp = MCPServer() mcp.add_tool(mixed_list_fn) # type: ignore async with Client(mcp) as client: result = await client.call_tool("mixed_list_fn", {}) assert len(result.content) == 5 # Check text conversion content1 = result.content[0] assert isinstance(content1, TextContent) assert content1.text == "text message" # Check image conversion content2 = result.content[1] assert isinstance(content2, ImageContent) assert content2.mime_type == "image/png" assert base64.b64decode(content2.data) == b"test image data" # Check audio conversion content3 = result.content[2] assert isinstance(content3, AudioContent) assert content3.mime_type == "audio/wav" assert base64.b64decode(content3.data) == b"test audio data" # Check dict conversion content4 = result.content[3] assert isinstance(content4, TextContent) assert '"key": "value"' in content4.text # Check direct TextContent content5 = result.content[4] assert isinstance(content5, TextContent) assert content5.text == "direct content" # Check structured content - untyped list with Image objects should NOT have structured output assert result.structured_content is None async def test_tool_structured_output_basemodel(self): """Test tool with structured output returning BaseModel""" class UserOutput(BaseModel): name: str age: int active: bool = True def get_user(user_id: int) -> UserOutput: """Get user by ID""" return UserOutput(name="John Doe", age=30) mcp = MCPServer() mcp.add_tool(get_user) async with Client(mcp) as client: # Check that the tool has outputSchema tools = await client.list_tools() tool = next(t for t in tools.tools if t.name == "get_user") assert tool.output_schema is not None assert tool.output_schema["type"] == "object" assert "name" in tool.output_schema["properties"] assert "age" in tool.output_schema["properties"] # Call the tool and check structured output result = await client.call_tool("get_user", {"user_id": 123}) assert result.is_error is False assert result.structured_content is not None assert result.structured_content == {"name": "John Doe", "age": 30, "active": True} # Content should be JSON serialized version assert len(result.content) == 1 assert isinstance(result.content[0], TextContent) assert '"name": "John Doe"' in result.content[0].text async def test_tool_structured_output_primitive(self): """Test tool with structured output returning primitive type""" def calculate_sum(a: int, b: int) -> int: """Add two numbers""" return a + b mcp = MCPServer() mcp.add_tool(calculate_sum) async with Client(mcp) as client: # Check that the tool has outputSchema tools = await client.list_tools() tool = next(t for t in tools.tools if t.name == "calculate_sum") assert tool.output_schema is not None # Primitive types are wrapped assert tool.output_schema["type"] == "object" assert "result" in tool.output_schema["properties"] assert tool.output_schema["properties"]["result"]["type"] == "integer" # Call the tool result = await client.call_tool("calculate_sum", {"a": 5, "b": 7}) assert result.is_error is False assert result.structured_content is not None assert result.structured_content == {"result": 12} async def test_tool_structured_output_list(self): """Test tool with structured output returning list""" def get_numbers() -> list[int]: """Get a list of numbers""" return [1, 2, 3, 4, 5] mcp = MCPServer() mcp.add_tool(get_numbers) async with Client(mcp) as client: result = await client.call_tool("get_numbers", {}) assert result.is_error is False assert result.structured_content is not None assert result.structured_content == {"result": [1, 2, 3, 4, 5]} async def test_tool_structured_output_server_side_validation_error(self): """Test that server-side validation errors are handled properly""" def get_numbers() -> list[int]: return [1, 2, 3, 4, [5]] # type: ignore mcp = MCPServer() mcp.add_tool(get_numbers) async with Client(mcp) as client: result = await client.call_tool("get_numbers", {}) assert result.is_error is True assert result.structured_content is None assert len(result.content) == 1 assert isinstance(result.content[0], TextContent) async def test_tool_structured_output_dict_str_any(self): """Test tool with dict[str, Any] structured output""" def get_metadata() -> dict[str, Any]: """Get metadata dictionary""" return { "version": "1.0.0", "enabled": True, "count": 42, "tags": ["production", "stable"], "config": {"nested": {"value": 123}}, } mcp = MCPServer() mcp.add_tool(get_metadata) async with Client(mcp) as client: # Check schema tools = await client.list_tools() tool = next(t for t in tools.tools if t.name == "get_metadata") assert tool.output_schema is not None assert tool.output_schema["type"] == "object" # dict[str, Any] should have minimal schema assert ( "additionalProperties" not in tool.output_schema or tool.output_schema.get("additionalProperties") is True ) # Call tool result = await client.call_tool("get_metadata", {}) assert result.is_error is False assert result.structured_content is not None expected = { "version": "1.0.0", "enabled": True, "count": 42, "tags": ["production", "stable"], "config": {"nested": {"value": 123}}, } assert result.structured_content == expected async def test_tool_structured_output_dict_str_typed(self): """Test tool with dict[str, T] structured output for specific T""" def get_settings() -> dict[str, str]: """Get settings as string dictionary""" return {"theme": "dark", "language": "en", "timezone": "UTC"} mcp = MCPServer() mcp.add_tool(get_settings) async with Client(mcp) as client: # Check schema tools = await client.list_tools() tool = next(t for t in tools.tools if t.name == "get_settings") assert tool.output_schema is not None assert tool.output_schema["type"] == "object" assert tool.output_schema["additionalProperties"]["type"] == "string" # Call tool result = await client.call_tool("get_settings", {}) assert result.is_error is False assert result.structured_content == {"theme": "dark", "language": "en", "timezone": "UTC"} async def test_remove_tool(self): """Test removing a tool from the server.""" mcp = MCPServer() mcp.add_tool(tool_fn) # Verify tool exists assert len(mcp._tool_manager.list_tools()) == 1 # Remove the tool mcp.remove_tool("tool_fn") # Verify tool is removed assert len(mcp._tool_manager.list_tools()) == 0 async def test_remove_nonexistent_tool(self): """Test that removing a non-existent tool raises ToolError.""" mcp = MCPServer() with pytest.raises(ToolError, match="Unknown tool: nonexistent"): mcp.remove_tool("nonexistent") async def test_remove_tool_and_list(self): """Test that a removed tool doesn't appear in list_tools.""" mcp = MCPServer() mcp.add_tool(tool_fn) mcp.add_tool(error_tool_fn) # Verify both tools exist async with Client(mcp) as client: tools = await client.list_tools() assert len(tools.tools) == 2 tool_names = [t.name for t in tools.tools] assert "tool_fn" in tool_names assert "error_tool_fn" in tool_names # Remove one tool mcp.remove_tool("tool_fn") # Verify only one tool remains async with Client(mcp) as client: tools = await client.list_tools() assert len(tools.tools) == 1 assert tools.tools[0].name == "error_tool_fn" async def test_remove_tool_and_call(self): """Test that calling a removed tool fails appropriately.""" mcp = MCPServer() mcp.add_tool(tool_fn) # Verify tool works before removal async with Client(mcp) as client: result = await client.call_tool("tool_fn", {"x": 1, "y": 2}) assert not result.is_error content = result.content[0] assert isinstance(content, TextContent) assert content.text == "3" # Remove the tool mcp.remove_tool("tool_fn") # Verify calling removed tool returns an error async with Client(mcp) as client: result = await client.call_tool("tool_fn", {"x": 1, "y": 2}) assert result.is_error content = result.content[0] assert isinstance(content, TextContent) assert "Unknown tool" in content.text class TestServerResources: async def test_text_resource(self): mcp = MCPServer() def get_text(): return "Hello, world!" 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") assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Hello, world!" 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() def get_binary(): return b"Binary data" resource = FunctionResource( uri="resource://binary", name="binary", fn=get_binary, mime_type="application/octet-stream", ) mcp.add_resource(resource) 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() async def test_file_resource_text(self, tmp_path: Path): mcp = MCPServer() # Create a text file text_file = tmp_path / "test.txt" text_file.write_text("Hello from file!") 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") assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Hello from file!" async def test_file_resource_binary(self, tmp_path: Path): mcp = MCPServer() # Create a binary file binary_file = tmp_path / "test.bin" binary_file.write_bytes(b"Binary file data") resource = FileResource( uri="file://test.bin", name="test.bin", path=binary_file, mime_type="application/octet-stream", ) mcp.add_resource(resource) 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() async def test_function_resource(self): mcp = MCPServer() @mcp.resource("function://test", name="test_get_data") def get_data() -> str: # pragma: no cover """get_data returns a string""" return "Hello, world!" async with Client(mcp) as client: resources = await client.list_resources() assert len(resources.resources) == 1 resource = resources.resources[0] assert resource.description == "get_data returns a string" assert resource.uri == "function://test" assert resource.name == "test_get_data" assert resource.mime_type == "text/plain" class TestServerResourceTemplates: async def test_resource_with_params(self): """Test that a resource with function parameters raises an error if the URI parameters don't match""" mcp = MCPServer() with pytest.raises(ValueError, match="Mismatch between URI parameters"): @mcp.resource("resource://data") def get_data_fn(param: str) -> str: # pragma: no cover return f"Data: {param}" async def test_resource_with_uri_params(self): """Test that a resource with URI parameters is automatically a template""" mcp = MCPServer() with pytest.raises(ValueError, match="Mismatch between URI parameters"): @mcp.resource("resource://{param}") def get_data() -> str: # pragma: no cover return "Data" async def test_resource_with_untyped_params(self): """Test that a resource with untyped parameters raises an error""" mcp = MCPServer() @mcp.resource("resource://{param}") def get_data(param) -> str: # type: ignore # pragma: no cover return "Data" async def test_resource_matching_params(self): """Test that a resource with matching URI and function parameters works""" mcp = MCPServer() @mcp.resource("resource://{name}/data") 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") assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Data for test" async def test_resource_mismatched_params(self): """Test that mismatched parameters raise an error""" mcp = MCPServer() with pytest.raises(ValueError, match="Mismatch between URI parameters"): @mcp.resource("resource://{name}/data") def get_data(user: str) -> str: # pragma: no cover return f"Data for {user}" async def test_resource_multiple_params(self): """Test that multiple parameters work correctly""" mcp = MCPServer() @mcp.resource("resource://{org}/{repo}/data") 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") assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Data for cursor/myrepo" async def test_resource_multiple_mismatched_params(self): """Test that mismatched parameters raise an error""" mcp = MCPServer() with pytest.raises(ValueError, match="Mismatch between URI parameters"): @mcp.resource("resource://{org}/{repo}/data") def get_data_mismatched(org: str, repo_2: str) -> str: # pragma: no cover return f"Data for {org}" """Test that a resource with no parameters works as a regular resource""" mcp = MCPServer() @mcp.resource("resource://static") def get_static_data() -> str: return "Static data" 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" async def test_template_to_resource_conversion(self): """Test that templates are properly converted to resources when accessed""" mcp = MCPServer() @mcp.resource("resource://{name}/data") def get_data(name: str) -> str: return f"Data for {name}" # Should be registered as a template assert len(mcp._resource_manager._templates) == 1 assert len(await mcp.list_resources()) == 0 # When accessed, should create a concrete resource resource = await mcp._resource_manager.get_resource("resource://test/data", Context()) assert isinstance(resource, FunctionResource) result = await resource.read() assert result == "Data for test" async def test_resource_template_includes_mime_type(self): """Test that list resource templates includes the correct mimeType.""" mcp = MCPServer() @mcp.resource("resource://{user}/csv", mime_type="text/csv") def get_csv(user: str) -> str: return f"csv for {user}" templates = await mcp.list_resource_templates() 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 result == snapshot( ReadResourceResult( contents=[TextResourceContents(uri="resource://bob/csv", mime_type="text/csv", text="csv for bob")] ) ) class TestServerResourceMetadata: """Test MCPServer @resource decorator meta parameter for list operations. Meta flows: @resource decorator -> resource/template storage -> list_resources/list_resource_templates. Note: read_resource does NOT pass meta to protocol response (lowlevel/server.py only extracts content/mime_type). """ 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() @mcp.resource("resource://config", meta={"ui": {"component": "file-viewer"}, "priority": "high"}) def get_config() -> str: ... # pragma: no branch resources = await mcp.list_resources() 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] ) ] ) 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() @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 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] ) ] ) 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() @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") 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.""" async def test_context_detection(self): """Test that context parameters are properly detected.""" mcp = MCPServer() def tool_with_context(x: int, ctx: Context) -> str: # pragma: no cover return f"Request {ctx.request_id}: {x}" tool = mcp._tool_manager.add_tool(tool_with_context) assert tool.context_kwarg == "ctx" async def test_context_injection(self): """Test that context is properly injected into tool calls.""" mcp = MCPServer() def tool_with_context(x: int, ctx: Context) -> str: assert ctx.request_id is not None return f"Request {ctx.request_id}: {x}" mcp.add_tool(tool_with_context) async with Client(mcp) as client: result = await client.call_tool("tool_with_context", {"x": 42}) assert len(result.content) == 1 content = result.content[0] assert isinstance(content, TextContent) assert "Request" in content.text assert "42" in content.text async def test_async_context(self): """Test that context works in async functions.""" mcp = MCPServer() async def async_tool(x: int, ctx: Context) -> str: assert ctx.request_id is not None return f"Async request {ctx.request_id}: {x}" mcp.add_tool(async_tool) async with Client(mcp) as client: result = await client.call_tool("async_tool", {"x": 42}) assert len(result.content) == 1 content = result.content[0] assert isinstance(content, TextContent) assert "Async request" in content.text assert "42" in content.text async def test_context_logging(self): """Test that context logging methods work.""" mcp = MCPServer() async def logging_tool(msg: str, ctx: Context) -> str: await ctx.debug("Debug message") await ctx.info("Info message") await ctx.warning("Warning message") await ctx.error("Error message") return f"Logged messages for {msg}" mcp.add_tool(logging_tool) with patch("mcp.server.session.ServerSession.send_log_message") as mock_log: async with Client(mcp) as client: result = await client.call_tool("logging_tool", {"msg": "test"}) assert len(result.content) == 1 content = result.content[0] assert isinstance(content, TextContent) 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") async def test_optional_context(self): """Test that context is optional.""" mcp = MCPServer() def no_context(x: int) -> int: return x * 2 mcp.add_tool(no_context) async with Client(mcp) as client: result = await client.call_tool("no_context", {"x": 21}) assert len(result.content) == 1 content = result.content[0] assert isinstance(content, TextContent) assert content.text == "42" async def test_context_resource_access(self): """Test that context can access resources.""" mcp = MCPServer() @mcp.resource("test://data") def test_resource() -> str: return "resource data" @mcp.tool() async def tool_with_resource(ctx: Context) -> str: r_iter = await ctx.read_resource("test://data") r_list = list(r_iter) assert len(r_list) == 1 r = r_list[0] return f"Read resource: {r.content} with mime type {r.mime_type}" async with Client(mcp) as client: result = await client.call_tool("tool_with_resource", {}) assert len(result.content) == 1 content = result.content[0] assert isinstance(content, TextContent) assert "Read resource: resource data" in content.text async def test_resource_with_context(self): """Test that resources can receive context parameter.""" mcp = MCPServer() @mcp.resource("resource://context/{name}") def resource_with_context(name: str, ctx: Context) -> str: """Resource that receives context.""" assert ctx is not None return f"Resource {name} - context injected" # Verify template has context_kwarg set templates = mcp._resource_manager.list_templates() assert len(templates) == 1 template = templates[0] assert hasattr(template, "context_kwarg") assert template.context_kwarg == "ctx" async with Client(mcp) as client: result = await client.read_resource("resource://context/test") assert len(result.contents) == 1 content = result.contents[0] assert isinstance(content, TextResourceContents) # Should have either request_id or indication that context was injected assert "Resource test - context injected" == content.text async def test_resource_without_context(self): """Test that resources without context work normally.""" mcp = MCPServer() @mcp.resource("resource://nocontext/{name}") def resource_no_context(name: str) -> str: """Resource without context.""" return f"Resource {name} works" # Verify template has no context_kwarg templates = mcp._resource_manager.list_templates() assert len(templates) == 1 template = templates[0] assert template.context_kwarg is None 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" ) ] ) ) async def test_resource_context_custom_name(self): """Test resource context with custom parameter name.""" mcp = MCPServer() @mcp.resource("resource://custom/{id}") def resource_custom_ctx(id: str, my_ctx: Context) -> str: """Resource with custom context parameter name.""" assert my_ctx is not None return f"Resource {id} with context" # Verify template detects custom context parameter templates = mcp._resource_manager.list_templates() assert len(templates) == 1 template = templates[0] assert template.context_kwarg == "my_ctx" 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" ) ] ) ) async def test_prompt_with_context(self): """Test that prompts can receive context parameter.""" mcp = MCPServer() @mcp.prompt("prompt_with_ctx") def prompt_with_context(text: str, ctx: Context) -> str: """Prompt that expects context.""" assert ctx is not None return f"Prompt '{text}' - context injected" # Test via client async with Client(mcp) as client: # Try calling without passing ctx explicitly result = await client.get_prompt("prompt_with_ctx", {"text": "test"}) # If this succeeds, check if context was injected assert len(result.messages) == 1 content = result.messages[0].content assert isinstance(content, TextContent) assert "Prompt 'test' - context injected" in content.text async def test_prompt_without_context(self): """Test that prompts without context work normally.""" mcp = MCPServer() @mcp.prompt("prompt_no_ctx") def prompt_no_context(text: str) -> str: """Prompt without context.""" return f"Prompt '{text}' works" # Test via client async with Client(mcp) as client: result = await client.get_prompt("prompt_no_ctx", {"text": "test"}) assert len(result.messages) == 1 message = result.messages[0] content = message.content assert isinstance(content, TextContent) assert content.text == "Prompt 'test' works" class TestServerPrompts: """Test prompt functionality in MCPServer server.""" async def test_get_prompt_direct_call_without_context(self): """Test calling mcp.get_prompt() directly without passing context.""" mcp = MCPServer() @mcp.prompt() def fn() -> str: return "Hello, world!" result = await mcp.get_prompt("fn") content = result.messages[0].content assert isinstance(content, TextContent) assert content.text == "Hello, world!" async def test_prompt_decorator(self): """Test that the prompt decorator registers prompts correctly.""" mcp = MCPServer() @mcp.prompt() def fn() -> str: return "Hello, world!" prompts = mcp._prompt_manager.list_prompts() assert len(prompts) == 1 assert prompts[0].name == "fn" # Don't compare functions directly since validate_call wraps them content = await prompts[0].render(None, Context()) assert isinstance(content[0].content, TextContent) assert content[0].content.text == "Hello, world!" async def test_prompt_decorator_with_name(self): """Test prompt decorator with custom name.""" mcp = MCPServer() @mcp.prompt(name="custom_name") def fn() -> str: return "Hello, world!" prompts = mcp._prompt_manager.list_prompts() assert len(prompts) == 1 assert prompts[0].name == "custom_name" content = await prompts[0].render(None, Context()) assert isinstance(content[0].content, TextContent) assert content[0].content.text == "Hello, world!" async def test_prompt_decorator_with_description(self): """Test prompt decorator with custom description.""" mcp = MCPServer() @mcp.prompt(description="A custom description") def fn() -> str: return "Hello, world!" prompts = mcp._prompt_manager.list_prompts() assert len(prompts) == 1 assert prompts[0].description == "A custom description" content = await prompts[0].render(None, Context()) assert isinstance(content[0].content, TextContent) assert content[0].content.text == "Hello, world!" def test_prompt_decorator_error(self): """Test error when decorator is used incorrectly.""" mcp = MCPServer() with pytest.raises(TypeError, match="decorator was used incorrectly"): @mcp.prompt # type: ignore def fn() -> str: ... # pragma: no branch 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 branch async with Client(mcp) as client: result = await client.list_prompts() 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() @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 == snapshot( GetPromptResult( description="", messages=[PromptMessage(role="user", content=TextContent(text="Hello, World!"))], ) ) async def test_get_prompt_with_description(self): """Test getting a prompt through MCP protocol.""" mcp = MCPServer() @mcp.prompt(description="Test prompt description") 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 == "Test prompt description" async def test_get_prompt_with_docstring_description(self): """Test prompt uses docstring as description when not explicitly provided.""" mcp = MCPServer() @mcp.prompt() def fn(name: str) -> str: """This is the function docstring.""" return f"Hello, {name}!" async with Client(mcp) as client: result = await client.get_prompt("fn", {"name": "World"}) assert result == snapshot( GetPromptResult( description="This is the function docstring.", messages=[PromptMessage(role="user", content=TextContent(text="Hello, World!"))], ) ) async def test_get_prompt_with_resource(self): """Test getting a prompt that returns resource content.""" mcp = MCPServer() @mcp.prompt() def fn() -> Message: return UserMessage( content=EmbeddedResource( type="resource", 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 result == snapshot( GetPromptResult( description="", messages=[ PromptMessage( role="user", content=EmbeddedResource( resource=TextResourceContents( uri="file://file.txt", mime_type="text/plain", text="File contents" ) ), ) ], ) ) 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") 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 branch async with Client(mcp) as client: with pytest.raises(MCPError, match="Missing required arguments"): await client.get_prompt("prompt_fn") async def test_completion_decorator() -> None: """Test that the completion decorator registers a working handler.""" mcp = MCPServer() @mcp.completion() async def handle_completion( ref: PromptReference, argument: CompletionArgument, context: CompletionContext | None ) -> Completion: assert argument.name == "style" return Completion(values=["bold", "italic", "underline"]) async with Client(mcp) as client: ref = PromptReference(type="ref/prompt", name="test") result = await client.complete(ref=ref, argument={"name": "style", "value": "b"}) assert result.completion.values == ["bold", "italic", "underline"] def test_streamable_http_no_redirect() -> None: """Test that streamable HTTP routes are correctly configured.""" mcp = MCPServer() # streamable_http_path defaults to "/mcp" app = mcp.streamable_http_app() # Find routes by type - streamable_http_app creates Route objects, not Mount objects streamable_routes = [r for r in app.routes if isinstance(r, Route) and hasattr(r, "path") and r.path == "/mcp"] # Verify routes exist assert len(streamable_routes) == 1, "Should have one streamable route" # Verify path values assert streamable_routes[0].path == "/mcp", "Streamable route path should be /mcp" async def test_report_progress_passes_related_request_id(): """Test that report_progress passes the request_id as related_request_id. Without related_request_id, the streamable HTTP transport cannot route progress notifications to the correct SSE stream, causing them to be silently dropped. See #953 and #2001. """ mock_session = AsyncMock() mock_session.send_progress_notification = AsyncMock() request_context = ServerRequestContext( request_id="req-abc-123", session=mock_session, meta={"progress_token": "tok-1"}, lifespan_context=None, experimental=Experimental(), ) ctx = Context(request_context=request_context, mcp_server=MagicMock()) await ctx.report_progress(50, 100, message="halfway") mock_session.send_progress_notification.assert_awaited_once_with( progress_token="tok-1", progress=50, total=100, message="halfway", related_request_id="req-abc-123", )