SEP-1686: Tasks (#1645)

This commit is contained in:
Max Isbey 2025-11-28 18:51:58 +00:00 • committed by GitHub
commit c92bb2f7ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 14230 additions and 120 deletions

View file

@ -0,0 +1,331 @@
"""Tests for client task capabilities declaration during initialization."""
import anyio
import pytest
import mcp.types as types
from mcp import ClientCapabilities
from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers
from mcp.client.session import ClientSession
from mcp.shared.context import RequestContext
from mcp.shared.message import SessionMessage
from mcp.types import (
LATEST_PROTOCOL_VERSION,
ClientRequest,
Implementation,
InitializeRequest,
InitializeResult,
JSONRPCMessage,
JSONRPCRequest,
JSONRPCResponse,
ServerCapabilities,
ServerResult,
)
@pytest.mark.anyio
async def test_client_capabilities_without_tasks():
"""Test that tasks capability is None when not provided."""
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1)
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1)
received_capabilities = None
async def mock_server():
nonlocal received_capabilities
session_message = await client_to_server_receive.receive()
jsonrpc_request = session_message.message
assert isinstance(jsonrpc_request.root, JSONRPCRequest)
request = ClientRequest.model_validate(
jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True)
)
assert isinstance(request.root, InitializeRequest)
received_capabilities = request.root.params.capabilities
result = ServerResult(
InitializeResult(
protocolVersion=LATEST_PROTOCOL_VERSION,
capabilities=ServerCapabilities(),
serverInfo=Implementation(name="mock-server", version="0.1.0"),
)
)
async with server_to_client_send:
await server_to_client_send.send(
SessionMessage(
JSONRPCMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=jsonrpc_request.root.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
)
await client_to_server_receive.receive()
async with (
ClientSession(
server_to_client_receive,
client_to_server_send,
) as session,
anyio.create_task_group() as tg,
client_to_server_send,
client_to_server_receive,
server_to_client_send,
server_to_client_receive,
):
tg.start_soon(mock_server)
await session.initialize()
# Assert that tasks capability is None when not provided
assert received_capabilities is not None
assert received_capabilities.tasks is None
@pytest.mark.anyio
async def test_client_capabilities_with_tasks():
"""Test that tasks capability is properly set when handlers are provided."""
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1)
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1)
received_capabilities: ClientCapabilities | None = None
# Define custom handlers to trigger capability building (never actually called)
async def my_list_tasks_handler(
context: RequestContext[ClientSession, None],
params: types.PaginatedRequestParams | None,
) -> types.ListTasksResult | types.ErrorData:
raise NotImplementedError
async def my_cancel_task_handler(
context: RequestContext[ClientSession, None],
params: types.CancelTaskRequestParams,
) -> types.CancelTaskResult | types.ErrorData:
raise NotImplementedError
async def mock_server():
nonlocal received_capabilities
session_message = await client_to_server_receive.receive()
jsonrpc_request = session_message.message
assert isinstance(jsonrpc_request.root, JSONRPCRequest)
request = ClientRequest.model_validate(
jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True)
)
assert isinstance(request.root, InitializeRequest)
received_capabilities = request.root.params.capabilities
result = ServerResult(
InitializeResult(
protocolVersion=LATEST_PROTOCOL_VERSION,
capabilities=ServerCapabilities(),
serverInfo=Implementation(name="mock-server", version="0.1.0"),
)
)
async with server_to_client_send:
await server_to_client_send.send(
SessionMessage(
JSONRPCMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=jsonrpc_request.root.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
)
await client_to_server_receive.receive()
# Create handlers container
task_handlers = ExperimentalTaskHandlers(
list_tasks=my_list_tasks_handler,
cancel_task=my_cancel_task_handler,
)
async with (
ClientSession(
server_to_client_receive,
client_to_server_send,
experimental_task_handlers=task_handlers,
) as session,
anyio.create_task_group() as tg,
client_to_server_send,
client_to_server_receive,
server_to_client_send,
server_to_client_receive,
):
tg.start_soon(mock_server)
await session.initialize()
# Assert that tasks capability is properly set from handlers
assert received_capabilities is not None
assert received_capabilities.tasks is not None
assert isinstance(received_capabilities.tasks, types.ClientTasksCapability)
assert received_capabilities.tasks.list is not None
assert received_capabilities.tasks.cancel is not None
@pytest.mark.anyio
async def test_client_capabilities_auto_built_from_handlers():
"""Test that tasks capability is automatically built from provided handlers."""
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1)
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1)
received_capabilities: ClientCapabilities | None = None
# Define custom handlers (not defaults)
async def my_list_tasks_handler(
context: RequestContext[ClientSession, None],
params: types.PaginatedRequestParams | None,
) -> types.ListTasksResult | types.ErrorData:
raise NotImplementedError
async def my_cancel_task_handler(
context: RequestContext[ClientSession, None],
params: types.CancelTaskRequestParams,
) -> types.CancelTaskResult | types.ErrorData:
raise NotImplementedError
async def mock_server():
nonlocal received_capabilities
session_message = await client_to_server_receive.receive()
jsonrpc_request = session_message.message
assert isinstance(jsonrpc_request.root, JSONRPCRequest)
request = ClientRequest.model_validate(
jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True)
)
assert isinstance(request.root, InitializeRequest)
received_capabilities = request.root.params.capabilities
result = ServerResult(
InitializeResult(
protocolVersion=LATEST_PROTOCOL_VERSION,
capabilities=ServerCapabilities(),
serverInfo=Implementation(name="mock-server", version="0.1.0"),
)
)
async with server_to_client_send:
await server_to_client_send.send(
SessionMessage(
JSONRPCMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=jsonrpc_request.root.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
)
await client_to_server_receive.receive()
# Provide handlers via ExperimentalTaskHandlers
task_handlers = ExperimentalTaskHandlers(
list_tasks=my_list_tasks_handler,
cancel_task=my_cancel_task_handler,
)
async with (
ClientSession(
server_to_client_receive,
client_to_server_send,
experimental_task_handlers=task_handlers,
) as session,
anyio.create_task_group() as tg,
client_to_server_send,
client_to_server_receive,
server_to_client_send,
server_to_client_receive,
):
tg.start_soon(mock_server)
await session.initialize()
# Assert that tasks capability was auto-built from handlers
assert received_capabilities is not None
assert received_capabilities.tasks is not None
assert received_capabilities.tasks.list is not None
assert received_capabilities.tasks.cancel is not None
# requests should be None since we didn't provide task-augmented handlers
assert received_capabilities.tasks.requests is None
@pytest.mark.anyio
async def test_client_capabilities_with_task_augmented_handlers():
"""Test that requests capability is built when augmented handlers are provided."""
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1)
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1)
received_capabilities: ClientCapabilities | None = None
# Define task-augmented handler
async def my_augmented_sampling_handler(
context: RequestContext[ClientSession, None],
params: types.CreateMessageRequestParams,
task_metadata: types.TaskMetadata,
) -> types.CreateTaskResult | types.ErrorData:
raise NotImplementedError
async def mock_server():
nonlocal received_capabilities
session_message = await client_to_server_receive.receive()
jsonrpc_request = session_message.message
assert isinstance(jsonrpc_request.root, JSONRPCRequest)
request = ClientRequest.model_validate(
jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True)
)
assert isinstance(request.root, InitializeRequest)
received_capabilities = request.root.params.capabilities
result = ServerResult(
InitializeResult(
protocolVersion=LATEST_PROTOCOL_VERSION,
capabilities=ServerCapabilities(),
serverInfo=Implementation(name="mock-server", version="0.1.0"),
)
)
async with server_to_client_send:
await server_to_client_send.send(
SessionMessage(
JSONRPCMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=jsonrpc_request.root.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
)
await client_to_server_receive.receive()
# Provide task-augmented sampling handler
task_handlers = ExperimentalTaskHandlers(
augmented_sampling=my_augmented_sampling_handler,
)
async with (
ClientSession(
server_to_client_receive,
client_to_server_send,
experimental_task_handlers=task_handlers,
) as session,
anyio.create_task_group() as tg,
client_to_server_send,
client_to_server_receive,
server_to_client_send,
server_to_client_receive,
):
tg.start_soon(mock_server)
await session.initialize()
# Assert that tasks capability includes requests.sampling
assert received_capabilities is not None
assert received_capabilities.tasks is not None
assert received_capabilities.tasks.requests is not None
assert received_capabilities.tasks.requests.sampling is not None
assert received_capabilities.tasks.requests.elicitation is None # Not provided

View file

@ -0,0 +1,878 @@
"""Tests for client-side task management handlers (server -> client requests).
These tests verify that clients can handle task-related requests from servers:
- GetTaskRequest - server polling client's task status
- GetTaskPayloadRequest - server getting result from client's task
- ListTasksRequest - server listing client's tasks
- CancelTaskRequest - server cancelling client's task
This is the inverse of the existing tests in test_tasks.py, which test
client -> server task requests.
"""
from collections.abc import AsyncIterator
from dataclasses import dataclass
import anyio
import pytest
from anyio import Event
from anyio.abc import TaskGroup
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
import mcp.types as types
from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers
from mcp.client.session import ClientSession
from mcp.shared.context import RequestContext
from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore
from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder
from mcp.types import (
CancelTaskRequest,
CancelTaskRequestParams,
CancelTaskResult,
ClientResult,
CreateMessageRequest,
CreateMessageRequestParams,
CreateMessageResult,
CreateTaskResult,
ElicitRequest,
ElicitRequestFormParams,
ElicitRequestParams,
ElicitResult,
ErrorData,
GetTaskPayloadRequest,
GetTaskPayloadRequestParams,
GetTaskPayloadResult,
GetTaskRequest,
GetTaskRequestParams,
GetTaskResult,
ListTasksRequest,
ListTasksResult,
SamplingMessage,
ServerNotification,
ServerRequest,
TaskMetadata,
TextContent,
)
# Buffer size for test streams
STREAM_BUFFER_SIZE = 10
@dataclass
class ClientTestStreams:
"""Bidirectional message streams for client/server communication in tests."""
server_send: MemoryObjectSendStream[SessionMessage]
server_receive: MemoryObjectReceiveStream[SessionMessage]
client_send: MemoryObjectSendStream[SessionMessage]
client_receive: MemoryObjectReceiveStream[SessionMessage]
@pytest.fixture
async def client_streams() -> AsyncIterator[ClientTestStreams]:
"""Create bidirectional message streams for client tests.
Automatically closes all streams after the test completes.
"""
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](
STREAM_BUFFER_SIZE
)
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](
STREAM_BUFFER_SIZE
)
streams = ClientTestStreams(
server_send=server_to_client_send,
server_receive=client_to_server_receive,
client_send=client_to_server_send,
client_receive=server_to_client_receive,
)
yield streams
# Cleanup
await server_to_client_send.aclose()
await server_to_client_receive.aclose()
await client_to_server_send.aclose()
await client_to_server_receive.aclose()
async def _default_message_handler(
message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception,
) -> None:
"""Default message handler that ignores messages (tests handle them explicitly)."""
...
@pytest.mark.anyio
async def test_client_handles_get_task_request(client_streams: ClientTestStreams) -> None:
"""Test that client can respond to GetTaskRequest from server."""
with anyio.fail_after(10):
store = InMemoryTaskStore()
received_task_id: str | None = None
async def get_task_handler(
context: RequestContext[ClientSession, None],
params: GetTaskRequestParams,
) -> GetTaskResult | ErrorData:
nonlocal received_task_id
received_task_id = params.taskId
task = await store.get_task(params.taskId)
assert task is not None, f"Test setup error: task {params.taskId} should exist"
return GetTaskResult(
taskId=task.taskId,
status=task.status,
statusMessage=task.statusMessage,
createdAt=task.createdAt,
lastUpdatedAt=task.lastUpdatedAt,
ttl=task.ttl,
pollInterval=task.pollInterval,
)
await store.create_task(TaskMetadata(ttl=60000), task_id="test-task-123")
task_handlers = ExperimentalTaskHandlers(get_task=get_task_handler)
client_ready = anyio.Event()
async with anyio.create_task_group() as tg:
async def run_client() -> None:
async with ClientSession(
client_streams.client_receive,
client_streams.client_send,
message_handler=_default_message_handler,
experimental_task_handlers=task_handlers,
):
client_ready.set()
await anyio.sleep_forever()
tg.start_soon(run_client)
await client_ready.wait()
typed_request = GetTaskRequest(params=GetTaskRequestParams(taskId="test-task-123"))
request = types.JSONRPCRequest(
jsonrpc="2.0",
id="req-1",
**typed_request.model_dump(by_alias=True),
)
await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request)))
response_msg = await client_streams.server_receive.receive()
response = response_msg.message.root
assert isinstance(response, types.JSONRPCResponse)
assert response.id == "req-1"
result = GetTaskResult.model_validate(response.result)
assert result.taskId == "test-task-123"
assert result.status == "working"
assert received_task_id == "test-task-123"
tg.cancel_scope.cancel()
store.cleanup()
@pytest.mark.anyio
async def test_client_handles_get_task_result_request(client_streams: ClientTestStreams) -> None:
"""Test that client can respond to GetTaskPayloadRequest from server."""
with anyio.fail_after(10):
store = InMemoryTaskStore()
async def get_task_result_handler(
context: RequestContext[ClientSession, None],
params: GetTaskPayloadRequestParams,
) -> GetTaskPayloadResult | ErrorData:
result = await store.get_result(params.taskId)
assert result is not None, f"Test setup error: result for {params.taskId} should exist"
assert isinstance(result, types.CallToolResult)
return GetTaskPayloadResult(**result.model_dump())
await store.create_task(TaskMetadata(ttl=60000), task_id="test-task-456")
await store.store_result(
"test-task-456",
types.CallToolResult(content=[TextContent(type="text", text="Task completed successfully!")]),
)
await store.update_task("test-task-456", status="completed")
task_handlers = ExperimentalTaskHandlers(get_task_result=get_task_result_handler)
client_ready = anyio.Event()
async with anyio.create_task_group() as tg:
async def run_client() -> None:
async with ClientSession(
client_streams.client_receive,
client_streams.client_send,
message_handler=_default_message_handler,
experimental_task_handlers=task_handlers,
):
client_ready.set()
await anyio.sleep_forever()
tg.start_soon(run_client)
await client_ready.wait()
typed_request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId="test-task-456"))
request = types.JSONRPCRequest(
jsonrpc="2.0",
id="req-2",
**typed_request.model_dump(by_alias=True),
)
await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request)))
response_msg = await client_streams.server_receive.receive()
response = response_msg.message.root
assert isinstance(response, types.JSONRPCResponse)
assert isinstance(response.result, dict)
result_dict = response.result
assert "content" in result_dict
assert len(result_dict["content"]) == 1
assert result_dict["content"][0]["text"] == "Task completed successfully!"
tg.cancel_scope.cancel()
store.cleanup()
@pytest.mark.anyio
async def test_client_handles_list_tasks_request(client_streams: ClientTestStreams) -> None:
"""Test that client can respond to ListTasksRequest from server."""
with anyio.fail_after(10):
store = InMemoryTaskStore()
async def list_tasks_handler(
context: RequestContext[ClientSession, None],
params: types.PaginatedRequestParams | None,
) -> ListTasksResult | ErrorData:
cursor = params.cursor if params else None
tasks_list, next_cursor = await store.list_tasks(cursor=cursor)
return ListTasksResult(tasks=tasks_list, nextCursor=next_cursor)
await store.create_task(TaskMetadata(ttl=60000), task_id="task-1")
await store.create_task(TaskMetadata(ttl=60000), task_id="task-2")
task_handlers = ExperimentalTaskHandlers(list_tasks=list_tasks_handler)
client_ready = anyio.Event()
async with anyio.create_task_group() as tg:
async def run_client() -> None:
async with ClientSession(
client_streams.client_receive,
client_streams.client_send,
message_handler=_default_message_handler,
experimental_task_handlers=task_handlers,
):
client_ready.set()
await anyio.sleep_forever()
tg.start_soon(run_client)
await client_ready.wait()
typed_request = ListTasksRequest()
request = types.JSONRPCRequest(
jsonrpc="2.0",
id="req-3",
**typed_request.model_dump(by_alias=True),
)
await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request)))
response_msg = await client_streams.server_receive.receive()
response = response_msg.message.root
assert isinstance(response, types.JSONRPCResponse)
result = ListTasksResult.model_validate(response.result)
assert len(result.tasks) == 2
tg.cancel_scope.cancel()
store.cleanup()
@pytest.mark.anyio
async def test_client_handles_cancel_task_request(client_streams: ClientTestStreams) -> None:
"""Test that client can respond to CancelTaskRequest from server."""
with anyio.fail_after(10):
store = InMemoryTaskStore()
async def cancel_task_handler(
context: RequestContext[ClientSession, None],
params: CancelTaskRequestParams,
) -> CancelTaskResult | ErrorData:
task = await store.get_task(params.taskId)
assert task is not None, f"Test setup error: task {params.taskId} should exist"
await store.update_task(params.taskId, status="cancelled")
updated = await store.get_task(params.taskId)
assert updated is not None
return CancelTaskResult(
taskId=updated.taskId,
status=updated.status,
createdAt=updated.createdAt,
lastUpdatedAt=updated.lastUpdatedAt,
ttl=updated.ttl,
)
await store.create_task(TaskMetadata(ttl=60000), task_id="task-to-cancel")
task_handlers = ExperimentalTaskHandlers(cancel_task=cancel_task_handler)
client_ready = anyio.Event()
async with anyio.create_task_group() as tg:
async def run_client() -> None:
async with ClientSession(
client_streams.client_receive,
client_streams.client_send,
message_handler=_default_message_handler,
experimental_task_handlers=task_handlers,
):
client_ready.set()
await anyio.sleep_forever()
tg.start_soon(run_client)
await client_ready.wait()
typed_request = CancelTaskRequest(params=CancelTaskRequestParams(taskId="task-to-cancel"))
request = types.JSONRPCRequest(
jsonrpc="2.0",
id="req-4",
**typed_request.model_dump(by_alias=True),
)
await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request)))
response_msg = await client_streams.server_receive.receive()
response = response_msg.message.root
assert isinstance(response, types.JSONRPCResponse)
result = CancelTaskResult.model_validate(response.result)
assert result.taskId == "task-to-cancel"
assert result.status == "cancelled"
tg.cancel_scope.cancel()
store.cleanup()
@pytest.mark.anyio
async def test_client_task_augmented_sampling(client_streams: ClientTestStreams) -> None:
"""Test that client can handle task-augmented sampling request from server."""
with anyio.fail_after(10):
store = InMemoryTaskStore()
sampling_completed = Event()
created_task_id: list[str | None] = [None]
background_tg: list[TaskGroup | None] = [None]
async def task_augmented_sampling_callback(
context: RequestContext[ClientSession, None],
params: CreateMessageRequestParams,
task_metadata: TaskMetadata,
) -> CreateTaskResult:
task = await store.create_task(task_metadata)
created_task_id[0] = task.taskId
async def do_sampling() -> None:
result = CreateMessageResult(
role="assistant",
content=TextContent(type="text", text="Sampled response"),
model="test-model",
stopReason="endTurn",
)
await store.store_result(task.taskId, result)
await store.update_task(task.taskId, status="completed")
sampling_completed.set()
assert background_tg[0] is not None
background_tg[0].start_soon(do_sampling)
return CreateTaskResult(task=task)
async def get_task_handler(
context: RequestContext[ClientSession, None],
params: GetTaskRequestParams,
) -> GetTaskResult | ErrorData:
task = await store.get_task(params.taskId)
assert task is not None, f"Test setup error: task {params.taskId} should exist"
return GetTaskResult(
taskId=task.taskId,
status=task.status,
statusMessage=task.statusMessage,
createdAt=task.createdAt,
lastUpdatedAt=task.lastUpdatedAt,
ttl=task.ttl,
pollInterval=task.pollInterval,
)
async def get_task_result_handler(
context: RequestContext[ClientSession, None],
params: GetTaskPayloadRequestParams,
) -> GetTaskPayloadResult | ErrorData:
result = await store.get_result(params.taskId)
assert result is not None, f"Test setup error: result for {params.taskId} should exist"
assert isinstance(result, CreateMessageResult)
return GetTaskPayloadResult(**result.model_dump())
task_handlers = ExperimentalTaskHandlers(
augmented_sampling=task_augmented_sampling_callback,
get_task=get_task_handler,
get_task_result=get_task_result_handler,
)
client_ready = anyio.Event()
async with anyio.create_task_group() as tg:
background_tg[0] = tg
async def run_client() -> None:
async with ClientSession(
client_streams.client_receive,
client_streams.client_send,
message_handler=_default_message_handler,
experimental_task_handlers=task_handlers,
):
client_ready.set()
await anyio.sleep_forever()
tg.start_soon(run_client)
await client_ready.wait()
# Step 1: Server sends task-augmented CreateMessageRequest
typed_request = CreateMessageRequest(
params=CreateMessageRequestParams(
messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))],
maxTokens=100,
task=TaskMetadata(ttl=60000),
)
)
request = types.JSONRPCRequest(
jsonrpc="2.0",
id="req-sampling",
**typed_request.model_dump(by_alias=True),
)
await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request)))
# Step 2: Client responds with CreateTaskResult
response_msg = await client_streams.server_receive.receive()
response = response_msg.message.root
assert isinstance(response, types.JSONRPCResponse)
task_result = CreateTaskResult.model_validate(response.result)
task_id = task_result.task.taskId
assert task_id == created_task_id[0]
# Step 3: Wait for background sampling
await sampling_completed.wait()
# Step 4: Server polls task status
typed_poll = GetTaskRequest(params=GetTaskRequestParams(taskId=task_id))
poll_request = types.JSONRPCRequest(
jsonrpc="2.0",
id="req-poll",
**typed_poll.model_dump(by_alias=True),
)
await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(poll_request)))
poll_response_msg = await client_streams.server_receive.receive()
poll_response = poll_response_msg.message.root
assert isinstance(poll_response, types.JSONRPCResponse)
status = GetTaskResult.model_validate(poll_response.result)
assert status.status == "completed"
# Step 5: Server gets result
typed_result_req = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=task_id))
result_request = types.JSONRPCRequest(
jsonrpc="2.0",
id="req-result",
**typed_result_req.model_dump(by_alias=True),
)
await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(result_request)))
result_response_msg = await client_streams.server_receive.receive()
result_response = result_response_msg.message.root
assert isinstance(result_response, types.JSONRPCResponse)
assert isinstance(result_response.result, dict)
assert result_response.result["role"] == "assistant"
tg.cancel_scope.cancel()
store.cleanup()
@pytest.mark.anyio
async def test_client_task_augmented_elicitation(client_streams: ClientTestStreams) -> None:
"""Test that client can handle task-augmented elicitation request from server."""
with anyio.fail_after(10):
store = InMemoryTaskStore()
elicitation_completed = Event()
created_task_id: list[str | None] = [None]
background_tg: list[TaskGroup | None] = [None]
async def task_augmented_elicitation_callback(
context: RequestContext[ClientSession, None],
params: ElicitRequestParams,
task_metadata: TaskMetadata,
) -> CreateTaskResult | ErrorData:
task = await store.create_task(task_metadata)
created_task_id[0] = task.taskId
async def do_elicitation() -> None:
# Simulate user providing elicitation response
result = ElicitResult(action="accept", content={"name": "Test User"})
await store.store_result(task.taskId, result)
await store.update_task(task.taskId, status="completed")
elicitation_completed.set()
assert background_tg[0] is not None
background_tg[0].start_soon(do_elicitation)
return CreateTaskResult(task=task)
async def get_task_handler(
context: RequestContext[ClientSession, None],
params: GetTaskRequestParams,
) -> GetTaskResult | ErrorData:
task = await store.get_task(params.taskId)
assert task is not None, f"Test setup error: task {params.taskId} should exist"
return GetTaskResult(
taskId=task.taskId,
status=task.status,
statusMessage=task.statusMessage,
createdAt=task.createdAt,
lastUpdatedAt=task.lastUpdatedAt,
ttl=task.ttl,
pollInterval=task.pollInterval,
)
async def get_task_result_handler(
context: RequestContext[ClientSession, None],
params: GetTaskPayloadRequestParams,
) -> GetTaskPayloadResult | ErrorData:
result = await store.get_result(params.taskId)
assert result is not None, f"Test setup error: result for {params.taskId} should exist"
assert isinstance(result, ElicitResult)
return GetTaskPayloadResult(**result.model_dump())
task_handlers = ExperimentalTaskHandlers(
augmented_elicitation=task_augmented_elicitation_callback,
get_task=get_task_handler,
get_task_result=get_task_result_handler,
)
client_ready = anyio.Event()
async with anyio.create_task_group() as tg:
background_tg[0] = tg
async def run_client() -> None:
async with ClientSession(
client_streams.client_receive,
client_streams.client_send,
message_handler=_default_message_handler,
experimental_task_handlers=task_handlers,
):
client_ready.set()
await anyio.sleep_forever()
tg.start_soon(run_client)
await client_ready.wait()
# Step 1: Server sends task-augmented ElicitRequest
typed_request = ElicitRequest(
params=ElicitRequestFormParams(
message="What is your name?",
requestedSchema={"type": "object", "properties": {"name": {"type": "string"}}},
task=TaskMetadata(ttl=60000),
)
)
request = types.JSONRPCRequest(
jsonrpc="2.0",
id="req-elicit",
**typed_request.model_dump(by_alias=True),
)
await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request)))
# Step 2: Client responds with CreateTaskResult
response_msg = await client_streams.server_receive.receive()
response = response_msg.message.root
assert isinstance(response, types.JSONRPCResponse)
task_result = CreateTaskResult.model_validate(response.result)
task_id = task_result.task.taskId
assert task_id == created_task_id[0]
# Step 3: Wait for background elicitation
await elicitation_completed.wait()
# Step 4: Server polls task status
typed_poll = GetTaskRequest(params=GetTaskRequestParams(taskId=task_id))
poll_request = types.JSONRPCRequest(
jsonrpc="2.0",
id="req-poll",
**typed_poll.model_dump(by_alias=True),
)
await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(poll_request)))
poll_response_msg = await client_streams.server_receive.receive()
poll_response = poll_response_msg.message.root
assert isinstance(poll_response, types.JSONRPCResponse)
status = GetTaskResult.model_validate(poll_response.result)
assert status.status == "completed"
# Step 5: Server gets result
typed_result_req = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=task_id))
result_request = types.JSONRPCRequest(
jsonrpc="2.0",
id="req-result",
**typed_result_req.model_dump(by_alias=True),
)
await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(result_request)))
result_response_msg = await client_streams.server_receive.receive()
result_response = result_response_msg.message.root
assert isinstance(result_response, types.JSONRPCResponse)
# Verify the elicitation result
assert isinstance(result_response.result, dict)
assert result_response.result["action"] == "accept"
assert result_response.result["content"] == {"name": "Test User"}
tg.cancel_scope.cancel()
store.cleanup()
@pytest.mark.anyio
async def test_client_returns_error_for_unhandled_task_request(client_streams: ClientTestStreams) -> None:
"""Test that client returns error when no handler is registered for task request."""
with anyio.fail_after(10):
client_ready = anyio.Event()
async with anyio.create_task_group() as tg:
async def run_client() -> None:
async with ClientSession(
client_streams.client_receive,
client_streams.client_send,
message_handler=_default_message_handler,
):
client_ready.set()
await anyio.sleep_forever()
tg.start_soon(run_client)
await client_ready.wait()
typed_request = GetTaskRequest(params=GetTaskRequestParams(taskId="nonexistent"))
request = types.JSONRPCRequest(
jsonrpc="2.0",
id="req-unhandled",
**typed_request.model_dump(by_alias=True),
)
await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request)))
response_msg = await client_streams.server_receive.receive()
response = response_msg.message.root
assert isinstance(response, types.JSONRPCError)
assert (
"not supported" in response.error.message.lower()
or "method not found" in response.error.message.lower()
)
tg.cancel_scope.cancel()
@pytest.mark.anyio
async def test_client_returns_error_for_unhandled_task_result_request(client_streams: ClientTestStreams) -> None:
"""Test that client returns error for unhandled tasks/result request."""
with anyio.fail_after(10):
client_ready = anyio.Event()
async with anyio.create_task_group() as tg:
async def run_client() -> None:
async with ClientSession(
client_streams.client_receive,
client_streams.client_send,
message_handler=_default_message_handler,
):
client_ready.set()
await anyio.sleep_forever()
tg.start_soon(run_client)
await client_ready.wait()
typed_request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId="nonexistent"))
request = types.JSONRPCRequest(
jsonrpc="2.0",
id="req-result",
**typed_request.model_dump(by_alias=True),
)
await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request)))
response_msg = await client_streams.server_receive.receive()
response = response_msg.message.root
assert isinstance(response, types.JSONRPCError)
assert "not supported" in response.error.message.lower()
tg.cancel_scope.cancel()
@pytest.mark.anyio
async def test_client_returns_error_for_unhandled_list_tasks_request(client_streams: ClientTestStreams) -> None:
"""Test that client returns error for unhandled tasks/list request."""
with anyio.fail_after(10):
client_ready = anyio.Event()
async with anyio.create_task_group() as tg:
async def run_client() -> None:
async with ClientSession(
client_streams.client_receive,
client_streams.client_send,
message_handler=_default_message_handler,
):
client_ready.set()
await anyio.sleep_forever()
tg.start_soon(run_client)
await client_ready.wait()
typed_request = ListTasksRequest()
request = types.JSONRPCRequest(
jsonrpc="2.0",
id="req-list",
**typed_request.model_dump(by_alias=True),
)
await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request)))
response_msg = await client_streams.server_receive.receive()
response = response_msg.message.root
assert isinstance(response, types.JSONRPCError)
assert "not supported" in response.error.message.lower()
tg.cancel_scope.cancel()
@pytest.mark.anyio
async def test_client_returns_error_for_unhandled_cancel_task_request(client_streams: ClientTestStreams) -> None:
"""Test that client returns error for unhandled tasks/cancel request."""
with anyio.fail_after(10):
client_ready = anyio.Event()
async with anyio.create_task_group() as tg:
async def run_client() -> None:
async with ClientSession(
client_streams.client_receive,
client_streams.client_send,
message_handler=_default_message_handler,
):
client_ready.set()
await anyio.sleep_forever()
tg.start_soon(run_client)
await client_ready.wait()
typed_request = CancelTaskRequest(params=CancelTaskRequestParams(taskId="nonexistent"))
request = types.JSONRPCRequest(
jsonrpc="2.0",
id="req-cancel",
**typed_request.model_dump(by_alias=True),
)
await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request)))
response_msg = await client_streams.server_receive.receive()
response = response_msg.message.root
assert isinstance(response, types.JSONRPCError)
assert "not supported" in response.error.message.lower()
tg.cancel_scope.cancel()
@pytest.mark.anyio
async def test_client_returns_error_for_unhandled_task_augmented_sampling(client_streams: ClientTestStreams) -> None:
"""Test that client returns error for task-augmented sampling without handler."""
with anyio.fail_after(10):
client_ready = anyio.Event()
async with anyio.create_task_group() as tg:
async def run_client() -> None:
# No task handlers provided - uses defaults
async with ClientSession(
client_streams.client_receive,
client_streams.client_send,
message_handler=_default_message_handler,
):
client_ready.set()
await anyio.sleep_forever()
tg.start_soon(run_client)
await client_ready.wait()
# Send task-augmented sampling request
typed_request = CreateMessageRequest(
params=CreateMessageRequestParams(
messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))],
maxTokens=100,
task=TaskMetadata(ttl=60000),
)
)
request = types.JSONRPCRequest(
jsonrpc="2.0",
id="req-sampling",
**typed_request.model_dump(by_alias=True),
)
await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request)))
response_msg = await client_streams.server_receive.receive()
response = response_msg.message.root
assert isinstance(response, types.JSONRPCError)
assert "not supported" in response.error.message.lower()
tg.cancel_scope.cancel()
@pytest.mark.anyio
async def test_client_returns_error_for_unhandled_task_augmented_elicitation(
client_streams: ClientTestStreams,
) -> None:
"""Test that client returns error for task-augmented elicitation without handler."""
with anyio.fail_after(10):
client_ready = anyio.Event()
async with anyio.create_task_group() as tg:
async def run_client() -> None:
# No task handlers provided - uses defaults
async with ClientSession(
client_streams.client_receive,
client_streams.client_send,
message_handler=_default_message_handler,
):
client_ready.set()
await anyio.sleep_forever()
tg.start_soon(run_client)
await client_ready.wait()
# Send task-augmented elicitation request
typed_request = ElicitRequest(
params=ElicitRequestFormParams(
message="What is your name?",
requestedSchema={"type": "object", "properties": {"name": {"type": "string"}}},
task=TaskMetadata(ttl=60000),
)
)
request = types.JSONRPCRequest(
jsonrpc="2.0",
id="req-elicit",
**typed_request.model_dump(by_alias=True),
)
await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request)))
response_msg = await client_streams.server_receive.receive()
response = response_msg.message.root
assert isinstance(response, types.JSONRPCError)
assert "not supported" in response.error.message.lower()
tg.cancel_scope.cancel()

View file

@ -0,0 +1,121 @@
"""Tests for poll_task async iterator."""
from collections.abc import Callable, Coroutine
from datetime import datetime, timezone
from typing import Any
from unittest.mock import AsyncMock
import pytest
from mcp.client.experimental.tasks import ExperimentalClientFeatures
from mcp.types import GetTaskResult, TaskStatus
def make_task_result(
status: TaskStatus = "working",
poll_interval: int = 0,
task_id: str = "test-task",
status_message: str | None = None,
) -> GetTaskResult:
"""Create GetTaskResult with sensible defaults."""
now = datetime.now(timezone.utc)
return GetTaskResult(
taskId=task_id,
status=status,
statusMessage=status_message,
createdAt=now,
lastUpdatedAt=now,
ttl=60000,
pollInterval=poll_interval,
)
def make_status_sequence(
*statuses: TaskStatus,
task_id: str = "test-task",
) -> Callable[[str], Coroutine[Any, Any, GetTaskResult]]:
"""Create mock get_task that returns statuses in sequence."""
status_iter = iter(statuses)
async def mock_get_task(tid: str) -> GetTaskResult:
return make_task_result(status=next(status_iter), task_id=tid)
return mock_get_task
@pytest.fixture
def mock_session() -> AsyncMock:
return AsyncMock()
@pytest.fixture
def features(mock_session: AsyncMock) -> ExperimentalClientFeatures:
return ExperimentalClientFeatures(mock_session)
@pytest.mark.anyio
async def test_poll_task_yields_until_completed(features: ExperimentalClientFeatures) -> None:
"""poll_task yields each status until terminal."""
features.get_task = make_status_sequence("working", "working", "completed") # type: ignore[method-assign]
statuses = [s.status async for s in features.poll_task("test-task")]
assert statuses == ["working", "working", "completed"]
@pytest.mark.anyio
@pytest.mark.parametrize("terminal_status", ["completed", "failed", "cancelled"])
async def test_poll_task_exits_on_terminal(features: ExperimentalClientFeatures, terminal_status: TaskStatus) -> None:
"""poll_task exits immediately when task is already terminal."""
features.get_task = make_status_sequence(terminal_status) # type: ignore[method-assign]
statuses = [s.status async for s in features.poll_task("test-task")]
assert statuses == [terminal_status]
@pytest.mark.anyio
async def test_poll_task_continues_through_input_required(features: ExperimentalClientFeatures) -> None:
"""poll_task yields input_required and continues (non-terminal)."""
features.get_task = make_status_sequence("working", "input_required", "working", "completed") # type: ignore[method-assign]
statuses = [s.status async for s in features.poll_task("test-task")]
assert statuses == ["working", "input_required", "working", "completed"]
@pytest.mark.anyio
async def test_poll_task_passes_task_id(features: ExperimentalClientFeatures) -> None:
"""poll_task passes correct task_id to get_task."""
received_ids: list[str] = []
async def mock_get_task(task_id: str) -> GetTaskResult:
received_ids.append(task_id)
return make_task_result(status="completed", task_id=task_id)
features.get_task = mock_get_task # type: ignore[method-assign]
_ = [s async for s in features.poll_task("my-task-123")]
assert received_ids == ["my-task-123"]
@pytest.mark.anyio
async def test_poll_task_yields_full_result(features: ExperimentalClientFeatures) -> None:
"""poll_task yields complete GetTaskResult objects."""
async def mock_get_task(task_id: str) -> GetTaskResult:
return make_task_result(
status="completed",
task_id=task_id,
status_message="All done!",
)
features.get_task = mock_get_task # type: ignore[method-assign]
results = [r async for r in features.poll_task("test-task")]
assert len(results) == 1
assert results[0].status == "completed"
assert results[0].statusMessage == "All done!"
assert results[0].taskId == "test-task"

View file

@ -0,0 +1,483 @@
"""Tests for the experimental client task methods (session.experimental)."""
from dataclasses import dataclass, field
from typing import Any
import anyio
import pytest
from anyio import Event
from anyio.abc import TaskGroup
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.experimental.tasks.helpers import task_execution
from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore
from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder
from mcp.types import (
CallToolRequest,
CallToolRequestParams,
CallToolResult,
CancelTaskRequest,
CancelTaskResult,
ClientRequest,
ClientResult,
CreateTaskResult,
GetTaskPayloadRequest,
GetTaskPayloadResult,
GetTaskRequest,
GetTaskResult,
ListTasksRequest,
ListTasksResult,
ServerNotification,
ServerRequest,
TaskMetadata,
TextContent,
Tool,
)
@dataclass
class AppContext:
"""Application context passed via lifespan_context."""
task_group: TaskGroup
store: InMemoryTaskStore
task_done_events: dict[str, Event] = field(default_factory=lambda: {})
@pytest.mark.anyio
async def test_session_experimental_get_task() -> None:
"""Test session.experimental.get_task() method."""
# Note: We bypass the normal lifespan mechanism
server: Server[AppContext, Any] = Server("test-server") # type: ignore[assignment]
store = InMemoryTaskStore()
@server.list_tools()
async def list_tools():
return [Tool(name="test_tool", description="Test", inputSchema={"type": "object"})]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent] | CreateTaskResult:
ctx = server.request_context
app = ctx.lifespan_context
if ctx.experimental.is_task:
task_metadata = ctx.experimental.task_metadata
assert task_metadata is not None
task = await app.store.create_task(task_metadata)
done_event = Event()
app.task_done_events[task.taskId] = done_event
async def do_work():
async with task_execution(task.taskId, app.store) as task_ctx:
await task_ctx.complete(CallToolResult(content=[TextContent(type="text", text="Done")]))
done_event.set()
app.task_group.start_soon(do_work)
return CreateTaskResult(task=task)
raise NotImplementedError
@server.experimental.get_task()
async def handle_get_task(request: GetTaskRequest) -> GetTaskResult:
app = server.request_context.lifespan_context
task = await app.store.get_task(request.params.taskId)
assert task is not None, f"Test setup error: task {request.params.taskId} should exist"
return GetTaskResult(
taskId=task.taskId,
status=task.status,
statusMessage=task.statusMessage,
createdAt=task.createdAt,
lastUpdatedAt=task.lastUpdatedAt,
ttl=task.ttl,
pollInterval=task.pollInterval,
)
# Set up streams
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(app_context: AppContext):
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 for message in server_session.incoming_messages:
await server._handle_message(message, server_session, app_context, raise_exceptions=False)
async with anyio.create_task_group() as tg:
app_context = AppContext(task_group=tg, store=store)
tg.start_soon(run_server, app_context)
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
create_result = await client_session.send_request(
ClientRequest(
CallToolRequest(
params=CallToolRequestParams(
name="test_tool",
arguments={},
task=TaskMetadata(ttl=60000),
)
)
),
CreateTaskResult,
)
task_id = create_result.task.taskId
# Wait for task to complete
await app_context.task_done_events[task_id].wait()
# Use session.experimental to get task status
task_status = await client_session.experimental.get_task(task_id)
assert task_status.taskId == task_id
assert task_status.status == "completed"
tg.cancel_scope.cancel()
@pytest.mark.anyio
async def test_session_experimental_get_task_result() -> None:
"""Test session.experimental.get_task_result() method."""
server: Server[AppContext, Any] = Server("test-server") # type: ignore[assignment]
store = InMemoryTaskStore()
@server.list_tools()
async def list_tools():
return [Tool(name="test_tool", description="Test", inputSchema={"type": "object"})]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent] | CreateTaskResult:
ctx = server.request_context
app = ctx.lifespan_context
if ctx.experimental.is_task:
task_metadata = ctx.experimental.task_metadata
assert task_metadata is not None
task = await app.store.create_task(task_metadata)
done_event = Event()
app.task_done_events[task.taskId] = done_event
async def do_work():
async with task_execution(task.taskId, app.store) as task_ctx:
await task_ctx.complete(
CallToolResult(content=[TextContent(type="text", text="Task result content")])
)
done_event.set()
app.task_group.start_soon(do_work)
return CreateTaskResult(task=task)
raise NotImplementedError
@server.experimental.get_task_result()
async def handle_get_task_result(
request: GetTaskPayloadRequest,
) -> GetTaskPayloadResult:
app = server.request_context.lifespan_context
result = await app.store.get_result(request.params.taskId)
assert result is not None, f"Test setup error: result for {request.params.taskId} should exist"
assert isinstance(result, CallToolResult)
return GetTaskPayloadResult(**result.model_dump())
# Set up streams
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(app_context: AppContext):
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 for message in server_session.incoming_messages:
await server._handle_message(message, server_session, app_context, raise_exceptions=False)
async with anyio.create_task_group() as tg:
app_context = AppContext(task_group=tg, store=store)
tg.start_soon(run_server, app_context)
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
create_result = await client_session.send_request(
ClientRequest(
CallToolRequest(
params=CallToolRequestParams(
name="test_tool",
arguments={},
task=TaskMetadata(ttl=60000),
)
)
),
CreateTaskResult,
)
task_id = create_result.task.taskId
# Wait for task to complete
await app_context.task_done_events[task_id].wait()
# Use TaskClient to get task result
task_result = await client_session.experimental.get_task_result(task_id, CallToolResult)
assert len(task_result.content) == 1
content = task_result.content[0]
assert isinstance(content, TextContent)
assert content.text == "Task result content"
tg.cancel_scope.cancel()
@pytest.mark.anyio
async def test_session_experimental_list_tasks() -> None:
"""Test TaskClient.list_tasks() method."""
server: Server[AppContext, Any] = Server("test-server") # type: ignore[assignment]
store = InMemoryTaskStore()
@server.list_tools()
async def list_tools():
return [Tool(name="test_tool", description="Test", inputSchema={"type": "object"})]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent] | CreateTaskResult:
ctx = server.request_context
app = ctx.lifespan_context
if ctx.experimental.is_task:
task_metadata = ctx.experimental.task_metadata
assert task_metadata is not None
task = await app.store.create_task(task_metadata)
done_event = Event()
app.task_done_events[task.taskId] = done_event
async def do_work():
async with task_execution(task.taskId, app.store) as task_ctx:
await task_ctx.complete(CallToolResult(content=[TextContent(type="text", text="Done")]))
done_event.set()
app.task_group.start_soon(do_work)
return CreateTaskResult(task=task)
raise NotImplementedError
@server.experimental.list_tasks()
async def handle_list_tasks(request: ListTasksRequest) -> ListTasksResult:
app = server.request_context.lifespan_context
tasks_list, next_cursor = await app.store.list_tasks(cursor=request.params.cursor if request.params else None)
return ListTasksResult(tasks=tasks_list, nextCursor=next_cursor)
# Set up streams
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(app_context: AppContext):
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 for message in server_session.incoming_messages:
await server._handle_message(message, server_session, app_context, raise_exceptions=False)
async with anyio.create_task_group() as tg:
app_context = AppContext(task_group=tg, store=store)
tg.start_soon(run_server, app_context)
async with ClientSession(
server_to_client_receive,
client_to_server_send,
message_handler=message_handler,
) as client_session:
await client_session.initialize()
# Create two tasks
for _ in range(2):
create_result = await client_session.send_request(
ClientRequest(
CallToolRequest(
params=CallToolRequestParams(
name="test_tool",
arguments={},
task=TaskMetadata(ttl=60000),
)
)
),
CreateTaskResult,
)
await app_context.task_done_events[create_result.task.taskId].wait()
# Use TaskClient to list tasks
list_result = await client_session.experimental.list_tasks()
assert len(list_result.tasks) == 2
tg.cancel_scope.cancel()
@pytest.mark.anyio
async def test_session_experimental_cancel_task() -> None:
"""Test TaskClient.cancel_task() method."""
server: Server[AppContext, Any] = Server("test-server") # type: ignore[assignment]
store = InMemoryTaskStore()
@server.list_tools()
async def list_tools():
return [Tool(name="test_tool", description="Test", inputSchema={"type": "object"})]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent] | CreateTaskResult:
ctx = server.request_context
app = ctx.lifespan_context
if ctx.experimental.is_task:
task_metadata = ctx.experimental.task_metadata
assert task_metadata is not None
task = await app.store.create_task(task_metadata)
# Don't start any work - task stays in "working" status
return CreateTaskResult(task=task)
raise NotImplementedError
@server.experimental.get_task()
async def handle_get_task(request: GetTaskRequest) -> GetTaskResult:
app = server.request_context.lifespan_context
task = await app.store.get_task(request.params.taskId)
assert task is not None, f"Test setup error: task {request.params.taskId} should exist"
return GetTaskResult(
taskId=task.taskId,
status=task.status,
statusMessage=task.statusMessage,
createdAt=task.createdAt,
lastUpdatedAt=task.lastUpdatedAt,
ttl=task.ttl,
pollInterval=task.pollInterval,
)
@server.experimental.cancel_task()
async def handle_cancel_task(request: CancelTaskRequest) -> CancelTaskResult:
app = server.request_context.lifespan_context
task = await app.store.get_task(request.params.taskId)
assert task is not None, f"Test setup error: task {request.params.taskId} should exist"
await app.store.update_task(request.params.taskId, status="cancelled")
# CancelTaskResult extends Task, so we need to return the updated task info
updated_task = await app.store.get_task(request.params.taskId)
assert updated_task is not None
return CancelTaskResult(
taskId=updated_task.taskId,
status=updated_task.status,
createdAt=updated_task.createdAt,
lastUpdatedAt=updated_task.lastUpdatedAt,
ttl=updated_task.ttl,
)
# Set up streams
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(app_context: AppContext):
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 for message in server_session.incoming_messages:
await server._handle_message(message, server_session, app_context, raise_exceptions=False)
async with anyio.create_task_group() as tg:
app_context = AppContext(task_group=tg, store=store)
tg.start_soon(run_server, app_context)
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 (but don't complete it)
create_result = await client_session.send_request(
ClientRequest(
CallToolRequest(
params=CallToolRequestParams(
name="test_tool",
arguments={},
task=TaskMetadata(ttl=60000),
)
)
),
CreateTaskResult,
)
task_id = create_result.task.taskId
# Verify task is working
status_before = await client_session.experimental.get_task(task_id)
assert status_before.status == "working"
# Cancel the task
await client_session.experimental.cancel_task(task_id)
# Verify task is cancelled
status_after = await client_session.experimental.get_task(task_id)
assert status_after.status == "cancelled"
tg.cancel_scope.cancel()