refactor: replace lowlevel Server decorators with on_* constructor kwargs (#1985)

This commit is contained in:
Max Isbey 2026-02-12 15:55:54 +00:00 • committed by GitHub
commit 0a22a9dc33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 3563 additions and 5481 deletions

View file

@ -1,292 +0,0 @@
"""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] # pragma: no cover
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]: # pragma: no cover
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] # pragma: no cover
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]: # pragma: no cover
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]: # pragma: no cover
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]: # pragma: no cover
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)

View file

@ -1,20 +1,16 @@
"""Basic tests for list_prompts, list_resources, and list_tools decorators without pagination."""
import warnings
"""Basic tests for list_prompts, list_resources, and list_tools handlers without pagination."""
import pytest
from mcp.server import Server
from mcp import Client
from mcp.server import Server, ServerRequestContext
from mcp.types import (
ListPromptsRequest,
ListPromptsResult,
ListResourcesRequest,
ListResourcesResult,
ListToolsRequest,
ListToolsResult,
PaginatedRequestParams,
Prompt,
Resource,
ServerResult,
Tool,
)
@ -22,60 +18,44 @@ from mcp.types import (
@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)
async def handle_list_prompts(
ctx: ServerRequestContext, params: PaginatedRequestParams | None
) -> ListPromptsResult:
return ListPromptsResult(prompts=test_prompts)
@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, ListPromptsResult)
assert result.prompts == test_prompts
server = Server("test", on_list_prompts=handle_list_prompts)
async with Client(server) as client:
result = await client.list_prompts()
assert result.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="file:///test1.txt", name="Test 1"),
Resource(uri="file:///test2.txt", name="Test 2"),
]
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
async def handle_list_resources(
ctx: ServerRequestContext, params: PaginatedRequestParams | None
) -> ListResourcesResult:
return ListResourcesResult(resources=test_resources)
@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, ListResourcesResult)
assert result.resources == test_resources
server = Server("test", on_list_resources=handle_list_resources)
async with Client(server) as client:
result = await client.list_resources()
assert result.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",
@ -102,80 +82,53 @@ async def test_list_tools_basic() -> None:
),
]
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=test_tools)
@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, ListToolsResult)
assert result.tools == test_tools
server = Server("test", on_list_tools=handle_list_tools)
async with Client(server) as client:
result = await client.list_tools()
assert result.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)
async def handle_list_prompts(
ctx: ServerRequestContext, params: PaginatedRequestParams | None
) -> ListPromptsResult:
return ListPromptsResult(prompts=[])
@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, ListPromptsResult)
assert result.prompts == []
server = Server("test", on_list_prompts=handle_list_prompts)
async with Client(server) as client:
result = await client.list_prompts()
assert result.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)
async def handle_list_resources(
ctx: ServerRequestContext, params: PaginatedRequestParams | None
) -> ListResourcesResult:
return ListResourcesResult(resources=[])
@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, ListResourcesResult)
assert result.resources == []
server = Server("test", on_list_resources=handle_list_resources)
async with Client(server) as client:
result = await client.list_resources()
assert result.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)
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[])
@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, ListToolsResult)
assert result.tools == []
server = Server("test", on_list_tools=handle_list_tools)
async with Client(server) as client:
result = await client.list_tools()
assert result.tools == []

View file

@ -1,111 +1,83 @@
import pytest
from mcp.server import Server
from mcp import Client
from mcp.server import Server, ServerRequestContext
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"
received_params: PaginatedRequestParams | None = None
# 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
async def handle_list_prompts(
ctx: ServerRequestContext, params: PaginatedRequestParams | None
) -> ListPromptsResult:
nonlocal received_params
received_params = params
return ListPromptsResult(prompts=[], next_cursor="next")
handler = server.request_handlers[ListPromptsRequest]
server = Server("test", on_list_prompts=handle_list_prompts)
async with Client(server) as client:
# No cursor provided
await client.list_prompts()
assert received_params is not None
assert received_params.cursor is None
# 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)
# Cursor provided
await client.list_prompts(cursor=test_cursor)
assert received_params is not None
assert received_params.cursor == test_cursor
@pytest.mark.anyio
async def test_list_resources_pagination() -> None:
server = Server("test")
test_cursor = "resource-cursor-456"
received_params: PaginatedRequestParams | None = None
# 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
async def handle_list_resources(
ctx: ServerRequestContext, params: PaginatedRequestParams | None
) -> ListResourcesResult:
nonlocal received_params
received_params = params
return ListResourcesResult(resources=[], next_cursor="next")
handler = server.request_handlers[ListResourcesRequest]
server = Server("test", on_list_resources=handle_list_resources)
async with Client(server) as client:
# No cursor provided
await client.list_resources()
assert received_params is not None
assert received_params.cursor is None
# 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)
# Cursor provided
await client.list_resources(cursor=test_cursor)
assert received_params is not None
assert received_params.cursor == test_cursor
@pytest.mark.anyio
async def test_list_tools_pagination() -> None:
server = Server("test")
test_cursor = "tools-cursor-789"
received_params: PaginatedRequestParams | None = None
# 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
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
nonlocal received_params
received_params = params
return ListToolsResult(tools=[], next_cursor="next")
handler = server.request_handlers[ListToolsRequest]
server = Server("test", on_list_tools=handle_list_tools)
async with Client(server) as client:
# No cursor provided
await client.list_tools()
assert received_params is not None
assert received_params.cursor is None
# 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)
# Cursor provided
await client.list_tools(cursor=test_cursor)
assert received_params is not None
assert received_params.cursor == test_cursor

View file

@ -21,7 +21,8 @@ from mcp.server.auth.provider import (
RefreshToken,
construct_redirect_uri,
)
from mcp.server.auth.routes import ClientRegistrationOptions, RevocationOptions, create_auth_routes
from mcp.server.auth.routes import create_auth_routes
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken

View file

@ -2,8 +2,8 @@ from typing import Any
import pytest
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, Prompt, TextContent, UserMessage
from mcp.types import EmbeddedResource, TextResourceContents
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, Prompt, UserMessage
from mcp.types import EmbeddedResource, TextContent, TextResourceContents
class TestRenderPrompt:

View file

@ -1,7 +1,8 @@
import pytest
from mcp.server.mcpserver.prompts.base import Prompt, TextContent, UserMessage
from mcp.server.mcpserver.prompts.base import Prompt, UserMessage
from mcp.server.mcpserver.prompts.manager import PromptManager
from mcp.types import TextContent
class TestPromptManager:

View file

@ -21,6 +21,9 @@ from mcp.shared.exceptions import MCPError
from mcp.types import (
AudioContent,
BlobResourceContents,
Completion,
CompletionArgument,
CompletionContext,
ContentBlock,
EmbeddedResource,
GetPromptResult,
@ -30,6 +33,7 @@ from mcp.types import (
Prompt,
PromptArgument,
PromptMessage,
PromptReference,
ReadResourceResult,
Resource,
ResourceTemplate,
@ -1401,6 +1405,23 @@ class TestServerPrompts:
await client.get_prompt("prompt_fn")
async def test_completion_decorator() -> None:
"""Test that the completion decorator registers a working handler."""
mcp = MCPServer()
@mcp.completion()
async def handle_completion(
ref: PromptReference, argument: CompletionArgument, context: CompletionContext | None
) -> Completion:
assert argument.name == "style"
return Completion(values=["bold", "italic", "underline"])
async with Client(mcp) as client:
ref = PromptReference(type="ref/prompt", name="test")
result = await client.complete(ref=ref, argument={"name": "style", "value": "b"})
assert result.completion.values == ["bold", "italic", "underline"]
def test_streamable_http_no_redirect() -> None:
"""Test that streamable HTTP routes are correctly configured."""
mcp = MCPServer()

View file

@ -1,12 +1,10 @@
"""Test that cancelled requests don't cause double responses."""
from typing import Any
import anyio
import pytest
from mcp import Client, types
from mcp.server.lowlevel.server import Server
from mcp import Client
from mcp.server import Server, ServerRequestContext
from mcp.shared.exceptions import MCPError
from mcp.types import (
CallToolRequest,
@ -14,6 +12,9 @@ from mcp.types import (
CallToolResult,
CancelledNotification,
CancelledNotificationParams,
ListToolsResult,
PaginatedRequestParams,
TextContent,
Tool,
)
@ -22,34 +23,34 @@ from mcp.types import (
async def test_server_remains_functional_after_cancel():
"""Verify server can handle new requests after a cancellation."""
server = Server("test-server")
# Track tool calls
call_count = 0
ev_first_call = anyio.Event()
first_request_id = None
@server.list_tools()
async def handle_list_tools() -> list[Tool]:
return [
Tool(
name="test_tool",
description="Tool for testing",
input_schema={},
)
]
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(
tools=[
Tool(
name="test_tool",
description="Tool for testing",
input_schema={},
)
]
)
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.TextContent]:
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
nonlocal call_count, first_request_id
if name == "test_tool":
if params.name == "test_tool":
call_count += 1
if call_count == 1:
first_request_id = server.request_context.request_id
first_request_id = ctx.request_id
ev_first_call.set()
await anyio.sleep(5) # First call is slow
return [types.TextContent(type="text", text=f"Call number: {call_count}")]
raise ValueError(f"Unknown tool: {name}") # pragma: no cover
return CallToolResult(content=[TextContent(type="text", text=f"Call number: {call_count}")])
raise ValueError(f"Unknown tool: {params.name}") # pragma: no cover
server = Server("test-server", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool)
async with Client(server) as client:
# First request (will be cancelled)
@ -86,6 +87,6 @@ async def test_server_remains_functional_after_cancel():
# Type narrowing for pyright
content = result.content[0]
assert content.type == "text"
assert isinstance(content, types.TextContent)
assert isinstance(content, TextContent)
assert content.text == "Call number: 2"
assert call_count == 2

View file

@ -1,15 +1,13 @@
"""Tests for completion handler with context functionality."""
from typing import Any
import pytest
from mcp import Client
from mcp.server.lowlevel import Server
from mcp.server import Server, ServerRequestContext
from mcp.types import (
CompleteRequestParams,
CompleteResult,
Completion,
CompletionArgument,
CompletionContext,
PromptReference,
ResourceTemplateReference,
)
@ -18,23 +16,15 @@ from mcp.types import (
@pytest.mark.anyio
async def test_completion_handler_receives_context():
"""Test that the completion handler receives context correctly."""
server = Server("test-server")
# Track what the handler receives
received_args: dict[str, Any] = {}
received_params: CompleteRequestParams | None = None
@server.completion()
async def handle_completion(
ref: PromptReference | ResourceTemplateReference,
argument: CompletionArgument,
context: CompletionContext | None,
) -> Completion | None:
received_args["ref"] = ref
received_args["argument"] = argument
received_args["context"] = context
async def handle_completion(ctx: ServerRequestContext, params: CompleteRequestParams) -> CompleteResult:
nonlocal received_params
received_params = params
return CompleteResult(completion=Completion(values=["test-completion"], total=1, has_more=False))
# Return test completion
return Completion(values=["test-completion"], total=1, has_more=False)
server = Server("test-server", on_completion=handle_completion)
async with Client(server) as client:
# Test with context
@ -45,28 +35,23 @@ async def test_completion_handler_receives_context():
)
# Verify handler received the context
assert received_args["context"] is not None
assert received_args["context"].arguments == {"previous": "value"}
assert received_params is not None
assert received_params.context is not None
assert received_params.context.arguments == {"previous": "value"}
assert result.completion.values == ["test-completion"]
@pytest.mark.anyio
async def test_completion_backward_compatibility():
"""Test that completion works without context (backward compatibility)."""
server = Server("test-server")
context_was_none = False
@server.completion()
async def handle_completion(
ref: PromptReference | ResourceTemplateReference,
argument: CompletionArgument,
context: CompletionContext | None,
) -> Completion | None:
async def handle_completion(ctx: ServerRequestContext, params: CompleteRequestParams) -> CompleteResult:
nonlocal context_was_none
context_was_none = context is None
context_was_none = params.context is None
return CompleteResult(completion=Completion(values=["no-context-completion"], total=1, has_more=False))
return Completion(values=["no-context-completion"], total=1, has_more=False)
server = Server("test-server", on_completion=handle_completion)
async with Client(server) as client:
# Test without context
@ -82,30 +67,31 @@ async def test_completion_backward_compatibility():
@pytest.mark.anyio
async def test_dependent_completion_scenario():
"""Test a real-world scenario with dependent completions."""
server = Server("test-server")
@server.completion()
async def handle_completion(
ref: PromptReference | ResourceTemplateReference,
argument: CompletionArgument,
context: CompletionContext | None,
) -> Completion | None:
async def handle_completion(ctx: ServerRequestContext, params: CompleteRequestParams) -> CompleteResult:
# Simulate database/table completion scenario
if isinstance(ref, ResourceTemplateReference):
if ref.uri == "db://{database}/{table}":
if argument.name == "database":
# Complete database names
return Completion(values=["users_db", "products_db", "analytics_db"], total=3, has_more=False)
elif argument.name == "table":
# Complete table names based on selected database
if context and context.arguments:
db = context.arguments.get("database")
if db == "users_db":
return Completion(values=["users", "sessions", "permissions"], total=3, has_more=False)
elif db == "products_db":
return Completion(values=["products", "categories", "inventory"], total=3, has_more=False)
assert isinstance(params.ref, ResourceTemplateReference)
assert params.ref.uri == "db://{database}/{table}"
return Completion(values=[], total=0, has_more=False) # pragma: no cover
if params.argument.name == "database":
return CompleteResult(
completion=Completion(values=["users_db", "products_db", "analytics_db"], total=3, has_more=False)
)
assert params.argument.name == "table"
assert params.context and params.context.arguments
db = params.context.arguments.get("database")
if db == "users_db":
return CompleteResult(
completion=Completion(values=["users", "sessions", "permissions"], total=3, has_more=False)
)
else:
assert db == "products_db"
return CompleteResult(
completion=Completion(values=["products", "categories", "inventory"], total=3, has_more=False)
)
server = Server("test-server", on_completion=handle_completion)
async with Client(server) as client:
# First, complete database
@ -136,27 +122,20 @@ async def test_dependent_completion_scenario():
@pytest.mark.anyio
async def test_completion_error_on_missing_context():
"""Test that server can raise error when required context is missing."""
server = Server("test-server")
@server.completion()
async def handle_completion(
ref: PromptReference | ResourceTemplateReference,
argument: CompletionArgument,
context: CompletionContext | None,
) -> Completion | None:
if isinstance(ref, ResourceTemplateReference):
if ref.uri == "db://{database}/{table}":
if argument.name == "table":
# Check if database context is provided
if not context or not context.arguments or "database" not in context.arguments:
# Raise an error instead of returning error as completion
raise ValueError("Please select a database first to see available tables")
# Normal completion if context is provided
db = context.arguments.get("database")
if db == "test_db":
return Completion(values=["users", "orders", "products"], total=3, has_more=False)
async def handle_completion(ctx: ServerRequestContext, params: CompleteRequestParams) -> CompleteResult:
assert isinstance(params.ref, ResourceTemplateReference)
assert params.ref.uri == "db://{database}/{table}"
assert params.argument.name == "table"
return Completion(values=[], total=0, has_more=False) # pragma: no cover
if not params.context or not params.context.arguments or "database" not in params.context.arguments:
raise ValueError("Please select a database first to see available tables")
db = params.context.arguments.get("database")
assert db == "test_db"
return CompleteResult(completion=Completion(values=["users", "orders", "products"], total=3, has_more=False))
server = Server("test-server", on_completion=handle_completion)
async with Client(server) as client:
# Try to complete table without database context - should raise error

View file

@ -2,18 +2,20 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
import anyio
import pytest
from pydantic import TypeAdapter
from mcp.server import ServerRequestContext
from mcp.server.lowlevel.server import NotificationOptions, Server
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.models import InitializationOptions
from mcp.server.session import ServerSession
from mcp.shared.message import SessionMessage
from mcp.types import (
CallToolRequestParams,
CallToolResult,
ClientCapabilities,
Implementation,
InitializeRequestParams,
@ -39,21 +41,21 @@ async def test_lowlevel_server_lifespan():
finally:
context["shutdown"] = True
server = Server[dict[str, bool]]("test", lifespan=test_lifespan)
# Create a tool that accesses lifespan context
async def check_lifespan(
ctx: ServerRequestContext[dict[str, bool]], params: CallToolRequestParams
) -> CallToolResult:
assert isinstance(ctx.lifespan_context, dict)
assert ctx.lifespan_context["started"]
assert not ctx.lifespan_context["shutdown"]
return CallToolResult(content=[TextContent(type="text", text="true")])
server = Server[dict[str, bool]]("test", lifespan=test_lifespan, on_call_tool=check_lifespan)
# Create memory streams for testing
send_stream1, receive_stream1 = anyio.create_memory_object_stream[SessionMessage](100)
send_stream2, receive_stream2 = anyio.create_memory_object_stream[SessionMessage](100)
# Create a tool that accesses lifespan context
@server.call_tool()
async def check_lifespan(name: str, arguments: dict[str, Any]) -> list[TextContent]:
ctx = server.request_context
assert isinstance(ctx.lifespan_context, dict)
assert ctx.lifespan_context["started"]
assert not ctx.lifespan_context["shutdown"]
return [TextContent(type="text", text="true")]
# Run server in background task
async with anyio.create_task_group() as tg, send_stream1, receive_stream1, send_stream2, receive_stream2:

View file

@ -1,311 +0,0 @@
"""Test input schema validation for lowlevel server."""
import logging
from collections.abc import Awaitable, Callable
from typing import Any
import anyio
import pytest
from mcp.client.session import ClientSession
from mcp.server import Server
from mcp.server.lowlevel import NotificationOptions
from mcp.server.models import InitializationOptions
from mcp.server.session import ServerSession
from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder
from mcp.types import CallToolResult, ClientResult, ServerNotification, ServerRequest, TextContent, Tool
async def run_tool_test(
tools: list[Tool],
call_tool_handler: Callable[[str, dict[str, Any]], Awaitable[list[TextContent]]],
test_callback: Callable[[ClientSession], Awaitable[CallToolResult]],
) -> CallToolResult | None:
"""Helper to run a tool test with minimal boilerplate.
Args:
tools: List of tools to register
call_tool_handler: Handler function for tool calls
test_callback: Async function that performs the test using the client session
Returns:
The result of the tool call
"""
server = Server("test")
result = None
@server.list_tools()
async def list_tools():
return tools
@server.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
return await call_tool_handler(name, arguments)
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10)
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10)
# Message handler for client
async def message_handler(
message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception,
) -> None:
if isinstance(message, Exception): # pragma: no cover
raise message
# Server task
async def run_server():
async with ServerSession(
client_to_server_receive,
server_to_client_send,
InitializationOptions(
server_name="test-server",
server_version="1.0.0",
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
),
) as server_session:
async with anyio.create_task_group() as tg:
async def handle_messages():
async for message in server_session.incoming_messages: # pragma: no branch
await server._handle_message(message, server_session, {}, False)
tg.start_soon(handle_messages)
await anyio.sleep_forever()
# Run the test
async with anyio.create_task_group() as tg:
tg.start_soon(run_server)
async with ClientSession(
server_to_client_receive,
client_to_server_send,
message_handler=message_handler,
) as client_session:
# Initialize the session
await client_session.initialize()
# Run the test callback
result = await test_callback(client_session)
# Cancel the server task
tg.cancel_scope.cancel()
return result
def create_add_tool() -> Tool:
"""Create a standard 'add' tool for testing."""
return Tool(
name="add",
description="Add two numbers",
input_schema={
"type": "object",
"properties": {
"a": {"type": "number"},
"b": {"type": "number"},
},
"required": ["a", "b"],
"additionalProperties": False,
},
)
@pytest.mark.anyio
async def test_valid_tool_call():
"""Test that valid arguments pass validation."""
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]:
if name == "add":
result = arguments["a"] + arguments["b"]
return [TextContent(type="text", text=f"Result: {result}")]
else: # pragma: no cover
raise ValueError(f"Unknown tool: {name}")
async def test_callback(client_session: ClientSession) -> CallToolResult:
return await client_session.call_tool("add", {"a": 5, "b": 3})
result = await run_tool_test([create_add_tool()], call_tool_handler, test_callback)
# Verify results
assert result is not None
assert not result.is_error
assert len(result.content) == 1
assert result.content[0].type == "text"
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Result: 8"
@pytest.mark.anyio
async def test_invalid_tool_call_missing_required():
"""Test that missing required arguments fail validation."""
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: # pragma: no cover
# This should not be reached due to validation
raise RuntimeError("Should not reach here")
async def test_callback(client_session: ClientSession) -> CallToolResult:
return await client_session.call_tool("add", {"a": 5}) # missing 'b'
result = await run_tool_test([create_add_tool()], call_tool_handler, test_callback)
# Verify results
assert result is not None
assert result.is_error
assert len(result.content) == 1
assert result.content[0].type == "text"
assert isinstance(result.content[0], TextContent)
assert "Input validation error" in result.content[0].text
assert "'b' is a required property" in result.content[0].text
@pytest.mark.anyio
async def test_invalid_tool_call_wrong_type():
"""Test that wrong argument types fail validation."""
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: # pragma: no cover
# This should not be reached due to validation
raise RuntimeError("Should not reach here")
async def test_callback(client_session: ClientSession) -> CallToolResult:
return await client_session.call_tool("add", {"a": "five", "b": 3}) # 'a' should be number
result = await run_tool_test([create_add_tool()], call_tool_handler, test_callback)
# Verify results
assert result is not None
assert result.is_error
assert len(result.content) == 1
assert result.content[0].type == "text"
assert isinstance(result.content[0], TextContent)
assert "Input validation error" in result.content[0].text
assert "'five' is not of type 'number'" in result.content[0].text
@pytest.mark.anyio
async def test_cache_refresh_on_missing_tool():
"""Test that tool cache is refreshed when tool is not found."""
tools = [
Tool(
name="multiply",
description="Multiply two numbers",
input_schema={
"type": "object",
"properties": {
"x": {"type": "number"},
"y": {"type": "number"},
},
"required": ["x", "y"],
},
)
]
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]:
if name == "multiply":
result = arguments["x"] * arguments["y"]
return [TextContent(type="text", text=f"Result: {result}")]
else: # pragma: no cover
raise ValueError(f"Unknown tool: {name}")
async def test_callback(client_session: ClientSession) -> CallToolResult:
# Call tool without first listing tools (cache should be empty)
# The cache should be refreshed automatically
return await client_session.call_tool("multiply", {"x": 10, "y": 20})
result = await run_tool_test(tools, call_tool_handler, test_callback)
# Verify results - should work because cache will be refreshed
assert result is not None
assert not result.is_error
assert len(result.content) == 1
assert result.content[0].type == "text"
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Result: 200"
@pytest.mark.anyio
async def test_enum_constraint_validation():
"""Test that enum constraints are validated."""
tools = [
Tool(
name="greet",
description="Greet someone",
input_schema={
"type": "object",
"properties": {
"name": {"type": "string"},
"title": {"type": "string", "enum": ["Mr", "Ms", "Dr"]},
},
"required": ["name"],
},
)
]
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: # pragma: no cover
# This should not be reached due to validation failure
raise RuntimeError("Should not reach here")
async def test_callback(client_session: ClientSession) -> CallToolResult:
return await client_session.call_tool("greet", {"name": "Smith", "title": "Prof"}) # Invalid title
result = await run_tool_test(tools, call_tool_handler, test_callback)
# Verify results
assert result is not None
assert result.is_error
assert len(result.content) == 1
assert result.content[0].type == "text"
assert isinstance(result.content[0], TextContent)
assert "Input validation error" in result.content[0].text
assert "'Prof' is not one of" in result.content[0].text
@pytest.mark.anyio
async def test_tool_not_in_list_logs_warning(caplog: pytest.LogCaptureFixture):
"""Test that calling a tool not in list_tools logs a warning and skips validation."""
tools = [
Tool(
name="add",
description="Add two numbers",
input_schema={
"type": "object",
"properties": {
"a": {"type": "number"},
"b": {"type": "number"},
},
"required": ["a", "b"],
},
)
]
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]:
# This should be reached since validation is skipped for unknown tools
if name == "unknown_tool":
# Even with invalid arguments, this should execute since validation is skipped
return [TextContent(type="text", text="Unknown tool executed without validation")]
else: # pragma: no cover
raise ValueError(f"Unknown tool: {name}")
async def test_callback(client_session: ClientSession) -> CallToolResult:
# Call a tool that's not in the list with invalid arguments
# This should trigger the warning about validation not being performed
return await client_session.call_tool("unknown_tool", {"invalid": "args"})
with caplog.at_level(logging.WARNING):
result = await run_tool_test(tools, call_tool_handler, test_callback)
# Verify results - should succeed because validation is skipped for unknown tools
assert result is not None
assert not result.is_error
assert len(result.content) == 1
assert result.content[0].type == "text"
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Unknown tool executed without validation"
# Verify warning was logged
assert any(
"Tool 'unknown_tool' not listed, no validation will be performed" in record.message for record in caplog.records
)

View file

@ -1,476 +0,0 @@
"""Test output schema validation for lowlevel server."""
import json
from collections.abc import Awaitable, Callable
from typing import Any
import anyio
import pytest
from mcp.client.session import ClientSession
from mcp.server import Server
from mcp.server.lowlevel import NotificationOptions
from mcp.server.models import InitializationOptions
from mcp.server.session import ServerSession
from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder
from mcp.types import CallToolResult, ClientResult, ServerNotification, ServerRequest, TextContent, Tool
async def run_tool_test(
tools: list[Tool],
call_tool_handler: Callable[[str, dict[str, Any]], Awaitable[Any]],
test_callback: Callable[[ClientSession], Awaitable[CallToolResult]],
) -> CallToolResult | None:
"""Helper to run a tool test with minimal boilerplate.
Args:
tools: List of tools to register
call_tool_handler: Handler function for tool calls
test_callback: Async function that performs the test using the client session
Returns:
The result of the tool call
"""
server = Server("test")
result = None
@server.list_tools()
async def list_tools():
return tools
@server.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]):
return await call_tool_handler(name, arguments)
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10)
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10)
# Message handler for client
async def message_handler( # pragma: no cover
message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception,
) -> None:
if isinstance(message, Exception):
raise message
# Server task
async def run_server():
async with ServerSession(
client_to_server_receive,
server_to_client_send,
InitializationOptions(
server_name="test-server",
server_version="1.0.0",
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
),
) as server_session:
async with anyio.create_task_group() as tg:
async def handle_messages():
async for message in server_session.incoming_messages: # pragma: no branch
await server._handle_message(message, server_session, {}, False)
tg.start_soon(handle_messages)
await anyio.sleep_forever()
# Run the test
async with anyio.create_task_group() as tg:
tg.start_soon(run_server)
async with ClientSession(
server_to_client_receive,
client_to_server_send,
message_handler=message_handler,
) as client_session:
# Initialize the session
await client_session.initialize()
# Run the test callback
result = await test_callback(client_session)
# Cancel the server task
tg.cancel_scope.cancel()
return result
@pytest.mark.anyio
async def test_content_only_without_output_schema():
"""Test returning content only when no outputSchema is defined."""
tools = [
Tool(
name="echo",
description="Echo a message",
input_schema={
"type": "object",
"properties": {
"message": {"type": "string"},
},
"required": ["message"],
},
# No outputSchema defined
)
]
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]:
if name == "echo":
return [TextContent(type="text", text=f"Echo: {arguments['message']}")]
else: # pragma: no cover
raise ValueError(f"Unknown tool: {name}")
async def test_callback(client_session: ClientSession) -> CallToolResult:
return await client_session.call_tool("echo", {"message": "Hello"})
result = await run_tool_test(tools, call_tool_handler, test_callback)
# Verify results
assert result is not None
assert not result.is_error
assert len(result.content) == 1
assert result.content[0].type == "text"
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Echo: Hello"
assert result.structured_content is None
@pytest.mark.anyio
async def test_dict_only_without_output_schema():
"""Test returning dict only when no outputSchema is defined."""
tools = [
Tool(
name="get_info",
description="Get structured information",
input_schema={
"type": "object",
"properties": {},
},
# No outputSchema defined
)
]
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
if name == "get_info":
return {"status": "ok", "data": {"value": 42}}
else: # pragma: no cover
raise ValueError(f"Unknown tool: {name}")
async def test_callback(client_session: ClientSession) -> CallToolResult:
return await client_session.call_tool("get_info", {})
result = await run_tool_test(tools, call_tool_handler, test_callback)
# Verify results
assert result is not None
assert not result.is_error
assert len(result.content) == 1
assert result.content[0].type == "text"
assert isinstance(result.content[0], TextContent)
# Check that the content is the JSON serialization
assert json.loads(result.content[0].text) == {"status": "ok", "data": {"value": 42}}
assert result.structured_content == {"status": "ok", "data": {"value": 42}}
@pytest.mark.anyio
async def test_both_content_and_dict_without_output_schema():
"""Test returning both content and dict when no outputSchema is defined."""
tools = [
Tool(
name="process",
description="Process data",
input_schema={
"type": "object",
"properties": {},
},
# No outputSchema defined
)
]
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> tuple[list[TextContent], dict[str, Any]]:
if name == "process":
content = [TextContent(type="text", text="Processing complete")]
data = {"result": "success", "count": 10}
return (content, data)
else: # pragma: no cover
raise ValueError(f"Unknown tool: {name}")
async def test_callback(client_session: ClientSession) -> CallToolResult:
return await client_session.call_tool("process", {})
result = await run_tool_test(tools, call_tool_handler, test_callback)
# Verify results
assert result is not None
assert not result.is_error
assert len(result.content) == 1
assert result.content[0].type == "text"
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Processing complete"
assert result.structured_content == {"result": "success", "count": 10}
@pytest.mark.anyio
async def test_content_only_with_output_schema_error():
"""Test error when outputSchema is defined but only content is returned."""
tools = [
Tool(
name="structured_tool",
description="Tool expecting structured output",
input_schema={
"type": "object",
"properties": {},
},
output_schema={
"type": "object",
"properties": {
"result": {"type": "string"},
},
"required": ["result"],
},
)
]
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]:
# This returns only content, but outputSchema expects structured data
return [TextContent(type="text", text="This is not structured")]
async def test_callback(client_session: ClientSession) -> CallToolResult:
return await client_session.call_tool("structured_tool", {})
result = await run_tool_test(tools, call_tool_handler, test_callback)
# Verify error
assert result is not None
assert result.is_error
assert len(result.content) == 1
assert result.content[0].type == "text"
assert isinstance(result.content[0], TextContent)
assert "Output validation error: outputSchema defined but no structured output returned" in result.content[0].text
@pytest.mark.anyio
async def test_valid_dict_with_output_schema():
"""Test valid dict output matching outputSchema."""
tools = [
Tool(
name="calc",
description="Calculate result",
input_schema={
"type": "object",
"properties": {
"x": {"type": "number"},
"y": {"type": "number"},
},
"required": ["x", "y"],
},
output_schema={
"type": "object",
"properties": {
"sum": {"type": "number"},
"product": {"type": "number"},
},
"required": ["sum", "product"],
},
)
]
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
if name == "calc":
x = arguments["x"]
y = arguments["y"]
return {"sum": x + y, "product": x * y}
else: # pragma: no cover
raise ValueError(f"Unknown tool: {name}")
async def test_callback(client_session: ClientSession) -> CallToolResult:
return await client_session.call_tool("calc", {"x": 3, "y": 4})
result = await run_tool_test(tools, call_tool_handler, test_callback)
# Verify results
assert result is not None
assert not result.is_error
assert len(result.content) == 1
assert result.content[0].type == "text"
# Check JSON serialization
assert json.loads(result.content[0].text) == {"sum": 7, "product": 12}
assert result.structured_content == {"sum": 7, "product": 12}
@pytest.mark.anyio
async def test_invalid_dict_with_output_schema():
"""Test dict output that doesn't match outputSchema."""
tools = [
Tool(
name="user_info",
description="Get user information",
input_schema={
"type": "object",
"properties": {},
},
output_schema={
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
},
"required": ["name", "age"],
},
)
]
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
if name == "user_info":
# Missing required 'age' field
return {"name": "Alice"}
else: # pragma: no cover
raise ValueError(f"Unknown tool: {name}")
async def test_callback(client_session: ClientSession) -> CallToolResult:
return await client_session.call_tool("user_info", {})
result = await run_tool_test(tools, call_tool_handler, test_callback)
# Verify error
assert result is not None
assert result.is_error
assert len(result.content) == 1
assert result.content[0].type == "text"
assert isinstance(result.content[0], TextContent)
assert "Output validation error:" in result.content[0].text
assert "'age' is a required property" in result.content[0].text
@pytest.mark.anyio
async def test_both_content_and_valid_dict_with_output_schema():
"""Test returning both content and valid dict with outputSchema."""
tools = [
Tool(
name="analyze",
description="Analyze data",
input_schema={
"type": "object",
"properties": {
"text": {"type": "string"},
},
"required": ["text"],
},
output_schema={
"type": "object",
"properties": {
"sentiment": {"type": "string", "enum": ["positive", "negative", "neutral"]},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
},
"required": ["sentiment", "confidence"],
},
)
]
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> tuple[list[TextContent], dict[str, Any]]:
if name == "analyze":
content = [TextContent(type="text", text=f"Analysis of: {arguments['text']}")]
data = {"sentiment": "positive", "confidence": 0.95}
return (content, data)
else: # pragma: no cover
raise ValueError(f"Unknown tool: {name}")
async def test_callback(client_session: ClientSession) -> CallToolResult:
return await client_session.call_tool("analyze", {"text": "Great job!"})
result = await run_tool_test(tools, call_tool_handler, test_callback)
# Verify results
assert result is not None
assert not result.is_error
assert len(result.content) == 1
assert result.content[0].type == "text"
assert result.content[0].text == "Analysis of: Great job!"
assert result.structured_content == {"sentiment": "positive", "confidence": 0.95}
@pytest.mark.anyio
async def test_tool_call_result():
"""Test returning ToolCallResult when no outputSchema is defined."""
tools = [
Tool(
name="get_info",
description="Get structured information",
input_schema={
"type": "object",
"properties": {},
},
# No outputSchema for direct return of tool call result
)
]
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> CallToolResult:
if name == "get_info":
return CallToolResult(
content=[TextContent(type="text", text="Results calculated")],
structured_content={"status": "ok", "data": {"value": 42}},
_meta={"some": "metadata"},
)
else: # pragma: no cover
raise ValueError(f"Unknown tool: {name}")
async def test_callback(client_session: ClientSession) -> CallToolResult:
return await client_session.call_tool("get_info", {})
result = await run_tool_test(tools, call_tool_handler, test_callback)
# Verify results
assert result is not None
assert not result.is_error
assert len(result.content) == 1
assert result.content[0].type == "text"
assert result.content[0].text == "Results calculated"
assert isinstance(result.content[0], TextContent)
assert result.structured_content == {"status": "ok", "data": {"value": 42}}
assert result.meta == {"some": "metadata"}
@pytest.mark.anyio
async def test_output_schema_type_validation():
"""Test outputSchema validates types correctly."""
tools = [
Tool(
name="stats",
description="Get statistics",
input_schema={
"type": "object",
"properties": {},
},
output_schema={
"type": "object",
"properties": {
"count": {"type": "integer"},
"average": {"type": "number"},
"items": {"type": "array", "items": {"type": "string"}},
},
"required": ["count", "average", "items"],
},
)
]
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
if name == "stats":
# Wrong type for 'count' - should be integer
return {"count": "five", "average": 2.5, "items": ["a", "b"]}
else: # pragma: no cover
raise ValueError(f"Unknown tool: {name}")
async def test_callback(client_session: ClientSession) -> CallToolResult:
return await client_session.call_tool("stats", {})
result = await run_tool_test(tools, call_tool_handler, test_callback)
# Verify error
assert result is not None
assert result.is_error
assert len(result.content) == 1
assert result.content[0].type == "text"
assert "Output validation error:" in result.content[0].text
assert "'five' is not of type 'integer'" in result.content[0].text

View file

@ -1,100 +1,44 @@
"""Tests for tool annotations in low-level server."""
import anyio
import pytest
from mcp.client.session import ClientSession
from mcp.server import Server
from mcp.server.lowlevel import NotificationOptions
from mcp.server.models import InitializationOptions
from mcp.server.session import ServerSession
from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder
from mcp.types import ClientResult, ServerNotification, ServerRequest, Tool, ToolAnnotations
from mcp import Client
from mcp.server import Server, ServerRequestContext
from mcp.types import ListToolsResult, PaginatedRequestParams, Tool, ToolAnnotations
@pytest.mark.anyio
async def test_lowlevel_server_tool_annotations():
"""Test that tool annotations work in low-level server."""
server = Server("test")
# Create a tool with annotations
@server.list_tools()
async def list_tools():
return [
Tool(
name="echo",
description="Echo a message back",
input_schema={
"type": "object",
"properties": {
"message": {"type": "string"},
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(
tools=[
Tool(
name="echo",
description="Echo a message back",
input_schema={
"type": "object",
"properties": {
"message": {"type": "string"},
},
"required": ["message"],
},
"required": ["message"],
},
annotations=ToolAnnotations(
title="Echo Tool",
read_only_hint=True,
),
)
]
annotations=ToolAnnotations(
title="Echo Tool",
read_only_hint=True,
),
)
]
)
tools_result = None
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10)
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10)
server = Server("test", on_list_tools=handle_list_tools)
# Message handler for client
async def message_handler(
message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception,
) -> None:
if isinstance(message, Exception): # pragma: no cover
raise message
async with Client(server) as client:
tools_result = await client.list_tools()
# Server task
async def run_server():
async with ServerSession(
client_to_server_receive,
server_to_client_send,
InitializationOptions(
server_name="test-server",
server_version="1.0.0",
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
),
) as server_session:
async with anyio.create_task_group() as tg:
async def handle_messages():
async for message in server_session.incoming_messages: # pragma: no branch
await server._handle_message(message, server_session, {}, False)
tg.start_soon(handle_messages)
await anyio.sleep_forever()
# Run the test
async with anyio.create_task_group() as tg:
tg.start_soon(run_server)
async with ClientSession(
server_to_client_receive,
client_to_server_send,
message_handler=message_handler,
) as client_session:
# Initialize the session
await client_session.initialize()
# List tools
tools_result = await client_session.list_tools()
# Cancel the server task
tg.cancel_scope.cancel()
# Verify results
assert tools_result is not None
assert len(tools_result.tools) == 1
assert tools_result.tools[0].name == "echo"
assert tools_result.tools[0].annotations is not None
assert tools_result.tools[0].annotations.title == "Echo Tool"
assert tools_result.tools[0].annotations.read_only_hint is True
assert len(tools_result.tools) == 1
assert tools_result.tools[0].name == "echo"
assert tools_result.tools[0].annotations is not None
assert tools_result.tools[0].annotations.title == "Echo Tool"
assert tools_result.tools[0].annotations.read_only_hint is True

View file

@ -1,106 +1,58 @@
from collections.abc import Iterable
from pathlib import Path
from tempfile import NamedTemporaryFile
import base64
import pytest
from mcp import types
from mcp.server.lowlevel.server import ReadResourceContents, Server
from mcp import Client
from mcp.server import Server, ServerRequestContext
from mcp.types import (
BlobResourceContents,
ReadResourceRequestParams,
ReadResourceResult,
TextResourceContents,
)
pytestmark = pytest.mark.anyio
@pytest.fixture
def temp_file():
"""Create a temporary file for testing."""
with NamedTemporaryFile(mode="w", delete=False) as f:
f.write("test content")
path = Path(f.name).resolve()
yield path
try:
path.unlink()
except FileNotFoundError: # pragma: no cover
pass
async def test_read_resource_text():
async def handle_read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
return ReadResourceResult(
contents=[TextResourceContents(uri=str(params.uri), text="Hello World", mime_type="text/plain")]
)
server = Server("test", on_read_resource=handle_read_resource)
async with Client(server) as client:
result = await client.read_resource("test://resource")
assert len(result.contents) == 1
content = result.contents[0]
assert isinstance(content, TextResourceContents)
assert content.text == "Hello World"
assert content.mime_type == "text/plain"
@pytest.mark.anyio
async def test_read_resource_text(temp_file: Path):
server = Server("test")
async def test_read_resource_binary():
binary_data = b"Hello World"
@server.read_resource()
async def read_resource(uri: str) -> Iterable[ReadResourceContents]:
return [ReadResourceContents(content="Hello World", mime_type="text/plain")]
async def handle_read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
return ReadResourceResult(
contents=[
BlobResourceContents(
uri=str(params.uri),
blob=base64.b64encode(binary_data).decode("utf-8"),
mime_type="application/octet-stream",
)
]
)
# Get the handler directly from the server
handler = server.request_handlers[types.ReadResourceRequest]
server = Server("test", on_read_resource=handle_read_resource)
# Create a request
request = types.ReadResourceRequest(
params=types.ReadResourceRequestParams(uri=temp_file.as_uri()),
)
async with Client(server) as client:
result = await client.read_resource("test://resource")
assert len(result.contents) == 1
# Call the handler
result = await handler(request)
assert isinstance(result, types.ReadResourceResult)
assert len(result.contents) == 1
content = result.contents[0]
assert isinstance(content, types.TextResourceContents)
assert content.text == "Hello World"
assert content.mime_type == "text/plain"
@pytest.mark.anyio
async def test_read_resource_binary(temp_file: Path):
server = Server("test")
@server.read_resource()
async def read_resource(uri: str) -> Iterable[ReadResourceContents]:
return [ReadResourceContents(content=b"Hello World", mime_type="application/octet-stream")]
# Get the handler directly from the server
handler = server.request_handlers[types.ReadResourceRequest]
# Create a request
request = types.ReadResourceRequest(
params=types.ReadResourceRequestParams(uri=temp_file.as_uri()),
)
# Call the handler
result = await handler(request)
assert isinstance(result, types.ReadResourceResult)
assert len(result.contents) == 1
content = result.contents[0]
assert isinstance(content, types.BlobResourceContents)
assert content.mime_type == "application/octet-stream"
@pytest.mark.anyio
async def test_read_resource_default_mime(temp_file: Path):
server = Server("test")
@server.read_resource()
async def read_resource(uri: str) -> Iterable[ReadResourceContents]:
return [
ReadResourceContents(
content="Hello World",
# No mime_type specified, should default to text/plain
)
]
# Get the handler directly from the server
handler = server.request_handlers[types.ReadResourceRequest]
# Create a request
request = types.ReadResourceRequest(
params=types.ReadResourceRequestParams(uri=temp_file.as_uri()),
)
# Call the handler
result = await handler(request)
assert isinstance(result, types.ReadResourceResult)
assert len(result.contents) == 1
content = result.contents[0]
assert isinstance(content, types.TextResourceContents)
assert content.text == "Hello World"
assert content.mime_type == "text/plain"
content = result.contents[0]
assert isinstance(content, BlobResourceContents)
assert content.mime_type == "application/octet-stream"
assert base64.b64decode(content.blob) == binary_data

View file

@ -5,7 +5,7 @@ import pytest
from mcp import types
from mcp.client.session import ClientSession
from mcp.server import Server
from mcp.server import Server, ServerRequestContext
from mcp.server.lowlevel import NotificationOptions
from mcp.server.models import InitializationOptions
from mcp.server.session import ServerSession
@ -14,17 +14,10 @@ from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder
from mcp.types import (
ClientNotification,
Completion,
CompletionArgument,
CompletionContext,
CompletionsCapability,
InitializedNotification,
Prompt,
PromptReference,
PromptsCapability,
Resource,
ResourcesCapability,
ResourceTemplateReference,
ServerCapabilities,
)
@ -85,47 +78,50 @@ async def test_server_session_initialize():
@pytest.mark.anyio
async def test_server_capabilities():
server = Server("test")
notification_options = NotificationOptions()
experimental_capabilities: dict[str, Any] = {}
# Initially no capabilities
async def noop_list_prompts(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListPromptsResult:
raise NotImplementedError
async def noop_list_resources(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListResourcesResult:
raise NotImplementedError
async def noop_completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> types.CompleteResult:
raise NotImplementedError
# No capabilities
server = Server("test")
caps = server.get_capabilities(notification_options, experimental_capabilities)
assert caps.prompts is None
assert caps.resources is None
assert caps.completions is None
# Add a prompts handler
@server.list_prompts()
async def list_prompts() -> list[Prompt]: # pragma: no cover
return []
# With prompts handler
server = Server("test", on_list_prompts=noop_list_prompts)
caps = server.get_capabilities(notification_options, experimental_capabilities)
assert caps.prompts == PromptsCapability(list_changed=False)
assert caps.resources is None
assert caps.completions is None
# Add a resources handler
@server.list_resources()
async def list_resources() -> list[Resource]: # pragma: no cover
return []
# With prompts + resources handlers
server = Server("test", on_list_prompts=noop_list_prompts, on_list_resources=noop_list_resources)
caps = server.get_capabilities(notification_options, experimental_capabilities)
assert caps.prompts == PromptsCapability(list_changed=False)
assert caps.resources == ResourcesCapability(subscribe=False, list_changed=False)
assert caps.completions is None
# Add a complete handler
@server.completion()
async def complete( # pragma: no cover
ref: PromptReference | ResourceTemplateReference,
argument: CompletionArgument,
context: CompletionContext | None,
) -> Completion | None:
return Completion(
values=["completion1", "completion2"],
)
# With prompts + resources + completion handlers
server = Server(
"test",
on_list_prompts=noop_list_prompts,
on_list_resources=noop_list_resources,
on_completion=noop_completion,
)
caps = server.get_capabilities(notification_options, experimental_capabilities)
assert caps.prompts == PromptsCapability(list_changed=False)
assert caps.resources == ResourcesCapability(subscribe=False, list_changed=False)

View file

@ -9,13 +9,12 @@ import httpx
import pytest
from starlette.types import Message
from mcp import Client, types
from mcp import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.server import streamable_http_manager
from mcp.server.lowlevel import Server
from mcp.server import Server, ServerRequestContext, streamable_http_manager
from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, StreamableHTTPServerTransport
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.types import INVALID_REQUEST
from mcp.types import INVALID_REQUEST, ListToolsResult, PaginatedRequestParams
@pytest.mark.anyio
@ -218,7 +217,7 @@ async def test_stateless_requests_memory_cleanup():
# Patch StreamableHTTPServerTransport constructor to track instances
original_constructor = streamable_http_manager.StreamableHTTPServerTransport
original_constructor = StreamableHTTPServerTransport
def track_transport(*args: Any, **kwargs: Any) -> StreamableHTTPServerTransport:
transport = original_constructor(*args, **kwargs)
@ -321,12 +320,11 @@ async def test_unknown_session_id_returns_404():
@pytest.mark.anyio
async def test_e2e_streamable_http_server_cleanup():
host = "testserver"
app = Server("test-server")
@app.list_tools()
async def list_tools(req: types.ListToolsRequest) -> types.ListToolsResult:
return types.ListToolsResult(tools=[])
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[])
app = Server("test-server", on_list_tools=handle_list_tools)
mcp_app = app.streamable_http_app(host=host)
async with (
mcp_app.router.lifespan_context(mcp_app),