798 lines
32 KiB
Python
798 lines
32 KiB
Python
"""Tests for server-side task support (handlers, capabilities, integration)."""
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
import anyio
|
|
import pytest
|
|
|
|
from mcp import Client
|
|
from mcp.client.session import ClientSession
|
|
from mcp.server import Server, ServerRequestContext
|
|
from mcp.server.lowlevel import NotificationOptions
|
|
from mcp.server.models import InitializationOptions
|
|
from mcp.server.session import ServerSession
|
|
from mcp.shared.exceptions import MCPError
|
|
from mcp.shared.message import ServerMessageMetadata, SessionMessage
|
|
from mcp.shared.response_router import ResponseRouter
|
|
from mcp.shared.session import RequestResponder
|
|
from mcp.types import (
|
|
INVALID_REQUEST,
|
|
TASK_FORBIDDEN,
|
|
TASK_OPTIONAL,
|
|
TASK_REQUIRED,
|
|
CallToolRequest,
|
|
CallToolRequestParams,
|
|
CallToolResult,
|
|
CancelTaskRequestParams,
|
|
CancelTaskResult,
|
|
ClientResult,
|
|
ErrorData,
|
|
GetTaskPayloadRequest,
|
|
GetTaskPayloadRequestParams,
|
|
GetTaskPayloadResult,
|
|
GetTaskRequestParams,
|
|
GetTaskResult,
|
|
JSONRPCError,
|
|
JSONRPCNotification,
|
|
JSONRPCResponse,
|
|
ListTasksResult,
|
|
ListToolsResult,
|
|
PaginatedRequestParams,
|
|
SamplingMessage,
|
|
ServerCapabilities,
|
|
ServerNotification,
|
|
ServerRequest,
|
|
Task,
|
|
TaskMetadata,
|
|
TextContent,
|
|
Tool,
|
|
ToolExecution,
|
|
)
|
|
|
|
pytestmark = pytest.mark.anyio
|
|
|
|
|
|
async def test_list_tasks_handler() -> None:
|
|
"""Test that experimental list_tasks handler works via Client."""
|
|
now = datetime.now(timezone.utc)
|
|
test_tasks = [
|
|
Task(task_id="task-1", status="working", created_at=now, last_updated_at=now, ttl=60000, poll_interval=1000),
|
|
Task(task_id="task-2", status="completed", created_at=now, last_updated_at=now, ttl=60000, poll_interval=1000),
|
|
]
|
|
|
|
async def handle_list_tasks(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListTasksResult:
|
|
return ListTasksResult(tasks=test_tasks)
|
|
|
|
server = Server("test")
|
|
server.experimental.enable_tasks(on_list_tasks=handle_list_tasks)
|
|
|
|
async with Client(server) as client:
|
|
result = await client.session.experimental.list_tasks()
|
|
assert len(result.tasks) == 2
|
|
assert result.tasks[0].task_id == "task-1"
|
|
assert result.tasks[1].task_id == "task-2"
|
|
|
|
|
|
async def test_get_task_handler() -> None:
|
|
"""Test that experimental get_task handler works via Client."""
|
|
|
|
async def handle_get_task(ctx: ServerRequestContext, params: GetTaskRequestParams) -> GetTaskResult:
|
|
now = datetime.now(timezone.utc)
|
|
return GetTaskResult(
|
|
task_id=params.task_id,
|
|
status="working",
|
|
created_at=now,
|
|
last_updated_at=now,
|
|
ttl=60000,
|
|
poll_interval=1000,
|
|
)
|
|
|
|
server = Server("test")
|
|
server.experimental.enable_tasks(on_get_task=handle_get_task)
|
|
|
|
async with Client(server) as client:
|
|
result = await client.session.experimental.get_task("test-task-123")
|
|
assert result.task_id == "test-task-123"
|
|
assert result.status == "working"
|
|
|
|
|
|
async def test_get_task_result_handler() -> None:
|
|
"""Test that experimental get_task_result handler works via Client."""
|
|
|
|
async def handle_get_task_result(
|
|
ctx: ServerRequestContext, params: GetTaskPayloadRequestParams
|
|
) -> GetTaskPayloadResult:
|
|
return GetTaskPayloadResult()
|
|
|
|
server = Server("test")
|
|
server.experimental.enable_tasks(on_task_result=handle_get_task_result)
|
|
|
|
async with Client(server) as client:
|
|
result = await client.session.send_request(
|
|
GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(task_id="test-task-123")),
|
|
GetTaskPayloadResult,
|
|
)
|
|
assert isinstance(result, GetTaskPayloadResult)
|
|
|
|
|
|
async def test_cancel_task_handler() -> None:
|
|
"""Test that experimental cancel_task handler works via Client."""
|
|
|
|
async def handle_cancel_task(ctx: ServerRequestContext, params: CancelTaskRequestParams) -> CancelTaskResult:
|
|
now = datetime.now(timezone.utc)
|
|
return CancelTaskResult(
|
|
task_id=params.task_id,
|
|
status="cancelled",
|
|
created_at=now,
|
|
last_updated_at=now,
|
|
ttl=60000,
|
|
)
|
|
|
|
server = Server("test")
|
|
server.experimental.enable_tasks(on_cancel_task=handle_cancel_task)
|
|
|
|
async with Client(server) as client:
|
|
result = await client.session.experimental.cancel_task("test-task-123")
|
|
assert result.task_id == "test-task-123"
|
|
assert result.status == "cancelled"
|
|
|
|
|
|
async def test_server_capabilities_include_tasks() -> None:
|
|
"""Test that server capabilities include tasks when handlers are registered."""
|
|
server = Server("test")
|
|
|
|
async def noop_list_tasks(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListTasksResult:
|
|
raise NotImplementedError
|
|
|
|
async def noop_cancel_task(ctx: ServerRequestContext, params: CancelTaskRequestParams) -> CancelTaskResult:
|
|
raise NotImplementedError
|
|
|
|
server.experimental.enable_tasks(on_list_tasks=noop_list_tasks, on_cancel_task=noop_cancel_task)
|
|
|
|
capabilities = server.get_capabilities(notification_options=NotificationOptions(), experimental_capabilities={})
|
|
|
|
assert capabilities.tasks is not None
|
|
assert capabilities.tasks.list is not None
|
|
assert capabilities.tasks.cancel is not None
|
|
assert capabilities.tasks.requests is not None
|
|
assert capabilities.tasks.requests.tools is not None
|
|
|
|
|
|
@pytest.mark.skip(
|
|
reason="TODO(maxisbey): enable_tasks registers default handlers for all task methods, "
|
|
"so partial capabilities aren't possible yet. Low-level API should support "
|
|
"selectively enabling/disabling task capabilities."
|
|
)
|
|
async def test_server_capabilities_partial_tasks() -> None: # pragma: no cover
|
|
"""Test capabilities with only some task handlers registered."""
|
|
server = Server("test")
|
|
|
|
async def noop_list_tasks(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListTasksResult:
|
|
raise NotImplementedError
|
|
|
|
# Only list_tasks registered, not cancel_task
|
|
server.experimental.enable_tasks(on_list_tasks=noop_list_tasks)
|
|
|
|
capabilities = server.get_capabilities(notification_options=NotificationOptions(), experimental_capabilities={})
|
|
|
|
assert capabilities.tasks is not None
|
|
assert capabilities.tasks.list is not None
|
|
assert capabilities.tasks.cancel is None # Not registered
|
|
|
|
|
|
async def test_tool_with_task_execution_metadata() -> None:
|
|
"""Test that tools can declare task execution mode."""
|
|
|
|
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
|
|
return ListToolsResult(
|
|
tools=[
|
|
Tool(
|
|
name="quick_tool",
|
|
description="Fast tool",
|
|
input_schema={"type": "object", "properties": {}},
|
|
execution=ToolExecution(task_support=TASK_FORBIDDEN),
|
|
),
|
|
Tool(
|
|
name="long_tool",
|
|
description="Long running tool",
|
|
input_schema={"type": "object", "properties": {}},
|
|
execution=ToolExecution(task_support=TASK_REQUIRED),
|
|
),
|
|
Tool(
|
|
name="flexible_tool",
|
|
description="Can be either",
|
|
input_schema={"type": "object", "properties": {}},
|
|
execution=ToolExecution(task_support=TASK_OPTIONAL),
|
|
),
|
|
]
|
|
)
|
|
|
|
server = Server("test", on_list_tools=handle_list_tools)
|
|
|
|
async with Client(server) as client:
|
|
result = await client.list_tools()
|
|
tools = result.tools
|
|
|
|
assert tools[0].execution is not None
|
|
assert tools[0].execution.task_support == TASK_FORBIDDEN
|
|
assert tools[1].execution is not None
|
|
assert tools[1].execution.task_support == TASK_REQUIRED
|
|
assert tools[2].execution is not None
|
|
assert tools[2].execution.task_support == TASK_OPTIONAL
|
|
|
|
|
|
async def test_task_metadata_in_call_tool_request() -> None:
|
|
"""Test that task metadata is accessible via ctx when calling a tool."""
|
|
captured_task_metadata: TaskMetadata | None = None
|
|
|
|
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
|
|
raise NotImplementedError
|
|
|
|
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
|
|
nonlocal captured_task_metadata
|
|
captured_task_metadata = ctx.experimental.task_metadata
|
|
return CallToolResult(content=[TextContent(type="text", text="done")])
|
|
|
|
server = Server("test", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool)
|
|
|
|
async with Client(server) as client:
|
|
# Call tool with task metadata
|
|
await client.session.send_request(
|
|
CallToolRequest(
|
|
params=CallToolRequestParams(
|
|
name="long_task",
|
|
arguments={},
|
|
task=TaskMetadata(ttl=60000),
|
|
),
|
|
),
|
|
CallToolResult,
|
|
)
|
|
|
|
assert captured_task_metadata is not None
|
|
assert captured_task_metadata.ttl == 60000
|
|
|
|
|
|
async def test_task_metadata_is_task_property() -> None:
|
|
"""Test that ctx.experimental.is_task works correctly."""
|
|
is_task_values: list[bool] = []
|
|
|
|
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
|
|
raise NotImplementedError
|
|
|
|
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
|
|
is_task_values.append(ctx.experimental.is_task)
|
|
return CallToolResult(content=[TextContent(type="text", text="done")])
|
|
|
|
server = Server("test", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool)
|
|
|
|
async with Client(server) as client:
|
|
# Call without task metadata
|
|
await client.session.send_request(
|
|
CallToolRequest(params=CallToolRequestParams(name="test_tool", arguments={})),
|
|
CallToolResult,
|
|
)
|
|
|
|
# Call with task metadata
|
|
await client.session.send_request(
|
|
CallToolRequest(
|
|
params=CallToolRequestParams(name="test_tool", arguments={}, task=TaskMetadata(ttl=60000)),
|
|
),
|
|
CallToolResult,
|
|
)
|
|
|
|
assert len(is_task_values) == 2
|
|
assert is_task_values[0] is False # First call without task
|
|
assert is_task_values[1] is True # Second call with task
|
|
|
|
|
|
async def test_update_capabilities_no_handlers() -> None:
|
|
"""Test that update_capabilities returns early when no task handlers are registered."""
|
|
server = Server("test-no-handlers")
|
|
_ = server.experimental
|
|
|
|
caps = server.get_capabilities(NotificationOptions(), {})
|
|
assert caps.tasks is None
|
|
|
|
|
|
async def test_update_capabilities_partial_handlers() -> None:
|
|
"""Test that update_capabilities skips list/cancel when only tasks/get is registered."""
|
|
server = Server("test-partial")
|
|
# Access .experimental to create the ExperimentalHandlers instance
|
|
exp = server.experimental
|
|
# Second access returns the same cached instance
|
|
assert server.experimental is exp
|
|
|
|
async def noop_get(ctx: ServerRequestContext, params: GetTaskRequestParams) -> GetTaskResult:
|
|
raise NotImplementedError
|
|
|
|
server._add_request_handler("tasks/get", noop_get)
|
|
|
|
caps = server.get_capabilities(NotificationOptions(), {})
|
|
assert caps.tasks is not None
|
|
assert caps.tasks.list is None
|
|
assert caps.tasks.cancel is None
|
|
|
|
|
|
async def test_default_task_handlers_via_enable_tasks() -> None:
|
|
"""Test that enable_tasks() auto-registers working default handlers."""
|
|
server = Server("test-default-handlers")
|
|
task_support = server.experimental.enable_tasks()
|
|
store = task_support.store
|
|
|
|
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)
|
|
|
|
async def message_handler(
|
|
message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception,
|
|
) -> None: ... # pragma: no branch
|
|
|
|
async def run_server() -> None:
|
|
async with task_support.run():
|
|
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:
|
|
task_support.configure_session(server_session)
|
|
async for message in server_session.incoming_messages:
|
|
await server._handle_message(message, server_session, {}, False)
|
|
|
|
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:
|
|
await client_session.initialize()
|
|
|
|
# Create a task directly in the store for testing
|
|
task = await store.create_task(TaskMetadata(ttl=60000))
|
|
|
|
# Test list_tasks (default handler)
|
|
list_result = await client_session.experimental.list_tasks()
|
|
assert len(list_result.tasks) == 1
|
|
assert list_result.tasks[0].task_id == task.task_id
|
|
|
|
# Test get_task (default handler - found)
|
|
get_result = await client_session.experimental.get_task(task.task_id)
|
|
assert get_result.task_id == task.task_id
|
|
assert get_result.status == "working"
|
|
|
|
# Test get_task (default handler - not found path)
|
|
with pytest.raises(MCPError, match="not found"):
|
|
await client_session.experimental.get_task("nonexistent-task")
|
|
|
|
# Create a completed task to test get_task_result
|
|
completed_task = await store.create_task(TaskMetadata(ttl=60000))
|
|
await store.store_result(
|
|
completed_task.task_id, CallToolResult(content=[TextContent(type="text", text="Test result")])
|
|
)
|
|
await store.update_task(completed_task.task_id, status="completed")
|
|
|
|
# Test get_task_result (default handler)
|
|
payload_result = await client_session.send_request(
|
|
GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(task_id=completed_task.task_id)),
|
|
GetTaskPayloadResult,
|
|
)
|
|
# The result should have the related-task metadata
|
|
assert payload_result.meta is not None
|
|
assert "io.modelcontextprotocol/related-task" in payload_result.meta
|
|
|
|
# Test cancel_task (default handler)
|
|
cancel_result = await client_session.experimental.cancel_task(task.task_id)
|
|
assert cancel_result.task_id == task.task_id
|
|
assert cancel_result.status == "cancelled"
|
|
|
|
tg.cancel_scope.cancel()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_build_elicit_form_request() -> None:
|
|
"""Test that _build_elicit_form_request builds a proper elicitation request."""
|
|
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)
|
|
|
|
try:
|
|
async with ServerSession(
|
|
client_to_server_receive,
|
|
server_to_client_send,
|
|
InitializationOptions(server_name="test-server", server_version="1.0.0", capabilities=ServerCapabilities()),
|
|
) as server_session:
|
|
# Test without task_id
|
|
request = server_session._build_elicit_form_request(
|
|
message="Test message",
|
|
requested_schema={"type": "object", "properties": {"answer": {"type": "string"}}},
|
|
)
|
|
assert request.method == "elicitation/create"
|
|
assert request.params is not None
|
|
assert request.params["message"] == "Test message"
|
|
|
|
# Test with related_task_id (adds related-task metadata)
|
|
request_with_task = server_session._build_elicit_form_request(
|
|
message="Task message",
|
|
requested_schema={"type": "object"},
|
|
related_task_id="test-task-123",
|
|
)
|
|
assert request_with_task.method == "elicitation/create"
|
|
assert request_with_task.params is not None
|
|
assert "_meta" in request_with_task.params
|
|
assert "io.modelcontextprotocol/related-task" in request_with_task.params["_meta"]
|
|
assert (
|
|
request_with_task.params["_meta"]["io.modelcontextprotocol/related-task"]["taskId"] == "test-task-123"
|
|
)
|
|
finally:
|
|
await server_to_client_send.aclose()
|
|
await server_to_client_receive.aclose()
|
|
await client_to_server_send.aclose()
|
|
await client_to_server_receive.aclose()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_build_elicit_url_request() -> None:
|
|
"""Test that _build_elicit_url_request builds a proper URL mode elicitation request."""
|
|
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)
|
|
|
|
try:
|
|
async with ServerSession(
|
|
client_to_server_receive,
|
|
server_to_client_send,
|
|
InitializationOptions(server_name="test-server", server_version="1.0.0", capabilities=ServerCapabilities()),
|
|
) as server_session:
|
|
# Test without related_task_id
|
|
request = server_session._build_elicit_url_request(
|
|
message="Please authorize with GitHub",
|
|
url="https://github.com/login/oauth/authorize",
|
|
elicitation_id="oauth-123",
|
|
)
|
|
assert request.method == "elicitation/create"
|
|
assert request.params is not None
|
|
assert request.params["message"] == "Please authorize with GitHub"
|
|
assert request.params["url"] == "https://github.com/login/oauth/authorize"
|
|
assert request.params["elicitationId"] == "oauth-123"
|
|
assert request.params["mode"] == "url"
|
|
|
|
# Test with related_task_id (adds related-task metadata)
|
|
request_with_task = server_session._build_elicit_url_request(
|
|
message="OAuth required",
|
|
url="https://example.com/oauth",
|
|
elicitation_id="oauth-456",
|
|
related_task_id="test-task-789",
|
|
)
|
|
assert request_with_task.method == "elicitation/create"
|
|
assert request_with_task.params is not None
|
|
assert "_meta" in request_with_task.params
|
|
assert "io.modelcontextprotocol/related-task" in request_with_task.params["_meta"]
|
|
assert (
|
|
request_with_task.params["_meta"]["io.modelcontextprotocol/related-task"]["taskId"] == "test-task-789"
|
|
)
|
|
finally:
|
|
await server_to_client_send.aclose()
|
|
await server_to_client_receive.aclose()
|
|
await client_to_server_send.aclose()
|
|
await client_to_server_receive.aclose()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_build_create_message_request() -> None:
|
|
"""Test that _build_create_message_request builds a proper sampling request."""
|
|
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)
|
|
|
|
try:
|
|
async with ServerSession(
|
|
client_to_server_receive,
|
|
server_to_client_send,
|
|
InitializationOptions(
|
|
server_name="test-server",
|
|
server_version="1.0.0",
|
|
capabilities=ServerCapabilities(),
|
|
),
|
|
) as server_session:
|
|
messages = [
|
|
SamplingMessage(role="user", content=TextContent(type="text", text="Hello")),
|
|
]
|
|
|
|
# Test without task_id
|
|
request = server_session._build_create_message_request(
|
|
messages=messages,
|
|
max_tokens=100,
|
|
system_prompt="You are helpful",
|
|
)
|
|
assert request.method == "sampling/createMessage"
|
|
assert request.params is not None
|
|
assert request.params["maxTokens"] == 100
|
|
|
|
# Test with related_task_id (adds related-task metadata)
|
|
request_with_task = server_session._build_create_message_request(
|
|
messages=messages,
|
|
max_tokens=50,
|
|
related_task_id="sampling-task-456",
|
|
)
|
|
assert request_with_task.method == "sampling/createMessage"
|
|
assert request_with_task.params is not None
|
|
assert "_meta" in request_with_task.params
|
|
assert "io.modelcontextprotocol/related-task" in request_with_task.params["_meta"]
|
|
assert (
|
|
request_with_task.params["_meta"]["io.modelcontextprotocol/related-task"]["taskId"]
|
|
== "sampling-task-456"
|
|
)
|
|
finally:
|
|
await server_to_client_send.aclose()
|
|
await server_to_client_receive.aclose()
|
|
await client_to_server_send.aclose()
|
|
await client_to_server_receive.aclose()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_send_message() -> None:
|
|
"""Test that send_message sends a raw session message."""
|
|
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)
|
|
|
|
try:
|
|
async with ServerSession(
|
|
client_to_server_receive,
|
|
server_to_client_send,
|
|
InitializationOptions(
|
|
server_name="test-server",
|
|
server_version="1.0.0",
|
|
capabilities=ServerCapabilities(),
|
|
),
|
|
) as server_session:
|
|
# Create a test message
|
|
notification = JSONRPCNotification(jsonrpc="2.0", method="test/notification")
|
|
message = SessionMessage(
|
|
message=notification,
|
|
metadata=ServerMessageMetadata(related_request_id="test-req-1"),
|
|
)
|
|
|
|
# Send the message
|
|
await server_session.send_message(message)
|
|
|
|
# Verify it was sent to the stream
|
|
received = await server_to_client_receive.receive()
|
|
assert isinstance(received.message, JSONRPCNotification)
|
|
assert received.message.method == "test/notification"
|
|
finally: # pragma: lax no cover
|
|
await server_to_client_send.aclose()
|
|
await server_to_client_receive.aclose()
|
|
await client_to_server_send.aclose()
|
|
await client_to_server_receive.aclose()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_response_routing_success() -> None:
|
|
"""Test that response routing works for success responses."""
|
|
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)
|
|
|
|
# Track routed responses with event for synchronization
|
|
routed_responses: list[dict[str, Any]] = []
|
|
response_received = anyio.Event()
|
|
|
|
class TestRouter(ResponseRouter):
|
|
def route_response(self, request_id: str | int, response: dict[str, Any]) -> bool:
|
|
routed_responses.append({"id": request_id, "response": response})
|
|
response_received.set()
|
|
return True # Handled
|
|
|
|
def route_error(self, request_id: str | int, error: ErrorData) -> bool:
|
|
raise NotImplementedError
|
|
|
|
try:
|
|
async with ServerSession(
|
|
client_to_server_receive,
|
|
server_to_client_send,
|
|
InitializationOptions(
|
|
server_name="test-server",
|
|
server_version="1.0.0",
|
|
capabilities=ServerCapabilities(),
|
|
),
|
|
) as server_session:
|
|
router = TestRouter()
|
|
server_session.add_response_router(router)
|
|
|
|
# Simulate receiving a response from client
|
|
response = JSONRPCResponse(jsonrpc="2.0", id="test-req-1", result={"status": "ok"})
|
|
message = SessionMessage(message=response)
|
|
|
|
# Send from "client" side
|
|
await client_to_server_send.send(message)
|
|
|
|
# Wait for response to be routed
|
|
with anyio.fail_after(5):
|
|
await response_received.wait()
|
|
|
|
# Verify response was routed
|
|
assert len(routed_responses) == 1
|
|
assert routed_responses[0]["id"] == "test-req-1"
|
|
assert routed_responses[0]["response"]["status"] == "ok"
|
|
finally: # pragma: lax no cover
|
|
await server_to_client_send.aclose()
|
|
await server_to_client_receive.aclose()
|
|
await client_to_server_send.aclose()
|
|
await client_to_server_receive.aclose()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_response_routing_error() -> None:
|
|
"""Test that error routing works for error responses."""
|
|
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)
|
|
|
|
# Track routed errors with event for synchronization
|
|
routed_errors: list[dict[str, Any]] = []
|
|
error_received = anyio.Event()
|
|
|
|
class TestRouter(ResponseRouter):
|
|
def route_response(self, request_id: str | int, response: dict[str, Any]) -> bool:
|
|
raise NotImplementedError
|
|
|
|
def route_error(self, request_id: str | int, error: ErrorData) -> bool:
|
|
routed_errors.append({"id": request_id, "error": error})
|
|
error_received.set()
|
|
return True # Handled
|
|
|
|
try:
|
|
async with ServerSession(
|
|
client_to_server_receive,
|
|
server_to_client_send,
|
|
InitializationOptions(
|
|
server_name="test-server",
|
|
server_version="1.0.0",
|
|
capabilities=ServerCapabilities(),
|
|
),
|
|
) as server_session:
|
|
router = TestRouter()
|
|
server_session.add_response_router(router)
|
|
|
|
# Simulate receiving an error response from client
|
|
error_data = ErrorData(code=INVALID_REQUEST, message="Test error")
|
|
error_response = JSONRPCError(jsonrpc="2.0", id="test-req-2", error=error_data)
|
|
message = SessionMessage(message=error_response)
|
|
|
|
# Send from "client" side
|
|
await client_to_server_send.send(message)
|
|
|
|
# Wait for error to be routed
|
|
with anyio.fail_after(5):
|
|
await error_received.wait()
|
|
|
|
# Verify error was routed
|
|
assert len(routed_errors) == 1
|
|
assert routed_errors[0]["id"] == "test-req-2"
|
|
assert routed_errors[0]["error"].message == "Test error"
|
|
finally: # pragma: lax no cover
|
|
await server_to_client_send.aclose()
|
|
await server_to_client_receive.aclose()
|
|
await client_to_server_send.aclose()
|
|
await client_to_server_receive.aclose()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_response_routing_skips_non_matching_routers() -> None:
|
|
"""Test that routing continues to next router when first doesn't match."""
|
|
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)
|
|
|
|
# Track which routers were called
|
|
router_calls: list[str] = []
|
|
response_received = anyio.Event()
|
|
|
|
class NonMatchingRouter(ResponseRouter):
|
|
def route_response(self, request_id: str | int, response: dict[str, Any]) -> bool:
|
|
router_calls.append("non_matching_response")
|
|
return False # Doesn't handle it
|
|
|
|
def route_error(self, request_id: str | int, error: ErrorData) -> bool:
|
|
raise NotImplementedError
|
|
|
|
class MatchingRouter(ResponseRouter):
|
|
def route_response(self, request_id: str | int, response: dict[str, Any]) -> bool:
|
|
router_calls.append("matching_response")
|
|
response_received.set()
|
|
return True # Handles it
|
|
|
|
def route_error(self, request_id: str | int, error: ErrorData) -> bool:
|
|
raise NotImplementedError
|
|
|
|
try:
|
|
async with ServerSession(
|
|
client_to_server_receive,
|
|
server_to_client_send,
|
|
InitializationOptions(
|
|
server_name="test-server",
|
|
server_version="1.0.0",
|
|
capabilities=ServerCapabilities(),
|
|
),
|
|
) as server_session:
|
|
# Add non-matching router first, then matching router
|
|
server_session.add_response_router(NonMatchingRouter())
|
|
server_session.add_response_router(MatchingRouter())
|
|
|
|
# Send a response - should skip first router and be handled by second
|
|
response = JSONRPCResponse(jsonrpc="2.0", id="test-req-1", result={"status": "ok"})
|
|
message = SessionMessage(message=response)
|
|
await client_to_server_send.send(message)
|
|
|
|
with anyio.fail_after(5):
|
|
await response_received.wait()
|
|
|
|
# Verify both routers were called (first returned False, second returned True)
|
|
assert router_calls == ["non_matching_response", "matching_response"]
|
|
finally: # pragma: lax no cover
|
|
await server_to_client_send.aclose()
|
|
await server_to_client_receive.aclose()
|
|
await client_to_server_send.aclose()
|
|
await client_to_server_receive.aclose()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_error_routing_skips_non_matching_routers() -> None:
|
|
"""Test that error routing continues to next router when first doesn't match."""
|
|
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)
|
|
|
|
# Track which routers were called
|
|
router_calls: list[str] = []
|
|
error_received = anyio.Event()
|
|
|
|
class NonMatchingRouter(ResponseRouter):
|
|
def route_response(self, request_id: str | int, response: dict[str, Any]) -> bool:
|
|
raise NotImplementedError
|
|
|
|
def route_error(self, request_id: str | int, error: ErrorData) -> bool:
|
|
router_calls.append("non_matching_error")
|
|
return False # Doesn't handle it
|
|
|
|
class MatchingRouter(ResponseRouter):
|
|
def route_response(self, request_id: str | int, response: dict[str, Any]) -> bool:
|
|
raise NotImplementedError
|
|
|
|
def route_error(self, request_id: str | int, error: ErrorData) -> bool:
|
|
router_calls.append("matching_error")
|
|
error_received.set()
|
|
return True # Handles it
|
|
|
|
try:
|
|
async with ServerSession(
|
|
client_to_server_receive,
|
|
server_to_client_send,
|
|
InitializationOptions(
|
|
server_name="test-server",
|
|
server_version="1.0.0",
|
|
capabilities=ServerCapabilities(),
|
|
),
|
|
) as server_session:
|
|
# Add non-matching router first, then matching router
|
|
server_session.add_response_router(NonMatchingRouter())
|
|
server_session.add_response_router(MatchingRouter())
|
|
|
|
# Send an error - should skip first router and be handled by second
|
|
error_data = ErrorData(code=INVALID_REQUEST, message="Test error")
|
|
error_response = JSONRPCError(jsonrpc="2.0", id="test-req-2", error=error_data)
|
|
message = SessionMessage(message=error_response)
|
|
await client_to_server_send.send(message)
|
|
|
|
with anyio.fail_after(5):
|
|
await error_received.wait()
|
|
|
|
# Verify both routers were called (first returned False, second returned True)
|
|
assert router_calls == ["non_matching_error", "matching_error"]
|
|
finally: # pragma: lax no cover
|
|
await server_to_client_send.aclose()
|
|
await server_to_client_receive.aclose()
|
|
await client_to_server_send.aclose()
|
|
await client_to_server_receive.aclose()
|