Add tests for MCPServer.read_resource exception handling (#1957)

This commit is contained in:
Marcelo Trylesinski 2026-01-27 13:49:06 +01:00 • committed by GitHub
commit 14744c66c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 165 additions and 254 deletions

View file

@ -91,7 +91,7 @@ class Experimental:
error = ErrorData(code=METHOD_NOT_FOUND, message="This tool does not support task-augmented invocation") error = ErrorData(code=METHOD_NOT_FOUND, message="This tool does not support task-augmented invocation")
if error is not None and raise_error: if error is not None and raise_error:
raise MCPError(code=error.code, message=error.message) raise MCPError.from_error_data(error)
return error return error

View file

@ -795,12 +795,7 @@ class Server(Generic[LifespanResultT, RequestT]):
await message.respond(response) await message.respond(response)
else: # pragma: no cover else: # pragma: no cover
await message.respond( await message.respond(types.ErrorData(code=types.METHOD_NOT_FOUND, message="Method not found"))
types.ErrorData(
code=types.METHOD_NOT_FOUND,
message="Method not found",
)
)
logger.debug("Response sent") logger.debug("Response sent")

View file

@ -82,10 +82,8 @@ class ResourceManager:
return template return template
async def get_resource( async def get_resource(
self, self, uri: AnyUrl | str, context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None
uri: AnyUrl | str, ) -> Resource:
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
) -> Resource | None:
"""Get resource by URI, checking concrete resources first, then templates.""" """Get resource by URI, checking concrete resources first, then templates."""
uri_str = str(uri) uri_str = str(uri)
logger.debug("Getting resource", extra={"uri": uri_str}) logger.debug("Getting resource", extra={"uri": uri_str})

View file

@ -347,16 +347,18 @@ class MCPServer(Generic[LifespanResultT]):
"""Read a resource by URI.""" """Read a resource by URI."""
context = self.get_context() context = self.get_context()
resource = await self._resource_manager.get_resource(uri, context=context) try:
if not resource: # pragma: no cover resource = await self._resource_manager.get_resource(uri, context=context)
except ValueError:
raise ResourceError(f"Unknown resource: {uri}") raise ResourceError(f"Unknown resource: {uri}")
try: try:
content = await resource.read() content = await resource.read()
return [ReadResourceContents(content=content, mime_type=resource.mime_type, meta=resource.meta)] return [ReadResourceContents(content=content, mime_type=resource.mime_type, meta=resource.meta)]
except Exception as e: # pragma: no cover except Exception as exc:
logger.exception(f"Error reading resource {uri}") logger.exception(f"Error getting resource {uri}")
raise ResourceError(str(e)) # 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( def add_tool(
self, self,

View file

@ -2,6 +2,7 @@ import pytest
from mcp import Client from mcp import Client
from mcp.server.mcpserver import MCPServer from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ResourceError
from mcp.types import ( from mcp.types import (
ListResourceTemplatesResult, ListResourceTemplatesResult,
TextResourceContents, TextResourceContents,
@ -54,10 +55,10 @@ async def test_resource_template_edge_cases():
assert result_list[0].mime_type == "text/plain" assert result_list[0].mime_type == "text/plain"
# Verify invalid parameters raise error # 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 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 await mcp.read_resource("resource://users/123/posts/456/extra") # Extra path component

View file

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