SEP-1686: Tasks (#1645)
This commit is contained in:
parent
5983a650cc
commit
c92bb2f7ff
5 changed files with 14230 additions and 120 deletions
0
tests/experimental/tasks/client/__init__.py
Normal file
0
tests/experimental/tasks/client/__init__.py
Normal file
331
tests/experimental/tasks/client/test_capabilities.py
Normal file
331
tests/experimental/tasks/client/test_capabilities.py
Normal 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
|
||||
878
tests/experimental/tasks/client/test_handlers.py
Normal file
878
tests/experimental/tasks/client/test_handlers.py
Normal 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()
|
||||
121
tests/experimental/tasks/client/test_poll_task.py
Normal file
121
tests/experimental/tasks/client/test_poll_task.py
Normal 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"
|
||||
483
tests/experimental/tasks/client/test_tasks.py
Normal file
483
tests/experimental/tasks/client/test_tasks.py
Normal 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()
|
||||
Loading…
Reference in a new issue