feat: add paginated list decorators for prompts, resources, and tools (#1286)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
03e19f1c90
commit
4fb975c6dc
4 changed files with 1274 additions and 19 deletions
0
tests/server/lowlevel/__init__.py
Normal file
0
tests/server/lowlevel/__init__.py
Normal file
292
tests/server/lowlevel/test_func_inspection.py
Normal file
292
tests/server/lowlevel/test_func_inspection.py
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
"""Unit tests for func_inspection module.
|
||||
|
||||
Tests the create_call_wrapper function which determines how to call handler functions
|
||||
with different parameter signatures and type hints.
|
||||
"""
|
||||
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp.server.lowlevel.func_inspection import create_call_wrapper
|
||||
from mcp.types import ListPromptsRequest, ListResourcesRequest, ListToolsRequest, PaginatedRequestParams
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_no_params_returns_deprecated_wrapper() -> None:
|
||||
"""Test: def foo() - should call without request."""
|
||||
called_without_request = False
|
||||
|
||||
async def handler() -> list[str]:
|
||||
nonlocal called_without_request
|
||||
called_without_request = True
|
||||
return ["test"]
|
||||
|
||||
wrapper = create_call_wrapper(handler, ListPromptsRequest)
|
||||
|
||||
# Wrapper should call handler without passing request
|
||||
request = ListPromptsRequest(method="prompts/list", params=None)
|
||||
result = await wrapper(request)
|
||||
assert called_without_request is True
|
||||
assert result == ["test"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_param_with_default_returns_deprecated_wrapper() -> None:
|
||||
"""Test: def foo(thing: int = 1) - should call without request."""
|
||||
called_without_request = False
|
||||
|
||||
async def handler(thing: int = 1) -> list[str]:
|
||||
nonlocal called_without_request
|
||||
called_without_request = True
|
||||
return [f"test-{thing}"]
|
||||
|
||||
wrapper = create_call_wrapper(handler, ListPromptsRequest)
|
||||
|
||||
# Wrapper should call handler without passing request (uses default value)
|
||||
request = ListPromptsRequest(method="prompts/list", params=None)
|
||||
result = await wrapper(request)
|
||||
assert called_without_request is True
|
||||
assert result == ["test-1"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_typed_request_param_passes_request() -> None:
|
||||
"""Test: def foo(req: ListPromptsRequest) - should pass request through."""
|
||||
received_request = None
|
||||
|
||||
async def handler(req: ListPromptsRequest) -> list[str]:
|
||||
nonlocal received_request
|
||||
received_request = req
|
||||
return ["test"]
|
||||
|
||||
wrapper = create_call_wrapper(handler, ListPromptsRequest)
|
||||
|
||||
# Wrapper should pass request to handler
|
||||
request = ListPromptsRequest(method="prompts/list", params=PaginatedRequestParams(cursor="test-cursor"))
|
||||
await wrapper(request)
|
||||
|
||||
assert received_request is not None
|
||||
assert received_request is request
|
||||
params = getattr(received_request, "params", None)
|
||||
assert params is not None
|
||||
assert params.cursor == "test-cursor"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_typed_request_with_default_param_passes_request() -> None:
|
||||
"""Test: def foo(req: ListPromptsRequest, thing: int = 1) - should pass request through."""
|
||||
received_request = None
|
||||
received_thing = None
|
||||
|
||||
async def handler(req: ListPromptsRequest, thing: int = 1) -> list[str]:
|
||||
nonlocal received_request, received_thing
|
||||
received_request = req
|
||||
received_thing = thing
|
||||
return ["test"]
|
||||
|
||||
wrapper = create_call_wrapper(handler, ListPromptsRequest)
|
||||
|
||||
# Wrapper should pass request to handler
|
||||
request = ListPromptsRequest(method="prompts/list", params=None)
|
||||
await wrapper(request)
|
||||
|
||||
assert received_request is request
|
||||
assert received_thing == 1 # default value
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_optional_typed_request_with_default_none_is_deprecated() -> None:
|
||||
"""Test: def foo(thing: int = 1, req: ListPromptsRequest | None = None) - old style."""
|
||||
called_without_request = False
|
||||
|
||||
async def handler(thing: int = 1, req: ListPromptsRequest | None = None) -> list[str]:
|
||||
nonlocal called_without_request
|
||||
called_without_request = True
|
||||
return ["test"]
|
||||
|
||||
wrapper = create_call_wrapper(handler, ListPromptsRequest)
|
||||
|
||||
# Wrapper should call handler without passing request
|
||||
request = ListPromptsRequest(method="prompts/list", params=None)
|
||||
result = await wrapper(request)
|
||||
assert called_without_request is True
|
||||
assert result == ["test"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_untyped_request_param_is_deprecated() -> None:
|
||||
"""Test: def foo(req) - should call without request."""
|
||||
called = False
|
||||
|
||||
async def handler(req): # type: ignore[no-untyped-def] # pyright: ignore[reportMissingParameterType]
|
||||
nonlocal called
|
||||
called = True
|
||||
return ["test"]
|
||||
|
||||
wrapper = create_call_wrapper(handler, ListPromptsRequest) # pyright: ignore[reportUnknownArgumentType]
|
||||
|
||||
# Wrapper should call handler without passing request, which will fail because req is required
|
||||
request = ListPromptsRequest(method="prompts/list", params=None)
|
||||
# This will raise TypeError because handler expects 'req' but wrapper doesn't provide it
|
||||
with pytest.raises(TypeError, match="missing 1 required positional argument"):
|
||||
await wrapper(request)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_any_typed_request_param_is_deprecated() -> None:
|
||||
"""Test: def foo(req: Any) - should call without request."""
|
||||
|
||||
async def handler(req: Any) -> list[str]:
|
||||
return ["test"]
|
||||
|
||||
wrapper = create_call_wrapper(handler, ListPromptsRequest)
|
||||
|
||||
# Wrapper should call handler without passing request, which will fail because req is required
|
||||
request = ListPromptsRequest(method="prompts/list", params=None)
|
||||
# This will raise TypeError because handler expects 'req' but wrapper doesn't provide it
|
||||
with pytest.raises(TypeError, match="missing 1 required positional argument"):
|
||||
await wrapper(request)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_generic_typed_request_param_is_deprecated() -> None:
|
||||
"""Test: def foo(req: Generic[T]) - should call without request."""
|
||||
|
||||
async def handler(req: Generic[T]) -> list[str]: # pyright: ignore[reportGeneralTypeIssues]
|
||||
return ["test"]
|
||||
|
||||
wrapper = create_call_wrapper(handler, ListPromptsRequest)
|
||||
|
||||
# Wrapper should call handler without passing request, which will fail because req is required
|
||||
request = ListPromptsRequest(method="prompts/list", params=None)
|
||||
# This will raise TypeError because handler expects 'req' but wrapper doesn't provide it
|
||||
with pytest.raises(TypeError, match="missing 1 required positional argument"):
|
||||
await wrapper(request)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wrong_typed_request_param_is_deprecated() -> None:
|
||||
"""Test: def foo(req: str) - should call without request."""
|
||||
|
||||
async def handler(req: str) -> list[str]:
|
||||
return ["test"]
|
||||
|
||||
wrapper = create_call_wrapper(handler, ListPromptsRequest)
|
||||
|
||||
# Wrapper should call handler without passing request, which will fail because req is required
|
||||
request = ListPromptsRequest(method="prompts/list", params=None)
|
||||
# This will raise TypeError because handler expects 'req' but wrapper doesn't provide it
|
||||
with pytest.raises(TypeError, match="missing 1 required positional argument"):
|
||||
await wrapper(request)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_required_param_before_typed_request_attempts_to_pass() -> None:
|
||||
"""Test: def foo(thing: int, req: ListPromptsRequest) - attempts to pass request (will fail at runtime)."""
|
||||
received_request = None
|
||||
|
||||
async def handler(thing: int, req: ListPromptsRequest) -> list[str]:
|
||||
nonlocal received_request
|
||||
received_request = req
|
||||
return ["test"]
|
||||
|
||||
wrapper = create_call_wrapper(handler, ListPromptsRequest)
|
||||
|
||||
# Wrapper will attempt to pass request, but it will fail at runtime
|
||||
# because 'thing' is required and has no default
|
||||
request = ListPromptsRequest(method="prompts/list", params=None)
|
||||
|
||||
# This will raise TypeError because 'thing' is missing
|
||||
with pytest.raises(TypeError, match="missing 1 required positional argument: 'thing'"):
|
||||
await wrapper(request)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_positional_only_param_with_correct_type() -> None:
|
||||
"""Test: def foo(req: ListPromptsRequest, /) - should pass request through."""
|
||||
received_request = None
|
||||
|
||||
async def handler(req: ListPromptsRequest, /) -> list[str]:
|
||||
nonlocal received_request
|
||||
received_request = req
|
||||
return ["test"]
|
||||
|
||||
wrapper = create_call_wrapper(handler, ListPromptsRequest)
|
||||
|
||||
# Wrapper should pass request to handler
|
||||
request = ListPromptsRequest(method="prompts/list", params=None)
|
||||
await wrapper(request)
|
||||
|
||||
assert received_request is request
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_keyword_only_param_with_correct_type() -> None:
|
||||
"""Test: def foo(*, req: ListPromptsRequest) - should pass request through."""
|
||||
received_request = None
|
||||
|
||||
async def handler(*, req: ListPromptsRequest) -> list[str]:
|
||||
nonlocal received_request
|
||||
received_request = req
|
||||
return ["test"]
|
||||
|
||||
wrapper = create_call_wrapper(handler, ListPromptsRequest)
|
||||
|
||||
# Wrapper should pass request to handler with keyword argument
|
||||
request = ListPromptsRequest(method="prompts/list", params=None)
|
||||
await wrapper(request)
|
||||
|
||||
assert received_request is request
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_different_request_types() -> None:
|
||||
"""Test that wrapper works with different request types."""
|
||||
# Test with ListResourcesRequest
|
||||
received_request = None
|
||||
|
||||
async def handler(req: ListResourcesRequest) -> list[str]:
|
||||
nonlocal received_request
|
||||
received_request = req
|
||||
return ["test"]
|
||||
|
||||
wrapper = create_call_wrapper(handler, ListResourcesRequest)
|
||||
|
||||
request = ListResourcesRequest(method="resources/list", params=None)
|
||||
await wrapper(request)
|
||||
|
||||
assert received_request is request
|
||||
|
||||
# Test with ListToolsRequest
|
||||
received_request = None
|
||||
|
||||
async def handler2(req: ListToolsRequest) -> list[str]:
|
||||
nonlocal received_request
|
||||
received_request = req
|
||||
return ["test"]
|
||||
|
||||
wrapper2 = create_call_wrapper(handler2, ListToolsRequest)
|
||||
|
||||
request2 = ListToolsRequest(method="tools/list", params=None)
|
||||
await wrapper2(request2)
|
||||
|
||||
assert received_request is request2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mixed_params_with_typed_request() -> None:
|
||||
"""Test: def foo(a: str, req: ListPromptsRequest, b: int = 5) - attempts to pass request."""
|
||||
|
||||
async def handler(a: str, req: ListPromptsRequest, b: int = 5) -> list[str]:
|
||||
return ["test"]
|
||||
|
||||
wrapper = create_call_wrapper(handler, ListPromptsRequest)
|
||||
|
||||
# Will fail at runtime due to missing 'a'
|
||||
request = ListPromptsRequest(method="prompts/list", params=None)
|
||||
|
||||
with pytest.raises(TypeError, match="missing 1 required positional argument: 'a'"):
|
||||
await wrapper(request)
|
||||
182
tests/server/lowlevel/test_server_listing.py
Normal file
182
tests/server/lowlevel/test_server_listing.py
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
"""Basic tests for list_prompts, list_resources, and list_tools decorators without pagination."""
|
||||
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from mcp.server import Server
|
||||
from mcp.types import (
|
||||
ListPromptsRequest,
|
||||
ListPromptsResult,
|
||||
ListResourcesRequest,
|
||||
ListResourcesResult,
|
||||
ListToolsRequest,
|
||||
ListToolsResult,
|
||||
Prompt,
|
||||
Resource,
|
||||
ServerResult,
|
||||
Tool,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_prompts_basic() -> None:
|
||||
"""Test basic prompt listing without pagination."""
|
||||
server = Server("test")
|
||||
|
||||
test_prompts = [
|
||||
Prompt(name="prompt1", description="First prompt"),
|
||||
Prompt(name="prompt2", description="Second prompt"),
|
||||
]
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
|
||||
@server.list_prompts()
|
||||
async def handle_list_prompts() -> list[Prompt]:
|
||||
return test_prompts
|
||||
|
||||
handler = server.request_handlers[ListPromptsRequest]
|
||||
request = ListPromptsRequest(method="prompts/list", params=None)
|
||||
result = await handler(request)
|
||||
|
||||
assert isinstance(result, ServerResult)
|
||||
assert isinstance(result.root, ListPromptsResult)
|
||||
assert result.root.prompts == test_prompts
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_resources_basic() -> None:
|
||||
"""Test basic resource listing without pagination."""
|
||||
server = Server("test")
|
||||
|
||||
test_resources = [
|
||||
Resource(uri=AnyUrl("file:///test1.txt"), name="Test 1"),
|
||||
Resource(uri=AnyUrl("file:///test2.txt"), name="Test 2"),
|
||||
]
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
|
||||
@server.list_resources()
|
||||
async def handle_list_resources() -> list[Resource]:
|
||||
return test_resources
|
||||
|
||||
handler = server.request_handlers[ListResourcesRequest]
|
||||
request = ListResourcesRequest(method="resources/list", params=None)
|
||||
result = await handler(request)
|
||||
|
||||
assert isinstance(result, ServerResult)
|
||||
assert isinstance(result.root, ListResourcesResult)
|
||||
assert result.root.resources == test_resources
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_tools_basic() -> None:
|
||||
"""Test basic tool listing without pagination."""
|
||||
server = Server("test")
|
||||
|
||||
test_tools = [
|
||||
Tool(
|
||||
name="tool1",
|
||||
description="First tool",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {"type": "string"},
|
||||
},
|
||||
"required": ["message"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="tool2",
|
||||
description="Second tool",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"count": {"type": "number"},
|
||||
"enabled": {"type": "boolean"},
|
||||
},
|
||||
"required": ["count"],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
|
||||
@server.list_tools()
|
||||
async def handle_list_tools() -> list[Tool]:
|
||||
return test_tools
|
||||
|
||||
handler = server.request_handlers[ListToolsRequest]
|
||||
request = ListToolsRequest(method="tools/list", params=None)
|
||||
result = await handler(request)
|
||||
|
||||
assert isinstance(result, ServerResult)
|
||||
assert isinstance(result.root, ListToolsResult)
|
||||
assert result.root.tools == test_tools
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_prompts_empty() -> None:
|
||||
"""Test listing with empty results."""
|
||||
server = Server("test")
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
|
||||
@server.list_prompts()
|
||||
async def handle_list_prompts() -> list[Prompt]:
|
||||
return []
|
||||
|
||||
handler = server.request_handlers[ListPromptsRequest]
|
||||
request = ListPromptsRequest(method="prompts/list", params=None)
|
||||
result = await handler(request)
|
||||
|
||||
assert isinstance(result, ServerResult)
|
||||
assert isinstance(result.root, ListPromptsResult)
|
||||
assert result.root.prompts == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_resources_empty() -> None:
|
||||
"""Test listing with empty results."""
|
||||
server = Server("test")
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
|
||||
@server.list_resources()
|
||||
async def handle_list_resources() -> list[Resource]:
|
||||
return []
|
||||
|
||||
handler = server.request_handlers[ListResourcesRequest]
|
||||
request = ListResourcesRequest(method="resources/list", params=None)
|
||||
result = await handler(request)
|
||||
|
||||
assert isinstance(result, ServerResult)
|
||||
assert isinstance(result.root, ListResourcesResult)
|
||||
assert result.root.resources == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_tools_empty() -> None:
|
||||
"""Test listing with empty results."""
|
||||
server = Server("test")
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
|
||||
@server.list_tools()
|
||||
async def handle_list_tools() -> list[Tool]:
|
||||
return []
|
||||
|
||||
handler = server.request_handlers[ListToolsRequest]
|
||||
request = ListToolsRequest(method="tools/list", params=None)
|
||||
result = await handler(request)
|
||||
|
||||
assert isinstance(result, ServerResult)
|
||||
assert isinstance(result.root, ListToolsResult)
|
||||
assert result.root.tools == []
|
||||
111
tests/server/lowlevel/test_server_pagination.py
Normal file
111
tests/server/lowlevel/test_server_pagination.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import pytest
|
||||
|
||||
from mcp.server import Server
|
||||
from mcp.types import (
|
||||
ListPromptsRequest,
|
||||
ListPromptsResult,
|
||||
ListResourcesRequest,
|
||||
ListResourcesResult,
|
||||
ListToolsRequest,
|
||||
ListToolsResult,
|
||||
PaginatedRequestParams,
|
||||
ServerResult,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_prompts_pagination() -> None:
|
||||
server = Server("test")
|
||||
test_cursor = "test-cursor-123"
|
||||
|
||||
# Track what request was received
|
||||
received_request: ListPromptsRequest | None = None
|
||||
|
||||
@server.list_prompts()
|
||||
async def handle_list_prompts(request: ListPromptsRequest) -> ListPromptsResult:
|
||||
nonlocal received_request
|
||||
received_request = request
|
||||
return ListPromptsResult(prompts=[], nextCursor="next")
|
||||
|
||||
handler = server.request_handlers[ListPromptsRequest]
|
||||
|
||||
# Test: No cursor provided -> handler receives request with None params
|
||||
request = ListPromptsRequest(method="prompts/list", params=None)
|
||||
result = await handler(request)
|
||||
assert received_request is not None
|
||||
assert received_request.params is None
|
||||
assert isinstance(result, ServerResult)
|
||||
|
||||
# Test: Cursor provided -> handler receives request with cursor in params
|
||||
request_with_cursor = ListPromptsRequest(method="prompts/list", params=PaginatedRequestParams(cursor=test_cursor))
|
||||
result2 = await handler(request_with_cursor)
|
||||
assert received_request is not None
|
||||
assert received_request.params is not None
|
||||
assert received_request.params.cursor == test_cursor
|
||||
assert isinstance(result2, ServerResult)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_resources_pagination() -> None:
|
||||
server = Server("test")
|
||||
test_cursor = "resource-cursor-456"
|
||||
|
||||
# Track what request was received
|
||||
received_request: ListResourcesRequest | None = None
|
||||
|
||||
@server.list_resources()
|
||||
async def handle_list_resources(request: ListResourcesRequest) -> ListResourcesResult:
|
||||
nonlocal received_request
|
||||
received_request = request
|
||||
return ListResourcesResult(resources=[], nextCursor="next")
|
||||
|
||||
handler = server.request_handlers[ListResourcesRequest]
|
||||
|
||||
# Test: No cursor provided -> handler receives request with None params
|
||||
request = ListResourcesRequest(method="resources/list", params=None)
|
||||
result = await handler(request)
|
||||
assert received_request is not None
|
||||
assert received_request.params is None
|
||||
assert isinstance(result, ServerResult)
|
||||
|
||||
# Test: Cursor provided -> handler receives request with cursor in params
|
||||
request_with_cursor = ListResourcesRequest(
|
||||
method="resources/list", params=PaginatedRequestParams(cursor=test_cursor)
|
||||
)
|
||||
result2 = await handler(request_with_cursor)
|
||||
assert received_request is not None
|
||||
assert received_request.params is not None
|
||||
assert received_request.params.cursor == test_cursor
|
||||
assert isinstance(result2, ServerResult)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_tools_pagination() -> None:
|
||||
server = Server("test")
|
||||
test_cursor = "tools-cursor-789"
|
||||
|
||||
# Track what request was received
|
||||
received_request: ListToolsRequest | None = None
|
||||
|
||||
@server.list_tools()
|
||||
async def handle_list_tools(request: ListToolsRequest) -> ListToolsResult:
|
||||
nonlocal received_request
|
||||
received_request = request
|
||||
return ListToolsResult(tools=[], nextCursor="next")
|
||||
|
||||
handler = server.request_handlers[ListToolsRequest]
|
||||
|
||||
# Test: No cursor provided -> handler receives request with None params
|
||||
request = ListToolsRequest(method="tools/list", params=None)
|
||||
result = await handler(request)
|
||||
assert received_request is not None
|
||||
assert received_request.params is None
|
||||
assert isinstance(result, ServerResult)
|
||||
|
||||
# Test: Cursor provided -> handler receives request with cursor in params
|
||||
request_with_cursor = ListToolsRequest(method="tools/list", params=PaginatedRequestParams(cursor=test_cursor))
|
||||
result2 = await handler(request_with_cursor)
|
||||
assert received_request is not None
|
||||
assert received_request.params is not None
|
||||
assert received_request.params.cursor == test_cursor
|
||||
assert isinstance(result2, ServerResult)
|
||||
Loading…
Reference in a new issue