Files
modelcontextprotocol--pytho…/tests/experimental/tasks/server/test_server_task_context.py
2026-01-26 14:37:44 +01:00

710 lines
22 KiB
Python

"""Tests for ServerTaskContext."""
import asyncio
from unittest.mock import AsyncMock, Mock
import anyio
import pytest
from mcp.server.experimental.task_context import ServerTaskContext
from mcp.server.experimental.task_result_handler import TaskResultHandler
from mcp.shared.exceptions import MCPError
from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore
from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue
from mcp.types import (
CallToolResult,
ClientCapabilities,
ClientTasksCapability,
ClientTasksRequestsCapability,
Implementation,
InitializeRequestParams,
JSONRPCRequest,
SamplingMessage,
TaskMetadata,
TasksCreateElicitationCapability,
TasksCreateMessageCapability,
TasksElicitationCapability,
TasksSamplingCapability,
TextContent,
)
@pytest.mark.anyio
async def test_server_task_context_properties() -> None:
"""Test ServerTaskContext property accessors."""
store = InMemoryTaskStore()
mock_session = Mock()
queue = InMemoryTaskMessageQueue()
task = await store.create_task(TaskMetadata(ttl=60000), task_id="test-123")
ctx = ServerTaskContext(
task=task,
store=store,
session=mock_session,
queue=queue,
)
assert ctx.task_id == "test-123"
assert ctx.task.task_id == "test-123"
assert ctx.is_cancelled is False
store.cleanup()
@pytest.mark.anyio
async def test_server_task_context_request_cancellation() -> None:
"""Test ServerTaskContext.request_cancellation()."""
store = InMemoryTaskStore()
mock_session = Mock()
queue = InMemoryTaskMessageQueue()
task = await store.create_task(TaskMetadata(ttl=60000))
ctx = ServerTaskContext(
task=task,
store=store,
session=mock_session,
queue=queue,
)
assert ctx.is_cancelled is False
ctx.request_cancellation()
assert ctx.is_cancelled is True
store.cleanup()
@pytest.mark.anyio
async def test_server_task_context_update_status_with_notify() -> None:
"""Test update_status sends notification when notify=True."""
store = InMemoryTaskStore()
mock_session = Mock()
mock_session.send_notification = AsyncMock()
queue = InMemoryTaskMessageQueue()
task = await store.create_task(TaskMetadata(ttl=60000))
ctx = ServerTaskContext(
task=task,
store=store,
session=mock_session,
queue=queue,
)
await ctx.update_status("Working...", notify=True)
mock_session.send_notification.assert_called_once()
store.cleanup()
@pytest.mark.anyio
async def test_server_task_context_update_status_without_notify() -> None:
"""Test update_status skips notification when notify=False."""
store = InMemoryTaskStore()
mock_session = Mock()
mock_session.send_notification = AsyncMock()
queue = InMemoryTaskMessageQueue()
task = await store.create_task(TaskMetadata(ttl=60000))
ctx = ServerTaskContext(
task=task,
store=store,
session=mock_session,
queue=queue,
)
await ctx.update_status("Working...", notify=False)
mock_session.send_notification.assert_not_called()
store.cleanup()
@pytest.mark.anyio
async def test_server_task_context_complete_with_notify() -> None:
"""Test complete sends notification when notify=True."""
store = InMemoryTaskStore()
mock_session = Mock()
mock_session.send_notification = AsyncMock()
queue = InMemoryTaskMessageQueue()
task = await store.create_task(TaskMetadata(ttl=60000))
ctx = ServerTaskContext(
task=task,
store=store,
session=mock_session,
queue=queue,
)
result = CallToolResult(content=[TextContent(type="text", text="Done")])
await ctx.complete(result, notify=True)
mock_session.send_notification.assert_called_once()
store.cleanup()
@pytest.mark.anyio
async def test_server_task_context_fail_with_notify() -> None:
"""Test fail sends notification when notify=True."""
store = InMemoryTaskStore()
mock_session = Mock()
mock_session.send_notification = AsyncMock()
queue = InMemoryTaskMessageQueue()
task = await store.create_task(TaskMetadata(ttl=60000))
ctx = ServerTaskContext(
task=task,
store=store,
session=mock_session,
queue=queue,
)
await ctx.fail("Something went wrong", notify=True)
mock_session.send_notification.assert_called_once()
store.cleanup()
@pytest.mark.anyio
async def test_elicit_raises_when_client_lacks_capability() -> None:
"""Test that elicit() raises MCPError when client doesn't support elicitation."""
store = InMemoryTaskStore()
mock_session = Mock()
mock_session.check_client_capability = Mock(return_value=False)
queue = InMemoryTaskMessageQueue()
handler = TaskResultHandler(store, queue)
task = await store.create_task(TaskMetadata(ttl=60000))
ctx = ServerTaskContext(
task=task,
store=store,
session=mock_session,
queue=queue,
handler=handler,
)
with pytest.raises(MCPError) as exc_info:
await ctx.elicit(message="Test?", requested_schema={"type": "object"})
assert "elicitation capability" in exc_info.value.error.message
mock_session.check_client_capability.assert_called_once()
store.cleanup()
@pytest.mark.anyio
async def test_create_message_raises_when_client_lacks_capability() -> None:
"""Test that create_message() raises MCPError when client doesn't support sampling."""
store = InMemoryTaskStore()
mock_session = Mock()
mock_session.check_client_capability = Mock(return_value=False)
queue = InMemoryTaskMessageQueue()
handler = TaskResultHandler(store, queue)
task = await store.create_task(TaskMetadata(ttl=60000))
ctx = ServerTaskContext(
task=task,
store=store,
session=mock_session,
queue=queue,
handler=handler,
)
with pytest.raises(MCPError) as exc_info:
await ctx.create_message(messages=[], max_tokens=100)
assert "sampling capability" in exc_info.value.error.message
mock_session.check_client_capability.assert_called_once()
store.cleanup()
@pytest.mark.anyio
async def test_elicit_raises_without_handler() -> None:
"""Test that elicit() raises when handler is not provided."""
store = InMemoryTaskStore()
mock_session = Mock()
mock_session.check_client_capability = Mock(return_value=True)
queue = InMemoryTaskMessageQueue()
task = await store.create_task(TaskMetadata(ttl=60000))
ctx = ServerTaskContext(
task=task,
store=store,
session=mock_session,
queue=queue,
handler=None,
)
with pytest.raises(RuntimeError, match="handler is required"):
await ctx.elicit(message="Test?", requested_schema={"type": "object"})
store.cleanup()
@pytest.mark.anyio
async def test_elicit_url_raises_without_handler() -> None:
"""Test that elicit_url() raises when handler is not provided."""
store = InMemoryTaskStore()
mock_session = Mock()
mock_session.check_client_capability = Mock(return_value=True)
queue = InMemoryTaskMessageQueue()
task = await store.create_task(TaskMetadata(ttl=60000))
ctx = ServerTaskContext(
task=task,
store=store,
session=mock_session,
queue=queue,
handler=None,
)
with pytest.raises(RuntimeError, match="handler is required for elicit_url"):
await ctx.elicit_url(
message="Please authorize",
url="https://example.com/oauth",
elicitation_id="oauth-123",
)
store.cleanup()
@pytest.mark.anyio
async def test_create_message_raises_without_handler() -> None:
"""Test that create_message() raises when handler is not provided."""
store = InMemoryTaskStore()
mock_session = Mock()
mock_session.check_client_capability = Mock(return_value=True)
queue = InMemoryTaskMessageQueue()
task = await store.create_task(TaskMetadata(ttl=60000))
ctx = ServerTaskContext(
task=task,
store=store,
session=mock_session,
queue=queue,
handler=None,
)
with pytest.raises(RuntimeError, match="handler is required"):
await ctx.create_message(messages=[], max_tokens=100)
store.cleanup()
@pytest.mark.anyio
async def test_elicit_queues_request_and_waits_for_response() -> None:
"""Test that elicit() queues request and waits for response."""
store = InMemoryTaskStore()
queue = InMemoryTaskMessageQueue()
handler = TaskResultHandler(store, queue)
task = await store.create_task(TaskMetadata(ttl=60000))
mock_session = Mock()
mock_session.check_client_capability = Mock(return_value=True)
mock_session._build_elicit_form_request = Mock(
return_value=JSONRPCRequest(
jsonrpc="2.0",
id="test-req-1",
method="elicitation/create",
params={"message": "Test?", "_meta": {}},
)
)
ctx = ServerTaskContext(
task=task,
store=store,
session=mock_session,
queue=queue,
handler=handler,
)
elicit_result = None
async def run_elicit() -> None:
nonlocal elicit_result
elicit_result = await ctx.elicit(
message="Test?",
requested_schema={"type": "object"},
)
async with anyio.create_task_group() as tg:
tg.start_soon(run_elicit)
# Wait for request to be queued
await queue.wait_for_message(task.task_id)
# Verify task is in input_required status
updated_task = await store.get_task(task.task_id)
assert updated_task is not None
assert updated_task.status == "input_required"
# Dequeue and simulate response
msg = await queue.dequeue(task.task_id)
assert msg is not None
assert msg.resolver is not None
# Resolve with mock elicitation response
msg.resolver.set_result({"action": "accept", "content": {"name": "Alice"}})
# Verify result
assert elicit_result is not None
assert elicit_result.action == "accept"
assert elicit_result.content == {"name": "Alice"}
# Verify task is back to working
final_task = await store.get_task(task.task_id)
assert final_task is not None
assert final_task.status == "working"
store.cleanup()
@pytest.mark.anyio
async def test_elicit_url_queues_request_and_waits_for_response() -> None:
"""Test that elicit_url() queues request and waits for response."""
store = InMemoryTaskStore()
queue = InMemoryTaskMessageQueue()
handler = TaskResultHandler(store, queue)
task = await store.create_task(TaskMetadata(ttl=60000))
mock_session = Mock()
mock_session.check_client_capability = Mock(return_value=True)
mock_session._build_elicit_url_request = Mock(
return_value=JSONRPCRequest(
jsonrpc="2.0",
id="test-url-req-1",
method="elicitation/create",
params={"message": "Authorize", "url": "https://example.com", "elicitationId": "123", "mode": "url"},
)
)
ctx = ServerTaskContext(
task=task,
store=store,
session=mock_session,
queue=queue,
handler=handler,
)
elicit_result = None
async def run_elicit_url() -> None:
nonlocal elicit_result
elicit_result = await ctx.elicit_url(
message="Authorize",
url="https://example.com/oauth",
elicitation_id="oauth-123",
)
async with anyio.create_task_group() as tg:
tg.start_soon(run_elicit_url)
# Wait for request to be queued
await queue.wait_for_message(task.task_id)
# Verify task is in input_required status
updated_task = await store.get_task(task.task_id)
assert updated_task is not None
assert updated_task.status == "input_required"
# Dequeue and simulate response
msg = await queue.dequeue(task.task_id)
assert msg is not None
assert msg.resolver is not None
# Resolve with mock elicitation response (URL mode just returns action)
msg.resolver.set_result({"action": "accept"})
# Verify result
assert elicit_result is not None
assert elicit_result.action == "accept"
# Verify task is back to working
final_task = await store.get_task(task.task_id)
assert final_task is not None
assert final_task.status == "working"
store.cleanup()
@pytest.mark.anyio
async def test_create_message_queues_request_and_waits_for_response() -> None:
"""Test that create_message() queues request and waits for response."""
store = InMemoryTaskStore()
queue = InMemoryTaskMessageQueue()
handler = TaskResultHandler(store, queue)
task = await store.create_task(TaskMetadata(ttl=60000))
mock_session = Mock()
mock_session.check_client_capability = Mock(return_value=True)
mock_session._build_create_message_request = Mock(
return_value=JSONRPCRequest(
jsonrpc="2.0",
id="test-req-2",
method="sampling/createMessage",
params={"messages": [], "maxTokens": 100, "_meta": {}},
)
)
ctx = ServerTaskContext(
task=task,
store=store,
session=mock_session,
queue=queue,
handler=handler,
)
sampling_result = None
async def run_sampling() -> None:
nonlocal sampling_result
sampling_result = await ctx.create_message(
messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))],
max_tokens=100,
)
async with anyio.create_task_group() as tg:
tg.start_soon(run_sampling)
# Wait for request to be queued
await queue.wait_for_message(task.task_id)
# Verify task is in input_required status
updated_task = await store.get_task(task.task_id)
assert updated_task is not None
assert updated_task.status == "input_required"
# Dequeue and simulate response
msg = await queue.dequeue(task.task_id)
assert msg is not None
assert msg.resolver is not None
# Resolve with mock sampling response
msg.resolver.set_result(
{
"role": "assistant",
"content": {"type": "text", "text": "Hello back!"},
"model": "test-model",
"stopReason": "endTurn",
}
)
# Verify result
assert sampling_result is not None
assert sampling_result.role == "assistant"
assert sampling_result.model == "test-model"
# Verify task is back to working
final_task = await store.get_task(task.task_id)
assert final_task is not None
assert final_task.status == "working"
store.cleanup()
@pytest.mark.anyio
async def test_elicit_restores_status_on_cancellation() -> None:
"""Test that elicit() restores task status to working when cancelled."""
store = InMemoryTaskStore()
queue = InMemoryTaskMessageQueue()
handler = TaskResultHandler(store, queue)
task = await store.create_task(TaskMetadata(ttl=60000))
mock_session = Mock()
mock_session.check_client_capability = Mock(return_value=True)
mock_session._build_elicit_form_request = Mock(
return_value=JSONRPCRequest(
jsonrpc="2.0",
id="test-req-cancel",
method="elicitation/create",
params={"message": "Test?", "_meta": {}},
)
)
ctx = ServerTaskContext(
task=task,
store=store,
session=mock_session,
queue=queue,
handler=handler,
)
cancelled_error_raised = False
async with anyio.create_task_group() as tg:
async def do_elicit() -> None:
nonlocal cancelled_error_raised
try:
await ctx.elicit(
message="Test?",
requested_schema={"type": "object"},
)
except anyio.get_cancelled_exc_class():
cancelled_error_raised = True
# Don't re-raise - let the test continue
tg.start_soon(do_elicit)
# Wait for request to be queued
await queue.wait_for_message(task.task_id)
# Verify task is in input_required status
updated_task = await store.get_task(task.task_id)
assert updated_task is not None
assert updated_task.status == "input_required"
# Get the queued message and set cancellation exception on its resolver
msg = await queue.dequeue(task.task_id)
assert msg is not None
assert msg.resolver is not None
# Trigger cancellation by setting exception (use asyncio.CancelledError directly)
msg.resolver.set_exception(asyncio.CancelledError())
# Verify task is back to working after cancellation
final_task = await store.get_task(task.task_id)
assert final_task is not None
assert final_task.status == "working"
assert cancelled_error_raised
store.cleanup()
@pytest.mark.anyio
async def test_create_message_restores_status_on_cancellation() -> None:
"""Test that create_message() restores task status to working when cancelled."""
store = InMemoryTaskStore()
queue = InMemoryTaskMessageQueue()
handler = TaskResultHandler(store, queue)
task = await store.create_task(TaskMetadata(ttl=60000))
mock_session = Mock()
mock_session.check_client_capability = Mock(return_value=True)
mock_session._build_create_message_request = Mock(
return_value=JSONRPCRequest(
jsonrpc="2.0",
id="test-req-cancel-2",
method="sampling/createMessage",
params={"messages": [], "maxTokens": 100, "_meta": {}},
)
)
ctx = ServerTaskContext(
task=task,
store=store,
session=mock_session,
queue=queue,
handler=handler,
)
cancelled_error_raised = False
async with anyio.create_task_group() as tg:
async def do_sampling() -> None:
nonlocal cancelled_error_raised
try:
await ctx.create_message(
messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))],
max_tokens=100,
)
except anyio.get_cancelled_exc_class():
cancelled_error_raised = True
# Don't re-raise
tg.start_soon(do_sampling)
# Wait for request to be queued
await queue.wait_for_message(task.task_id)
# Verify task is in input_required status
updated_task = await store.get_task(task.task_id)
assert updated_task is not None
assert updated_task.status == "input_required"
# Get the queued message and set cancellation exception on its resolver
msg = await queue.dequeue(task.task_id)
assert msg is not None
assert msg.resolver is not None
# Trigger cancellation by setting exception (use asyncio.CancelledError directly)
msg.resolver.set_exception(asyncio.CancelledError())
# Verify task is back to working after cancellation
final_task = await store.get_task(task.task_id)
assert final_task is not None
assert final_task.status == "working"
assert cancelled_error_raised
store.cleanup()
@pytest.mark.anyio
async def test_elicit_as_task_raises_without_handler() -> None:
"""Test that elicit_as_task() raises when handler is not provided."""
store = InMemoryTaskStore()
queue = InMemoryTaskMessageQueue()
task = await store.create_task(TaskMetadata(ttl=60000))
# Create mock session with proper client capabilities
mock_session = Mock()
mock_session.client_params = InitializeRequestParams(
protocol_version="2025-01-01",
capabilities=ClientCapabilities(
tasks=ClientTasksCapability(
requests=ClientTasksRequestsCapability(
elicitation=TasksElicitationCapability(create=TasksCreateElicitationCapability())
)
)
),
client_info=Implementation(name="test", version="1.0"),
)
ctx = ServerTaskContext(
task=task,
store=store,
session=mock_session,
queue=queue,
handler=None,
)
with pytest.raises(RuntimeError, match="handler is required for elicit_as_task"):
await ctx.elicit_as_task(message="Test?", requested_schema={"type": "object"})
store.cleanup()
@pytest.mark.anyio
async def test_create_message_as_task_raises_without_handler() -> None:
"""Test that create_message_as_task() raises when handler is not provided."""
store = InMemoryTaskStore()
queue = InMemoryTaskMessageQueue()
task = await store.create_task(TaskMetadata(ttl=60000))
# Create mock session with proper client capabilities
mock_session = Mock()
mock_session.client_params = InitializeRequestParams(
protocol_version="2025-01-01",
capabilities=ClientCapabilities(
tasks=ClientTasksCapability(
requests=ClientTasksRequestsCapability(
sampling=TasksSamplingCapability(create_message=TasksCreateMessageCapability())
)
)
),
client_info=Implementation(name="test", version="1.0"),
)
ctx = ServerTaskContext(
task=task,
store=store,
session=mock_session,
queue=queue,
handler=None,
)
with pytest.raises(RuntimeError, match="handler is required for create_message_as_task"):
await ctx.create_message_as_task(
messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))],
max_tokens=100,
)
store.cleanup()