Add tests for MCPServer.read_resource exception handling (#1957)
This commit is contained in:
parent
6f0e8e7851
commit
14744c66c5
6 changed files with 165 additions and 254 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue