refactor: replace lowlevel Server decorators with on_* constructor kwargs (#1985)
This commit is contained in:
parent
d6d3ad9a7c
commit
0a22a9dc33
16 changed files with 3563 additions and 5481 deletions
|
|
@ -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)
|
||||
|
|
@ -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 == []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue