SEP-1686: Tasks (#1645)

This commit is contained in:
Max Isbey
2025-11-28 18:51:58 +00:00
committed by GitHub
parent 5983a650cc
commit c92bb2f7ff
76 changed files with 14230 additions and 120 deletions
+1
View File
@@ -2512,6 +2512,7 @@ MCP servers declare capabilities during initialization:
## Documentation
- [API Reference](https://modelcontextprotocol.github.io/python-sdk/api/)
- [Experimental Features (Tasks)](https://modelcontextprotocol.github.io/python-sdk/experimental/tasks/)
- [Model Context Protocol documentation](https://modelcontextprotocol.io)
- [Model Context Protocol specification](https://modelcontextprotocol.io/specification/latest)
- [Officially supported servers](https://github.com/modelcontextprotocol/servers)
+43
View File
@@ -0,0 +1,43 @@
# Experimental Features
!!! warning "Experimental APIs"
The features in this section are experimental and may change without notice.
They track the evolving MCP specification and are not yet stable.
This section documents experimental features in the MCP Python SDK. These features
implement draft specifications that are still being refined.
## Available Experimental Features
### [Tasks](tasks.md)
Tasks enable asynchronous execution of MCP operations. Instead of waiting for a
long-running operation to complete, the server returns a task reference immediately.
Clients can then poll for status updates and retrieve results when ready.
Tasks are useful for:
- **Long-running computations** that would otherwise block
- **Batch operations** that process many items
- **Interactive workflows** that require user input (elicitation) or LLM assistance (sampling)
## Using Experimental APIs
Experimental features are accessed via the `.experimental` property:
```python
# Server-side
@server.experimental.get_task()
async def handle_get_task(request: GetTaskRequest) -> GetTaskResult:
...
# Client-side
result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"})
```
## Providing Feedback
Since these features are experimental, feedback is especially valuable. If you encounter
issues or have suggestions, please open an issue on the
[python-sdk repository](https://github.com/modelcontextprotocol/python-sdk/issues).
+361
View File
@@ -0,0 +1,361 @@
# Client Task Usage
!!! warning "Experimental"
Tasks are an experimental feature. The API may change without notice.
This guide covers calling task-augmented tools from clients, handling the `input_required` status, and advanced patterns like receiving task requests from servers.
## Quick Start
Call a tool as a task and poll for the result:
```python
from mcp.client.session import ClientSession
from mcp.types import CallToolResult
async with ClientSession(read, write) as session:
await session.initialize()
# Call tool as task
result = await session.experimental.call_tool_as_task(
"process_data",
{"input": "hello"},
ttl=60000,
)
task_id = result.task.taskId
# Poll until complete
async for status in session.experimental.poll_task(task_id):
print(f"Status: {status.status} - {status.statusMessage or ''}")
# Get result
final = await session.experimental.get_task_result(task_id, CallToolResult)
print(f"Result: {final.content[0].text}")
```
## Calling Tools as Tasks
Use `call_tool_as_task()` to invoke a tool with task augmentation:
```python
result = await session.experimental.call_tool_as_task(
"my_tool", # Tool name
{"arg": "value"}, # Arguments
ttl=60000, # Time-to-live in milliseconds
meta={"key": "val"}, # Optional metadata
)
task_id = result.task.taskId
print(f"Task: {task_id}, Status: {result.task.status}")
```
The response is a `CreateTaskResult` containing:
- `task.taskId` - Unique identifier for polling
- `task.status` - Initial status (usually `"working"`)
- `task.pollInterval` - Suggested polling interval (milliseconds)
- `task.ttl` - Time-to-live for results
- `task.createdAt` - Creation timestamp
## Polling with poll_task
The `poll_task()` async iterator polls until the task reaches a terminal state:
```python
async for status in session.experimental.poll_task(task_id):
print(f"Status: {status.status}")
if status.statusMessage:
print(f"Progress: {status.statusMessage}")
```
It automatically:
- Respects the server's suggested `pollInterval`
- Stops when status is `completed`, `failed`, or `cancelled`
- Yields each status for progress display
### Handling input_required
When a task needs user input (elicitation), it transitions to `input_required`. You must call `get_task_result()` to receive and respond to the elicitation:
```python
async for status in session.experimental.poll_task(task_id):
print(f"Status: {status.status}")
if status.status == "input_required":
# This delivers the elicitation and waits for completion
final = await session.experimental.get_task_result(task_id, CallToolResult)
break
```
The elicitation callback (set during session creation) handles the actual user interaction.
## Elicitation Callbacks
To handle elicitation requests from the server, provide a callback when creating the session:
```python
from mcp.types import ElicitRequestParams, ElicitResult
async def handle_elicitation(context, params: ElicitRequestParams) -> ElicitResult:
# Display the message to the user
print(f"Server asks: {params.message}")
# Collect user input (this is a simplified example)
response = input("Your response (y/n): ")
confirmed = response.lower() == "y"
return ElicitResult(
action="accept",
content={"confirm": confirmed},
)
async with ClientSession(
read,
write,
elicitation_callback=handle_elicitation,
) as session:
await session.initialize()
# ... call tasks that may require elicitation
```
## Sampling Callbacks
Similarly, handle sampling requests with a callback:
```python
from mcp.types import CreateMessageRequestParams, CreateMessageResult, TextContent
async def handle_sampling(context, params: CreateMessageRequestParams) -> CreateMessageResult:
# In a real implementation, call your LLM here
prompt = params.messages[-1].content.text if params.messages else ""
# Return a mock response
return CreateMessageResult(
role="assistant",
content=TextContent(type="text", text=f"Response to: {prompt}"),
model="my-model",
)
async with ClientSession(
read,
write,
sampling_callback=handle_sampling,
) as session:
# ...
```
## Retrieving Results
Once a task completes, retrieve the result:
```python
if status.status == "completed":
result = await session.experimental.get_task_result(task_id, CallToolResult)
for content in result.content:
if hasattr(content, "text"):
print(content.text)
elif status.status == "failed":
print(f"Task failed: {status.statusMessage}")
elif status.status == "cancelled":
print("Task was cancelled")
```
The result type matches the original request:
- `tools/call``CallToolResult`
- `sampling/createMessage``CreateMessageResult`
- `elicitation/create``ElicitResult`
## Cancellation
Cancel a running task:
```python
cancel_result = await session.experimental.cancel_task(task_id)
print(f"Cancelled, status: {cancel_result.status}")
```
Note: Cancellation is cooperative—the server must check for and handle cancellation.
## Listing Tasks
View all tasks on the server:
```python
result = await session.experimental.list_tasks()
for task in result.tasks:
print(f"{task.taskId}: {task.status}")
# Handle pagination
while result.nextCursor:
result = await session.experimental.list_tasks(cursor=result.nextCursor)
for task in result.tasks:
print(f"{task.taskId}: {task.status}")
```
## Advanced: Client as Task Receiver
Servers can send task-augmented requests to clients. This is useful when the server needs the client to perform async work (like complex sampling or user interaction).
### Declaring Client Capabilities
Register task handlers to declare what task-augmented requests your client accepts:
```python
from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers
from mcp.types import (
CreateTaskResult, GetTaskResult, GetTaskPayloadResult,
TaskMetadata, ElicitRequestParams,
)
from mcp.shared.experimental.tasks import InMemoryTaskStore
# Client-side task store
client_store = InMemoryTaskStore()
async def handle_augmented_elicitation(context, params: ElicitRequestParams, task_metadata: TaskMetadata):
"""Handle task-augmented elicitation from server."""
# Create a task for this elicitation
task = await client_store.create_task(task_metadata)
# Start async work (e.g., show UI, wait for user)
async def complete_elicitation():
# ... do async work ...
result = ElicitResult(action="accept", content={"confirm": True})
await client_store.store_result(task.taskId, result)
await client_store.update_task(task.taskId, status="completed")
context.session._task_group.start_soon(complete_elicitation)
# Return task reference immediately
return CreateTaskResult(task=task)
async def handle_get_task(context, params):
"""Handle tasks/get from server."""
task = await client_store.get_task(params.taskId)
return GetTaskResult(
taskId=task.taskId,
status=task.status,
statusMessage=task.statusMessage,
createdAt=task.createdAt,
lastUpdatedAt=task.lastUpdatedAt,
ttl=task.ttl,
pollInterval=100,
)
async def handle_get_task_result(context, params):
"""Handle tasks/result from server."""
result = await client_store.get_result(params.taskId)
return GetTaskPayloadResult.model_validate(result.model_dump())
task_handlers = ExperimentalTaskHandlers(
augmented_elicitation=handle_augmented_elicitation,
get_task=handle_get_task,
get_task_result=handle_get_task_result,
)
async with ClientSession(
read,
write,
experimental_task_handlers=task_handlers,
) as session:
# Client now accepts task-augmented elicitation from server
await session.initialize()
```
This enables flows where:
1. Client calls a task-augmented tool
2. Server's tool work calls `task.elicit_as_task()`
3. Client receives task-augmented elicitation
4. Client creates its own task, does async work
5. Server polls client's task
6. Eventually both tasks complete
## Complete Example
A client that handles all task scenarios:
```python
import anyio
from mcp.client.session import ClientSession
from mcp.client.stdio import stdio_client
from mcp.types import CallToolResult, ElicitRequestParams, ElicitResult
async def elicitation_callback(context, params: ElicitRequestParams) -> ElicitResult:
print(f"\n[Elicitation] {params.message}")
response = input("Confirm? (y/n): ")
return ElicitResult(action="accept", content={"confirm": response.lower() == "y"})
async def main():
async with stdio_client(command="python", args=["server.py"]) as (read, write):
async with ClientSession(
read,
write,
elicitation_callback=elicitation_callback,
) as session:
await session.initialize()
# List available tools
tools = await session.list_tools()
print("Tools:", [t.name for t in tools.tools])
# Call a task-augmented tool
print("\nCalling task tool...")
result = await session.experimental.call_tool_as_task(
"confirm_action",
{"action": "delete files"},
)
task_id = result.task.taskId
print(f"Task created: {task_id}")
# Poll and handle input_required
async for status in session.experimental.poll_task(task_id):
print(f"Status: {status.status}")
if status.status == "input_required":
final = await session.experimental.get_task_result(task_id, CallToolResult)
print(f"Result: {final.content[0].text}")
break
if status.status == "completed":
final = await session.experimental.get_task_result(task_id, CallToolResult)
print(f"Result: {final.content[0].text}")
if __name__ == "__main__":
anyio.run(main)
```
## Error Handling
Handle task errors gracefully:
```python
from mcp.shared.exceptions import McpError
try:
result = await session.experimental.call_tool_as_task("my_tool", args)
task_id = result.task.taskId
async for status in session.experimental.poll_task(task_id):
if status.status == "failed":
raise RuntimeError(f"Task failed: {status.statusMessage}")
final = await session.experimental.get_task_result(task_id, CallToolResult)
except McpError as e:
print(f"MCP error: {e.error.message}")
except Exception as e:
print(f"Error: {e}")
```
## Next Steps
- [Server Implementation](tasks-server.md) - Build task-supporting servers
- [Tasks Overview](tasks.md) - Review lifecycle and concepts
+597
View File
@@ -0,0 +1,597 @@
# Server Task Implementation
!!! warning "Experimental"
Tasks are an experimental feature. The API may change without notice.
This guide covers implementing task support in MCP servers, from basic setup to advanced patterns like elicitation and sampling within tasks.
## Quick Start
The simplest way to add task support:
```python
from mcp.server import Server
from mcp.server.experimental.task_context import ServerTaskContext
from mcp.types import CallToolResult, CreateTaskResult, TextContent, Tool, ToolExecution, TASK_REQUIRED
server = Server("my-server")
server.experimental.enable_tasks() # Registers all task handlers automatically
@server.list_tools()
async def list_tools():
return [
Tool(
name="process_data",
description="Process data asynchronously",
inputSchema={"type": "object", "properties": {"input": {"type": "string"}}},
execution=ToolExecution(taskSupport=TASK_REQUIRED),
)
]
@server.call_tool()
async def handle_tool(name: str, arguments: dict) -> CallToolResult | CreateTaskResult:
if name == "process_data":
return await handle_process_data(arguments)
return CallToolResult(content=[TextContent(type="text", text=f"Unknown: {name}")], isError=True)
async def handle_process_data(arguments: dict) -> CreateTaskResult:
ctx = server.request_context
ctx.experimental.validate_task_mode(TASK_REQUIRED)
async def work(task: ServerTaskContext) -> CallToolResult:
await task.update_status("Processing...")
result = arguments.get("input", "").upper()
return CallToolResult(content=[TextContent(type="text", text=result)])
return await ctx.experimental.run_task(work)
```
That's it. `enable_tasks()` automatically:
- Creates an in-memory task store
- Registers handlers for `tasks/get`, `tasks/result`, `tasks/list`, `tasks/cancel`
- Updates server capabilities
## Tool Declaration
Tools declare task support via the `execution.taskSupport` field:
```python
from mcp.types import Tool, ToolExecution, TASK_REQUIRED, TASK_OPTIONAL, TASK_FORBIDDEN
Tool(
name="my_tool",
inputSchema={"type": "object"},
execution=ToolExecution(taskSupport=TASK_REQUIRED), # or TASK_OPTIONAL, TASK_FORBIDDEN
)
```
| Value | Meaning |
|-------|---------|
| `TASK_REQUIRED` | Tool **must** be called as a task |
| `TASK_OPTIONAL` | Tool supports both sync and task execution |
| `TASK_FORBIDDEN` | Tool **cannot** be called as a task (default) |
Validate the request matches your tool's requirements:
```python
@server.call_tool()
async def handle_tool(name: str, arguments: dict):
ctx = server.request_context
if name == "required_task_tool":
ctx.experimental.validate_task_mode(TASK_REQUIRED) # Raises if not task mode
return await handle_as_task(arguments)
elif name == "optional_task_tool":
if ctx.experimental.is_task:
return await handle_as_task(arguments)
else:
return handle_sync(arguments)
```
## The run_task Pattern
`run_task()` is the recommended way to execute task work:
```python
async def handle_my_tool(arguments: dict) -> CreateTaskResult:
ctx = server.request_context
ctx.experimental.validate_task_mode(TASK_REQUIRED)
async def work(task: ServerTaskContext) -> CallToolResult:
# Your work here
return CallToolResult(content=[TextContent(type="text", text="Done")])
return await ctx.experimental.run_task(work)
```
**What `run_task()` does:**
1. Creates a task in the store
2. Spawns your work function in the background
3. Returns `CreateTaskResult` immediately
4. Auto-completes the task when your function returns
5. Auto-fails the task if your function raises
**The `ServerTaskContext` provides:**
- `task.task_id` - The task identifier
- `task.update_status(message)` - Update progress
- `task.complete(result)` - Explicitly complete (usually automatic)
- `task.fail(error)` - Explicitly fail
- `task.is_cancelled` - Check if cancellation requested
## Status Updates
Keep clients informed of progress:
```python
async def work(task: ServerTaskContext) -> CallToolResult:
await task.update_status("Starting...")
for i, item in enumerate(items):
await task.update_status(f"Processing {i+1}/{len(items)}")
await process_item(item)
await task.update_status("Finalizing...")
return CallToolResult(content=[TextContent(type="text", text="Complete")])
```
Status messages appear in `tasks/get` responses, letting clients show progress to users.
## Elicitation Within Tasks
Tasks can request user input via elicitation. This transitions the task to `input_required` status.
### Form Elicitation
Collect structured data from the user:
```python
async def work(task: ServerTaskContext) -> CallToolResult:
await task.update_status("Waiting for confirmation...")
result = await task.elicit(
message="Delete these files?",
requestedSchema={
"type": "object",
"properties": {
"confirm": {"type": "boolean"},
"reason": {"type": "string"},
},
"required": ["confirm"],
},
)
if result.action == "accept" and result.content.get("confirm"):
# User confirmed
return CallToolResult(content=[TextContent(type="text", text="Files deleted")])
else:
# User declined or cancelled
return CallToolResult(content=[TextContent(type="text", text="Cancelled")])
```
### URL Elicitation
Direct users to external URLs for OAuth, payments, or other out-of-band flows:
```python
async def work(task: ServerTaskContext) -> CallToolResult:
await task.update_status("Waiting for OAuth...")
result = await task.elicit_url(
message="Please authorize with GitHub",
url="https://github.com/login/oauth/authorize?client_id=...",
elicitation_id="oauth-github-123",
)
if result.action == "accept":
# User completed OAuth flow
return CallToolResult(content=[TextContent(type="text", text="Connected to GitHub")])
else:
return CallToolResult(content=[TextContent(type="text", text="OAuth cancelled")])
```
## Sampling Within Tasks
Tasks can request LLM completions from the client:
```python
from mcp.types import SamplingMessage, TextContent
async def work(task: ServerTaskContext) -> CallToolResult:
await task.update_status("Generating response...")
result = await task.create_message(
messages=[
SamplingMessage(
role="user",
content=TextContent(type="text", text="Write a haiku about coding"),
)
],
max_tokens=100,
)
haiku = result.content.text if isinstance(result.content, TextContent) else "Error"
return CallToolResult(content=[TextContent(type="text", text=haiku)])
```
Sampling supports additional parameters:
```python
result = await task.create_message(
messages=[...],
max_tokens=500,
system_prompt="You are a helpful assistant",
temperature=0.7,
stop_sequences=["\n\n"],
model_preferences=ModelPreferences(hints=[ModelHint(name="claude-3")]),
)
```
## Cancellation Support
Check for cancellation in long-running work:
```python
async def work(task: ServerTaskContext) -> CallToolResult:
for i in range(1000):
if task.is_cancelled:
# Clean up and exit
return CallToolResult(content=[TextContent(type="text", text="Cancelled")])
await task.update_status(f"Step {i}/1000")
await process_step(i)
return CallToolResult(content=[TextContent(type="text", text="Complete")])
```
The SDK's default cancel handler updates the task status. Your work function should check `is_cancelled` periodically.
## Custom Task Store
For production, implement `TaskStore` with persistent storage:
```python
from mcp.shared.experimental.tasks.store import TaskStore
from mcp.types import Task, TaskMetadata, Result
class RedisTaskStore(TaskStore):
def __init__(self, redis_client):
self.redis = redis_client
async def create_task(self, metadata: TaskMetadata, task_id: str | None = None) -> Task:
# Create and persist task
...
async def get_task(self, task_id: str) -> Task | None:
# Retrieve task from Redis
...
async def update_task(self, task_id: str, status: str | None = None, ...) -> Task:
# Update and persist
...
async def store_result(self, task_id: str, result: Result) -> None:
# Store result in Redis
...
async def get_result(self, task_id: str) -> Result | None:
# Retrieve result
...
# ... implement remaining methods
```
Use your custom store:
```python
store = RedisTaskStore(redis_client)
server.experimental.enable_tasks(store=store)
```
## Complete Example
A server with multiple task-supporting tools:
```python
from mcp.server import Server
from mcp.server.experimental.task_context import ServerTaskContext
from mcp.types import (
CallToolResult, CreateTaskResult, TextContent, Tool, ToolExecution,
SamplingMessage, TASK_REQUIRED,
)
server = Server("task-demo")
server.experimental.enable_tasks()
@server.list_tools()
async def list_tools():
return [
Tool(
name="confirm_action",
description="Requires user confirmation",
inputSchema={"type": "object", "properties": {"action": {"type": "string"}}},
execution=ToolExecution(taskSupport=TASK_REQUIRED),
),
Tool(
name="generate_text",
description="Generate text via LLM",
inputSchema={"type": "object", "properties": {"prompt": {"type": "string"}}},
execution=ToolExecution(taskSupport=TASK_REQUIRED),
),
]
async def handle_confirm_action(arguments: dict) -> CreateTaskResult:
ctx = server.request_context
ctx.experimental.validate_task_mode(TASK_REQUIRED)
action = arguments.get("action", "unknown action")
async def work(task: ServerTaskContext) -> CallToolResult:
result = await task.elicit(
message=f"Confirm: {action}?",
requestedSchema={
"type": "object",
"properties": {"confirm": {"type": "boolean"}},
"required": ["confirm"],
},
)
if result.action == "accept" and result.content.get("confirm"):
return CallToolResult(content=[TextContent(type="text", text=f"Executed: {action}")])
return CallToolResult(content=[TextContent(type="text", text="Cancelled")])
return await ctx.experimental.run_task(work)
async def handle_generate_text(arguments: dict) -> CreateTaskResult:
ctx = server.request_context
ctx.experimental.validate_task_mode(TASK_REQUIRED)
prompt = arguments.get("prompt", "Hello")
async def work(task: ServerTaskContext) -> CallToolResult:
await task.update_status("Generating...")
result = await task.create_message(
messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))],
max_tokens=200,
)
text = result.content.text if isinstance(result.content, TextContent) else "Error"
return CallToolResult(content=[TextContent(type="text", text=text)])
return await ctx.experimental.run_task(work)
@server.call_tool()
async def handle_tool(name: str, arguments: dict) -> CallToolResult | CreateTaskResult:
if name == "confirm_action":
return await handle_confirm_action(arguments)
elif name == "generate_text":
return await handle_generate_text(arguments)
return CallToolResult(content=[TextContent(type="text", text=f"Unknown: {name}")], isError=True)
```
## Error Handling in Tasks
Tasks handle errors automatically, but you can also fail explicitly:
```python
async def work(task: ServerTaskContext) -> CallToolResult:
try:
result = await risky_operation()
return CallToolResult(content=[TextContent(type="text", text=result)])
except PermissionError:
await task.fail("Access denied - insufficient permissions")
raise
except TimeoutError:
await task.fail("Operation timed out after 30 seconds")
raise
```
When `run_task()` catches an exception, it automatically:
1. Marks the task as `failed`
2. Sets `statusMessage` to the exception message
3. Propagates the exception (which is caught by the task group)
For custom error messages, call `task.fail()` before raising.
## HTTP Transport Example
For web applications, use the Streamable HTTP transport:
```python
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import uvicorn
from starlette.applications import Starlette
from starlette.routing import Mount
from mcp.server import Server
from mcp.server.experimental.task_context import ServerTaskContext
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.types import (
CallToolResult, CreateTaskResult, TextContent, Tool, ToolExecution, TASK_REQUIRED,
)
server = Server("http-task-server")
server.experimental.enable_tasks()
@server.list_tools()
async def list_tools():
return [
Tool(
name="long_operation",
description="A long-running operation",
inputSchema={"type": "object", "properties": {"duration": {"type": "number"}}},
execution=ToolExecution(taskSupport=TASK_REQUIRED),
)
]
async def handle_long_operation(arguments: dict) -> CreateTaskResult:
ctx = server.request_context
ctx.experimental.validate_task_mode(TASK_REQUIRED)
duration = arguments.get("duration", 5)
async def work(task: ServerTaskContext) -> CallToolResult:
import anyio
for i in range(int(duration)):
await task.update_status(f"Step {i+1}/{int(duration)}")
await anyio.sleep(1)
return CallToolResult(content=[TextContent(type="text", text=f"Completed after {duration}s")])
return await ctx.experimental.run_task(work)
@server.call_tool()
async def handle_tool(name: str, arguments: dict) -> CallToolResult | CreateTaskResult:
if name == "long_operation":
return await handle_long_operation(arguments)
return CallToolResult(content=[TextContent(type="text", text=f"Unknown: {name}")], isError=True)
def create_app():
session_manager = StreamableHTTPSessionManager(app=server)
@asynccontextmanager
async def lifespan(app: Starlette) -> AsyncIterator[None]:
async with session_manager.run():
yield
return Starlette(
routes=[Mount("/mcp", app=session_manager.handle_request)],
lifespan=lifespan,
)
if __name__ == "__main__":
uvicorn.run(create_app(), host="127.0.0.1", port=8000)
```
## Testing Task Servers
Test task functionality with the SDK's testing utilities:
```python
import pytest
import anyio
from mcp.client.session import ClientSession
from mcp.types import CallToolResult
@pytest.mark.anyio
async def test_task_tool():
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream(10)
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream(10)
async def run_server():
await server.run(
client_to_server_receive,
server_to_client_send,
server.create_initialization_options(),
)
async def run_client():
async with ClientSession(server_to_client_receive, client_to_server_send) as session:
await session.initialize()
# Call the tool as a task
result = await session.experimental.call_tool_as_task("my_tool", {"arg": "value"})
task_id = result.task.taskId
assert result.task.status == "working"
# Poll until complete
async for status in session.experimental.poll_task(task_id):
if status.status in ("completed", "failed"):
break
# Get result
final = await session.experimental.get_task_result(task_id, CallToolResult)
assert len(final.content) > 0
async with anyio.create_task_group() as tg:
tg.start_soon(run_server)
tg.start_soon(run_client)
```
## Best Practices
### Keep Work Functions Focused
```python
# Good: focused work function
async def work(task: ServerTaskContext) -> CallToolResult:
await task.update_status("Validating...")
validate_input(arguments)
await task.update_status("Processing...")
result = await process_data(arguments)
return CallToolResult(content=[TextContent(type="text", text=result)])
```
### Check Cancellation in Loops
```python
async def work(task: ServerTaskContext) -> CallToolResult:
results = []
for item in large_dataset:
if task.is_cancelled:
return CallToolResult(content=[TextContent(type="text", text="Cancelled")])
results.append(await process(item))
return CallToolResult(content=[TextContent(type="text", text=str(results))])
```
### Use Meaningful Status Messages
```python
async def work(task: ServerTaskContext) -> CallToolResult:
await task.update_status("Connecting to database...")
db = await connect()
await task.update_status("Fetching records (0/1000)...")
for i, record in enumerate(records):
if i % 100 == 0:
await task.update_status(f"Processing records ({i}/1000)...")
await process(record)
await task.update_status("Finalizing results...")
return CallToolResult(content=[TextContent(type="text", text="Done")])
```
### Handle Elicitation Responses
```python
async def work(task: ServerTaskContext) -> CallToolResult:
result = await task.elicit(message="Continue?", requestedSchema={...})
match result.action:
case "accept":
# User accepted, process content
return await process_accepted(result.content)
case "decline":
# User explicitly declined
return CallToolResult(content=[TextContent(type="text", text="User declined")])
case "cancel":
# User cancelled the elicitation
return CallToolResult(content=[TextContent(type="text", text="Cancelled")])
```
## Next Steps
- [Client Usage](tasks-client.md) - Learn how clients interact with task servers
- [Tasks Overview](tasks.md) - Review lifecycle and concepts
+188
View File
@@ -0,0 +1,188 @@
# Tasks
!!! warning "Experimental"
Tasks are an experimental feature tracking the draft MCP specification.
The API may change without notice.
Tasks enable asynchronous request handling in MCP. Instead of blocking until an operation completes, the receiver creates a task, returns immediately, and the requestor polls for the result.
## When to Use Tasks
Tasks are designed for operations that:
- Take significant time (seconds to minutes)
- Need progress updates during execution
- Require user input mid-execution (elicitation, sampling)
- Should run without blocking the requestor
Common use cases:
- Long-running data processing
- Multi-step workflows with user confirmation
- LLM-powered operations requiring sampling
- OAuth flows requiring user browser interaction
## Task Lifecycle
```text
┌─────────────┐
│ working │
└──────┬──────┘
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
┌────────────┐ ┌───────────┐ ┌───────────┐
│ completed │ │ failed │ │ cancelled │
└────────────┘ └───────────┘ └───────────┘
┌────────┴────────┐
│ input_required │◄──────┐
└────────┬────────┘ │
│ │
└────────────────┘
```
| Status | Description |
|--------|-------------|
| `working` | Task is being processed |
| `input_required` | Receiver needs input from requestor (elicitation/sampling) |
| `completed` | Task finished successfully |
| `failed` | Task encountered an error |
| `cancelled` | Task was cancelled by requestor |
Terminal states (`completed`, `failed`, `cancelled`) are final—tasks cannot transition out of them.
## Bidirectional Flow
Tasks work in both directions:
**Client → Server** (most common):
```text
Client Server
│ │
│── tools/call (task) ──────────────>│ Creates task
│<── CreateTaskResult ───────────────│
│ │
│── tasks/get ──────────────────────>│
│<── status: working ────────────────│
│ │ ... work continues ...
│── tasks/get ──────────────────────>│
│<── status: completed ──────────────│
│ │
│── tasks/result ───────────────────>│
│<── CallToolResult ─────────────────│
```
**Server → Client** (for elicitation/sampling):
```text
Server Client
│ │
│── elicitation/create (task) ──────>│ Creates task
│<── CreateTaskResult ───────────────│
│ │
│── tasks/get ──────────────────────>│
│<── status: working ────────────────│
│ │ ... user interaction ...
│── tasks/get ──────────────────────>│
│<── status: completed ──────────────│
│ │
│── tasks/result ───────────────────>│
│<── ElicitResult ───────────────────│
```
## Key Concepts
### Task Metadata
When augmenting a request with task execution, include `TaskMetadata`:
```python
from mcp.types import TaskMetadata
task = TaskMetadata(ttl=60000) # TTL in milliseconds
```
The `ttl` (time-to-live) specifies how long the task and result are retained after completion.
### Task Store
Servers persist task state in a `TaskStore`. The SDK provides `InMemoryTaskStore` for development:
```python
from mcp.shared.experimental.tasks import InMemoryTaskStore
store = InMemoryTaskStore()
```
For production, implement `TaskStore` with a database or distributed cache.
### Capabilities
Both servers and clients declare task support through capabilities:
**Server capabilities:**
- `tasks.requests.tools.call` - Server accepts task-augmented tool calls
**Client capabilities:**
- `tasks.requests.sampling.createMessage` - Client accepts task-augmented sampling
- `tasks.requests.elicitation.create` - Client accepts task-augmented elicitation
The SDK manages these automatically when you enable task support.
## Quick Example
**Server** (simplified API):
```python
from mcp.server import Server
from mcp.server.experimental.task_context import ServerTaskContext
from mcp.types import CallToolResult, TextContent, TASK_REQUIRED
server = Server("my-server")
server.experimental.enable_tasks() # One-line setup
@server.call_tool()
async def handle_tool(name: str, arguments: dict):
ctx = server.request_context
ctx.experimental.validate_task_mode(TASK_REQUIRED)
async def work(task: ServerTaskContext):
await task.update_status("Processing...")
# ... do work ...
return CallToolResult(content=[TextContent(type="text", text="Done!")])
return await ctx.experimental.run_task(work)
```
**Client:**
```python
from mcp.client.session import ClientSession
from mcp.types import CallToolResult
async with ClientSession(read, write) as session:
await session.initialize()
# Call tool as task
result = await session.experimental.call_tool_as_task("my_tool", {"arg": "value"})
task_id = result.task.taskId
# Poll until done
async for status in session.experimental.poll_task(task_id):
print(f"Status: {status.status}")
# Get result
final = await session.experimental.get_task_result(task_id, CallToolResult)
```
## Next Steps
- [Server Implementation](tasks-server.md) - Build task-supporting servers
- [Client Usage](tasks-client.md) - Call and poll tasks from clients
@@ -0,0 +1,43 @@
# Simple Task Client
A minimal MCP client demonstrating polling for task results over streamable HTTP.
## Running
First, start the simple-task server in another terminal:
```bash
cd examples/servers/simple-task
uv run mcp-simple-task
```
Then run the client:
```bash
cd examples/clients/simple-task-client
uv run mcp-simple-task-client
```
Use `--url` to connect to a different server.
## What it does
1. Connects to the server via streamable HTTP
2. Calls the `long_running_task` tool as a task
3. Polls the task status until completion
4. Retrieves and prints the result
## Expected output
```text
Available tools: ['long_running_task']
Calling tool as a task...
Task created: <task-id>
Status: working - Starting work...
Status: working - Processing step 1...
Status: working - Processing step 2...
Status: completed -
Result: Task completed!
```
@@ -0,0 +1,5 @@
import sys
from .main import main
sys.exit(main()) # type: ignore[call-arg]
@@ -0,0 +1,55 @@
"""Simple task client demonstrating MCP tasks polling over streamable HTTP."""
import asyncio
import click
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
from mcp.types import CallToolResult, TextContent
async def run(url: str) -> None:
async with streamablehttp_client(url) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
# List tools
tools = await session.list_tools()
print(f"Available tools: {[t.name for t in tools.tools]}")
# Call the tool as a task
print("\nCalling tool as a task...")
result = await session.experimental.call_tool_as_task(
"long_running_task",
arguments={},
ttl=60000,
)
task_id = result.task.taskId
print(f"Task created: {task_id}")
# Poll until done (respects server's pollInterval hint)
async for status in session.experimental.poll_task(task_id):
print(f" Status: {status.status} - {status.statusMessage or ''}")
# Check final status
if status.status != "completed":
print(f"Task ended with status: {status.status}")
return
# Get the result
task_result = await session.experimental.get_task_result(task_id, CallToolResult)
content = task_result.content[0]
if isinstance(content, TextContent):
print(f"\nResult: {content.text}")
@click.command()
@click.option("--url", default="http://localhost:8000/mcp", help="Server URL")
def main(url: str) -> int:
asyncio.run(run(url))
return 0
if __name__ == "__main__":
main()
@@ -0,0 +1,43 @@
[project]
name = "mcp-simple-task-client"
version = "0.1.0"
description = "A simple MCP client demonstrating task polling"
readme = "README.md"
requires-python = ">=3.10"
authors = [{ name = "Anthropic, PBC." }]
keywords = ["mcp", "llm", "tasks", "client"]
license = { text = "MIT" }
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
]
dependencies = ["click>=8.0", "mcp"]
[project.scripts]
mcp-simple-task-client = "mcp_simple_task_client.main:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["mcp_simple_task_client"]
[tool.pyright]
include = ["mcp_simple_task_client"]
venvPath = "."
venv = ".venv"
[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = []
[tool.ruff]
line-length = 120
target-version = "py310"
[dependency-groups]
dev = ["pyright>=1.1.378", "ruff>=0.6.9"]
@@ -0,0 +1,87 @@
# Simple Interactive Task Client
A minimal MCP client demonstrating responses to interactive tasks (elicitation and sampling).
## Running
First, start the interactive task server in another terminal:
```bash
cd examples/servers/simple-task-interactive
uv run mcp-simple-task-interactive
```
Then run the client:
```bash
cd examples/clients/simple-task-interactive-client
uv run mcp-simple-task-interactive-client
```
Use `--url` to connect to a different server.
## What it does
1. Connects to the server via streamable HTTP
2. Calls `confirm_delete` - server asks for confirmation, client responds via terminal
3. Calls `write_haiku` - server requests LLM completion, client returns a hardcoded haiku
## Key concepts
### Elicitation callback
```python
async def elicitation_callback(context, params) -> ElicitResult:
# Handle user input request from server
return ElicitResult(action="accept", content={"confirm": True})
```
### Sampling callback
```python
async def sampling_callback(context, params) -> CreateMessageResult:
# Handle LLM completion request from server
return CreateMessageResult(model="...", role="assistant", content=...)
```
### Using call_tool_as_task
```python
# Call a tool as a task (returns immediately with task reference)
result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"})
task_id = result.task.taskId
# Get result - this delivers elicitation/sampling requests and blocks until complete
final = await session.experimental.get_task_result(task_id, CallToolResult)
```
**Important**: The `get_task_result()` call is what triggers the delivery of elicitation
and sampling requests to your callbacks. It blocks until the task completes and returns
the final result.
## Expected output
```text
Available tools: ['confirm_delete', 'write_haiku']
--- Demo 1: Elicitation ---
Calling confirm_delete tool...
Task created: <task-id>
[Elicitation] Server asks: Are you sure you want to delete 'important.txt'?
Your response (y/n): y
[Elicitation] Responding with: confirm=True
Result: Deleted 'important.txt'
--- Demo 2: Sampling ---
Calling write_haiku tool...
Task created: <task-id>
[Sampling] Server requests LLM completion for: Write a haiku about autumn leaves
[Sampling] Responding with haiku
Result:
Haiku:
Cherry blossoms fall
Softly on the quiet pond
Spring whispers goodbye
```
@@ -0,0 +1,5 @@
import sys
from .main import main
sys.exit(main()) # type: ignore[call-arg]
@@ -0,0 +1,138 @@
"""Simple interactive task client demonstrating elicitation and sampling responses.
This example demonstrates the spec-compliant polling pattern:
1. Poll tasks/get watching for status changes
2. On input_required, call tasks/result to receive elicitation/sampling requests
3. Continue until terminal status, then retrieve final result
"""
import asyncio
from typing import Any
import click
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
from mcp.shared.context import RequestContext
from mcp.types import (
CallToolResult,
CreateMessageRequestParams,
CreateMessageResult,
ElicitRequestParams,
ElicitResult,
TextContent,
)
async def elicitation_callback(
context: RequestContext[ClientSession, Any],
params: ElicitRequestParams,
) -> ElicitResult:
"""Handle elicitation requests from the server."""
print(f"\n[Elicitation] Server asks: {params.message}")
# Simple terminal prompt
response = input("Your response (y/n): ").strip().lower()
confirmed = response in ("y", "yes", "true", "1")
print(f"[Elicitation] Responding with: confirm={confirmed}")
return ElicitResult(action="accept", content={"confirm": confirmed})
async def sampling_callback(
context: RequestContext[ClientSession, Any],
params: CreateMessageRequestParams,
) -> CreateMessageResult:
"""Handle sampling requests from the server."""
# Get the prompt from the first message
prompt = "unknown"
if params.messages:
content = params.messages[0].content
if isinstance(content, TextContent):
prompt = content.text
print(f"\n[Sampling] Server requests LLM completion for: {prompt}")
# Return a hardcoded haiku (in real use, call your LLM here)
haiku = """Cherry blossoms fall
Softly on the quiet pond
Spring whispers goodbye"""
print("[Sampling] Responding with haiku")
return CreateMessageResult(
model="mock-haiku-model",
role="assistant",
content=TextContent(type="text", text=haiku),
)
def get_text(result: CallToolResult) -> str:
"""Extract text from a CallToolResult."""
if result.content and isinstance(result.content[0], TextContent):
return result.content[0].text
return "(no text)"
async def run(url: str) -> None:
async with streamablehttp_client(url) as (read, write, _):
async with ClientSession(
read,
write,
elicitation_callback=elicitation_callback,
sampling_callback=sampling_callback,
) as session:
await session.initialize()
# List tools
tools = await session.list_tools()
print(f"Available tools: {[t.name for t in tools.tools]}")
# Demo 1: Elicitation (confirm_delete)
print("\n--- Demo 1: Elicitation ---")
print("Calling confirm_delete tool...")
elicit_task = await session.experimental.call_tool_as_task("confirm_delete", {"filename": "important.txt"})
elicit_task_id = elicit_task.task.taskId
print(f"Task created: {elicit_task_id}")
# Poll until terminal, calling tasks/result on input_required
async for status in session.experimental.poll_task(elicit_task_id):
print(f"[Poll] Status: {status.status}")
if status.status == "input_required":
# Server needs input - tasks/result delivers the elicitation request
elicit_result = await session.experimental.get_task_result(elicit_task_id, CallToolResult)
break
else:
# poll_task exited due to terminal status
elicit_result = await session.experimental.get_task_result(elicit_task_id, CallToolResult)
print(f"Result: {get_text(elicit_result)}")
# Demo 2: Sampling (write_haiku)
print("\n--- Demo 2: Sampling ---")
print("Calling write_haiku tool...")
sampling_task = await session.experimental.call_tool_as_task("write_haiku", {"topic": "autumn leaves"})
sampling_task_id = sampling_task.task.taskId
print(f"Task created: {sampling_task_id}")
# Poll until terminal, calling tasks/result on input_required
async for status in session.experimental.poll_task(sampling_task_id):
print(f"[Poll] Status: {status.status}")
if status.status == "input_required":
sampling_result = await session.experimental.get_task_result(sampling_task_id, CallToolResult)
break
else:
sampling_result = await session.experimental.get_task_result(sampling_task_id, CallToolResult)
print(f"Result:\n{get_text(sampling_result)}")
@click.command()
@click.option("--url", default="http://localhost:8000/mcp", help="Server URL")
def main(url: str) -> int:
asyncio.run(run(url))
return 0
if __name__ == "__main__":
main()
@@ -0,0 +1,43 @@
[project]
name = "mcp-simple-task-interactive-client"
version = "0.1.0"
description = "A simple MCP client demonstrating interactive task responses"
readme = "README.md"
requires-python = ">=3.10"
authors = [{ name = "Anthropic, PBC." }]
keywords = ["mcp", "llm", "tasks", "client", "elicitation", "sampling"]
license = { text = "MIT" }
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
]
dependencies = ["click>=8.0", "mcp"]
[project.scripts]
mcp-simple-task-interactive-client = "mcp_simple_task_interactive_client.main:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["mcp_simple_task_interactive_client"]
[tool.pyright]
include = ["mcp_simple_task_interactive_client"]
venvPath = "."
venv = ".venv"
[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = []
[tool.ruff]
line-length = 120
target-version = "py310"
[dependency-groups]
dev = ["pyright>=1.1.378", "ruff>=0.6.9"]
@@ -0,0 +1,74 @@
# Simple Interactive Task Server
A minimal MCP server demonstrating interactive tasks with elicitation and sampling.
## Running
```bash
cd examples/servers/simple-task-interactive
uv run mcp-simple-task-interactive
```
The server starts on `http://localhost:8000/mcp` by default. Use `--port` to change.
## What it does
This server exposes two tools:
### `confirm_delete` (demonstrates elicitation)
Asks the user for confirmation before "deleting" a file.
- Uses `task.elicit()` to request user input
- Shows the elicitation flow: task -> input_required -> response -> complete
### `write_haiku` (demonstrates sampling)
Asks the LLM to write a haiku about a topic.
- Uses `task.create_message()` to request LLM completion
- Shows the sampling flow: task -> input_required -> response -> complete
## Usage with the client
In one terminal, start the server:
```bash
cd examples/servers/simple-task-interactive
uv run mcp-simple-task-interactive
```
In another terminal, run the interactive client:
```bash
cd examples/clients/simple-task-interactive-client
uv run mcp-simple-task-interactive-client
```
## Expected server output
When a client connects and calls the tools, you'll see:
```text
Starting server on http://localhost:8000/mcp
[Server] confirm_delete called for 'important.txt'
[Server] Task created: <task-id>
[Server] Sending elicitation request to client...
[Server] Received elicitation response: action=accept, content={'confirm': True}
[Server] Completing task with result: Deleted 'important.txt'
[Server] write_haiku called for topic 'autumn leaves'
[Server] Task created: <task-id>
[Server] Sending sampling request to client...
[Server] Received sampling response: Cherry blossoms fall
Softly on the quiet pon...
[Server] Completing task with haiku
```
## Key concepts
1. **ServerTaskContext**: Provides `elicit()` and `create_message()` for user interaction
2. **run_task()**: Spawns background work, auto-completes/fails, returns immediately
3. **TaskResultHandler**: Delivers queued messages and routes responses
4. **Response routing**: Responses are routed back to waiting resolvers
@@ -0,0 +1,5 @@
import sys
from .server import main
sys.exit(main()) # type: ignore[call-arg]
@@ -0,0 +1,147 @@
"""Simple interactive task server demonstrating elicitation and sampling.
This example shows the simplified task API where:
- server.experimental.enable_tasks() sets up all infrastructure
- ctx.experimental.run_task() handles task lifecycle automatically
- ServerTaskContext.elicit() and ServerTaskContext.create_message() queue requests properly
"""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
import click
import mcp.types as types
import uvicorn
from mcp.server.experimental.task_context import ServerTaskContext
from mcp.server.lowlevel import Server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from starlette.applications import Starlette
from starlette.routing import Mount
server = Server("simple-task-interactive")
# Enable task support - this auto-registers all handlers
server.experimental.enable_tasks()
@server.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name="confirm_delete",
description="Asks for confirmation before deleting (demonstrates elicitation)",
inputSchema={
"type": "object",
"properties": {"filename": {"type": "string"}},
},
execution=types.ToolExecution(taskSupport=types.TASK_REQUIRED),
),
types.Tool(
name="write_haiku",
description="Asks LLM to write a haiku (demonstrates sampling)",
inputSchema={"type": "object", "properties": {"topic": {"type": "string"}}},
execution=types.ToolExecution(taskSupport=types.TASK_REQUIRED),
),
]
async def handle_confirm_delete(arguments: dict[str, Any]) -> types.CreateTaskResult:
"""Handle the confirm_delete tool - demonstrates elicitation."""
ctx = server.request_context
ctx.experimental.validate_task_mode(types.TASK_REQUIRED)
filename = arguments.get("filename", "unknown.txt")
print(f"\n[Server] confirm_delete called for '{filename}'")
async def work(task: ServerTaskContext) -> types.CallToolResult:
print(f"[Server] Task {task.task_id} starting elicitation...")
result = await task.elicit(
message=f"Are you sure you want to delete '{filename}'?",
requestedSchema={
"type": "object",
"properties": {"confirm": {"type": "boolean"}},
"required": ["confirm"],
},
)
print(f"[Server] Received elicitation response: action={result.action}, content={result.content}")
if result.action == "accept" and result.content:
confirmed = result.content.get("confirm", False)
text = f"Deleted '{filename}'" if confirmed else "Deletion cancelled"
else:
text = "Deletion cancelled"
print(f"[Server] Completing task with result: {text}")
return types.CallToolResult(content=[types.TextContent(type="text", text=text)])
return await ctx.experimental.run_task(work)
async def handle_write_haiku(arguments: dict[str, Any]) -> types.CreateTaskResult:
"""Handle the write_haiku tool - demonstrates sampling."""
ctx = server.request_context
ctx.experimental.validate_task_mode(types.TASK_REQUIRED)
topic = arguments.get("topic", "nature")
print(f"\n[Server] write_haiku called for topic '{topic}'")
async def work(task: ServerTaskContext) -> types.CallToolResult:
print(f"[Server] Task {task.task_id} starting sampling...")
result = await task.create_message(
messages=[
types.SamplingMessage(
role="user",
content=types.TextContent(type="text", text=f"Write a haiku about {topic}"),
)
],
max_tokens=50,
)
haiku = "No response"
if isinstance(result.content, types.TextContent):
haiku = result.content.text
print(f"[Server] Received sampling response: {haiku[:50]}...")
return types.CallToolResult(content=[types.TextContent(type="text", text=f"Haiku:\n{haiku}")])
return await ctx.experimental.run_task(work)
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult | types.CreateTaskResult:
"""Dispatch tool calls to their handlers."""
if name == "confirm_delete":
return await handle_confirm_delete(arguments)
elif name == "write_haiku":
return await handle_write_haiku(arguments)
else:
return types.CallToolResult(
content=[types.TextContent(type="text", text=f"Unknown tool: {name}")],
isError=True,
)
def create_app(session_manager: StreamableHTTPSessionManager) -> Starlette:
@asynccontextmanager
async def app_lifespan(app: Starlette) -> AsyncIterator[None]:
async with session_manager.run():
yield
return Starlette(
routes=[Mount("/mcp", app=session_manager.handle_request)],
lifespan=app_lifespan,
)
@click.command()
@click.option("--port", default=8000, help="Port to listen on")
def main(port: int) -> int:
session_manager = StreamableHTTPSessionManager(app=server)
starlette_app = create_app(session_manager)
print(f"Starting server on http://localhost:{port}/mcp")
uvicorn.run(starlette_app, host="127.0.0.1", port=port)
return 0
@@ -0,0 +1,43 @@
[project]
name = "mcp-simple-task-interactive"
version = "0.1.0"
description = "A simple MCP server demonstrating interactive tasks (elicitation & sampling)"
readme = "README.md"
requires-python = ">=3.10"
authors = [{ name = "Anthropic, PBC." }]
keywords = ["mcp", "llm", "tasks", "elicitation", "sampling"]
license = { text = "MIT" }
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
]
dependencies = ["anyio>=4.5", "click>=8.0", "mcp", "starlette", "uvicorn"]
[project.scripts]
mcp-simple-task-interactive = "mcp_simple_task_interactive.server:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["mcp_simple_task_interactive"]
[tool.pyright]
include = ["mcp_simple_task_interactive"]
venvPath = "."
venv = ".venv"
[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = []
[tool.ruff]
line-length = 120
target-version = "py310"
[dependency-groups]
dev = ["pyright>=1.1.378", "ruff>=0.6.9"]
+37
View File
@@ -0,0 +1,37 @@
# Simple Task Server
A minimal MCP server demonstrating the experimental tasks feature over streamable HTTP.
## Running
```bash
cd examples/servers/simple-task
uv run mcp-simple-task
```
The server starts on `http://localhost:8000/mcp` by default. Use `--port` to change.
## What it does
This server exposes a single tool `long_running_task` that:
1. Must be called as a task (with `task` metadata in the request)
2. Takes ~3 seconds to complete
3. Sends status updates during execution
4. Returns a result when complete
## Usage with the client
In one terminal, start the server:
```bash
cd examples/servers/simple-task
uv run mcp-simple-task
```
In another terminal, run the client:
```bash
cd examples/clients/simple-task-client
uv run mcp-simple-task-client
```
@@ -0,0 +1,5 @@
import sys
from .server import main
sys.exit(main()) # type: ignore[call-arg]
@@ -0,0 +1,84 @@
"""Simple task server demonstrating MCP tasks over streamable HTTP."""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
import anyio
import click
import mcp.types as types
import uvicorn
from mcp.server.experimental.task_context import ServerTaskContext
from mcp.server.lowlevel import Server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from starlette.applications import Starlette
from starlette.routing import Mount
server = Server("simple-task-server")
# One-line setup: auto-registers get_task, get_task_result, list_tasks, cancel_task
server.experimental.enable_tasks()
@server.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name="long_running_task",
description="A task that takes a few seconds to complete with status updates",
inputSchema={"type": "object", "properties": {}},
execution=types.ToolExecution(taskSupport=types.TASK_REQUIRED),
)
]
async def handle_long_running_task(arguments: dict[str, Any]) -> types.CreateTaskResult:
"""Handle the long_running_task tool - demonstrates status updates."""
ctx = server.request_context
ctx.experimental.validate_task_mode(types.TASK_REQUIRED)
async def work(task: ServerTaskContext) -> types.CallToolResult:
await task.update_status("Starting work...")
await anyio.sleep(1)
await task.update_status("Processing step 1...")
await anyio.sleep(1)
await task.update_status("Processing step 2...")
await anyio.sleep(1)
return types.CallToolResult(content=[types.TextContent(type="text", text="Task completed!")])
return await ctx.experimental.run_task(work)
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult | types.CreateTaskResult:
"""Dispatch tool calls to their handlers."""
if name == "long_running_task":
return await handle_long_running_task(arguments)
else:
return types.CallToolResult(
content=[types.TextContent(type="text", text=f"Unknown tool: {name}")],
isError=True,
)
@click.command()
@click.option("--port", default=8000, help="Port to listen on")
def main(port: int) -> int:
session_manager = StreamableHTTPSessionManager(app=server)
@asynccontextmanager
async def app_lifespan(app: Starlette) -> AsyncIterator[None]:
async with session_manager.run():
yield
starlette_app = Starlette(
routes=[Mount("/mcp", app=session_manager.handle_request)],
lifespan=app_lifespan,
)
print(f"Starting server on http://localhost:{port}/mcp")
uvicorn.run(starlette_app, host="127.0.0.1", port=port)
return 0
@@ -0,0 +1,43 @@
[project]
name = "mcp-simple-task"
version = "0.1.0"
description = "A simple MCP server demonstrating tasks"
readme = "README.md"
requires-python = ">=3.10"
authors = [{ name = "Anthropic, PBC." }]
keywords = ["mcp", "llm", "tasks"]
license = { text = "MIT" }
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
]
dependencies = ["anyio>=4.5", "click>=8.0", "mcp", "starlette", "uvicorn"]
[project.scripts]
mcp-simple-task = "mcp_simple_task.server:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["mcp_simple_task"]
[tool.pyright]
include = ["mcp_simple_task"]
venvPath = "."
venv = ".venv"
[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = []
[tool.ruff]
line-length = 120
target-version = "py310"
[dependency-groups]
dev = ["pyright>=1.1.378", "ruff>=0.6.9"]
+6
View File
@@ -18,6 +18,12 @@ nav:
- Low-Level Server: low-level-server.md
- Authorization: authorization.md
- Testing: testing.md
- Experimental:
- Overview: experimental/index.md
- Tasks:
- Introduction: experimental/tasks.md
- Server Implementation: experimental/tasks-server.md
- Client Usage: experimental/tasks-client.md
- API Reference: api.md
theme:
+9
View File
@@ -0,0 +1,9 @@
"""
Experimental client features.
WARNING: These APIs are experimental and may change without notice.
"""
from mcp.client.experimental.tasks import ExperimentalClientFeatures
__all__ = ["ExperimentalClientFeatures"]
@@ -0,0 +1,290 @@
"""
Experimental task handler protocols for server -> client requests.
This module provides Protocol types and default handlers for when servers
send task-related requests to clients (the reverse of normal client -> server flow).
WARNING: These APIs are experimental and may change without notice.
Use cases:
- Server sends task-augmented sampling/elicitation request to client
- Client creates a local task, spawns background work, returns CreateTaskResult
- Server polls client's task status via tasks/get, tasks/result, etc.
"""
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol
from pydantic import TypeAdapter
import mcp.types as types
from mcp.shared.context import RequestContext
from mcp.shared.session import RequestResponder
if TYPE_CHECKING:
from mcp.client.session import ClientSession
class GetTaskHandlerFnT(Protocol):
"""Handler for tasks/get requests from server.
WARNING: This is experimental and may change without notice.
"""
async def __call__(
self,
context: RequestContext["ClientSession", Any],
params: types.GetTaskRequestParams,
) -> types.GetTaskResult | types.ErrorData: ... # pragma: no branch
class GetTaskResultHandlerFnT(Protocol):
"""Handler for tasks/result requests from server.
WARNING: This is experimental and may change without notice.
"""
async def __call__(
self,
context: RequestContext["ClientSession", Any],
params: types.GetTaskPayloadRequestParams,
) -> types.GetTaskPayloadResult | types.ErrorData: ... # pragma: no branch
class ListTasksHandlerFnT(Protocol):
"""Handler for tasks/list requests from server.
WARNING: This is experimental and may change without notice.
"""
async def __call__(
self,
context: RequestContext["ClientSession", Any],
params: types.PaginatedRequestParams | None,
) -> types.ListTasksResult | types.ErrorData: ... # pragma: no branch
class CancelTaskHandlerFnT(Protocol):
"""Handler for tasks/cancel requests from server.
WARNING: This is experimental and may change without notice.
"""
async def __call__(
self,
context: RequestContext["ClientSession", Any],
params: types.CancelTaskRequestParams,
) -> types.CancelTaskResult | types.ErrorData: ... # pragma: no branch
class TaskAugmentedSamplingFnT(Protocol):
"""Handler for task-augmented sampling/createMessage requests from server.
When server sends a CreateMessageRequest with task field, this callback
is invoked. The callback should create a task, spawn background work,
and return CreateTaskResult immediately.
WARNING: This is experimental and may change without notice.
"""
async def __call__(
self,
context: RequestContext["ClientSession", Any],
params: types.CreateMessageRequestParams,
task_metadata: types.TaskMetadata,
) -> types.CreateTaskResult | types.ErrorData: ... # pragma: no branch
class TaskAugmentedElicitationFnT(Protocol):
"""Handler for task-augmented elicitation/create requests from server.
When server sends an ElicitRequest with task field, this callback
is invoked. The callback should create a task, spawn background work,
and return CreateTaskResult immediately.
WARNING: This is experimental and may change without notice.
"""
async def __call__(
self,
context: RequestContext["ClientSession", Any],
params: types.ElicitRequestParams,
task_metadata: types.TaskMetadata,
) -> types.CreateTaskResult | types.ErrorData: ... # pragma: no branch
async def default_get_task_handler(
context: RequestContext["ClientSession", Any],
params: types.GetTaskRequestParams,
) -> types.GetTaskResult | types.ErrorData:
return types.ErrorData(
code=types.METHOD_NOT_FOUND,
message="tasks/get not supported",
)
async def default_get_task_result_handler(
context: RequestContext["ClientSession", Any],
params: types.GetTaskPayloadRequestParams,
) -> types.GetTaskPayloadResult | types.ErrorData:
return types.ErrorData(
code=types.METHOD_NOT_FOUND,
message="tasks/result not supported",
)
async def default_list_tasks_handler(
context: RequestContext["ClientSession", Any],
params: types.PaginatedRequestParams | None,
) -> types.ListTasksResult | types.ErrorData:
return types.ErrorData(
code=types.METHOD_NOT_FOUND,
message="tasks/list not supported",
)
async def default_cancel_task_handler(
context: RequestContext["ClientSession", Any],
params: types.CancelTaskRequestParams,
) -> types.CancelTaskResult | types.ErrorData:
return types.ErrorData(
code=types.METHOD_NOT_FOUND,
message="tasks/cancel not supported",
)
async def default_task_augmented_sampling(
context: RequestContext["ClientSession", Any],
params: types.CreateMessageRequestParams,
task_metadata: types.TaskMetadata,
) -> types.CreateTaskResult | types.ErrorData:
return types.ErrorData(
code=types.INVALID_REQUEST,
message="Task-augmented sampling not supported",
)
async def default_task_augmented_elicitation(
context: RequestContext["ClientSession", Any],
params: types.ElicitRequestParams,
task_metadata: types.TaskMetadata,
) -> types.CreateTaskResult | types.ErrorData:
return types.ErrorData(
code=types.INVALID_REQUEST,
message="Task-augmented elicitation not supported",
)
@dataclass
class ExperimentalTaskHandlers:
"""Container for experimental task handlers.
Groups all task-related handlers that handle server -> client requests.
This includes both pure task requests (get, list, cancel, result) and
task-augmented request handlers (sampling, elicitation with task field).
WARNING: These APIs are experimental and may change without notice.
Example:
handlers = ExperimentalTaskHandlers(
get_task=my_get_task_handler,
list_tasks=my_list_tasks_handler,
)
session = ClientSession(..., experimental_task_handlers=handlers)
"""
# Pure task request handlers
get_task: GetTaskHandlerFnT = field(default=default_get_task_handler)
get_task_result: GetTaskResultHandlerFnT = field(default=default_get_task_result_handler)
list_tasks: ListTasksHandlerFnT = field(default=default_list_tasks_handler)
cancel_task: CancelTaskHandlerFnT = field(default=default_cancel_task_handler)
# Task-augmented request handlers
augmented_sampling: TaskAugmentedSamplingFnT = field(default=default_task_augmented_sampling)
augmented_elicitation: TaskAugmentedElicitationFnT = field(default=default_task_augmented_elicitation)
def build_capability(self) -> types.ClientTasksCapability | None:
"""Build ClientTasksCapability from the configured handlers.
Returns a capability object that reflects which handlers are configured
(i.e., not using the default "not supported" handlers).
Returns:
ClientTasksCapability if any handlers are provided, None otherwise
"""
has_list = self.list_tasks is not default_list_tasks_handler
has_cancel = self.cancel_task is not default_cancel_task_handler
has_sampling = self.augmented_sampling is not default_task_augmented_sampling
has_elicitation = self.augmented_elicitation is not default_task_augmented_elicitation
# If no handlers are provided, return None
if not any([has_list, has_cancel, has_sampling, has_elicitation]):
return None
# Build requests capability if any request handlers are provided
requests_capability: types.ClientTasksRequestsCapability | None = None
if has_sampling or has_elicitation:
requests_capability = types.ClientTasksRequestsCapability(
sampling=types.TasksSamplingCapability(createMessage=types.TasksCreateMessageCapability())
if has_sampling
else None,
elicitation=types.TasksElicitationCapability(create=types.TasksCreateElicitationCapability())
if has_elicitation
else None,
)
return types.ClientTasksCapability(
list=types.TasksListCapability() if has_list else None,
cancel=types.TasksCancelCapability() if has_cancel else None,
requests=requests_capability,
)
@staticmethod
def handles_request(request: types.ServerRequest) -> bool:
"""Check if this handler handles the given request type."""
return isinstance(
request.root,
types.GetTaskRequest | types.GetTaskPayloadRequest | types.ListTasksRequest | types.CancelTaskRequest,
)
async def handle_request(
self,
ctx: RequestContext["ClientSession", Any],
responder: RequestResponder[types.ServerRequest, types.ClientResult],
) -> None:
"""Handle a task-related request from the server.
Call handles_request() first to check if this handler can handle the request.
"""
client_response_type: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter(
types.ClientResult | types.ErrorData
)
match responder.request.root:
case types.GetTaskRequest(params=params):
response = await self.get_task(ctx, params)
client_response = client_response_type.validate_python(response)
await responder.respond(client_response)
case types.GetTaskPayloadRequest(params=params):
response = await self.get_task_result(ctx, params)
client_response = client_response_type.validate_python(response)
await responder.respond(client_response)
case types.ListTasksRequest(params=params):
response = await self.list_tasks(ctx, params)
client_response = client_response_type.validate_python(response)
await responder.respond(client_response)
case types.CancelTaskRequest(params=params):
response = await self.cancel_task(ctx, params)
client_response = client_response_type.validate_python(response)
await responder.respond(client_response)
case _: # pragma: no cover
raise ValueError(f"Unhandled request type: {type(responder.request.root)}")
# Backwards compatibility aliases
default_task_augmented_sampling_callback = default_task_augmented_sampling
default_task_augmented_elicitation_callback = default_task_augmented_elicitation
+224
View File
@@ -0,0 +1,224 @@
"""
Experimental client-side task support.
This module provides client methods for interacting with MCP tasks.
WARNING: These APIs are experimental and may change without notice.
Example:
# Call a tool as a task
result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"})
task_id = result.task.taskId
# Get task status
status = await session.experimental.get_task(task_id)
# Get task result when complete
if status.status == "completed":
result = await session.experimental.get_task_result(task_id, CallToolResult)
# List all tasks
tasks = await session.experimental.list_tasks()
# Cancel a task
await session.experimental.cancel_task(task_id)
"""
from collections.abc import AsyncIterator
from typing import TYPE_CHECKING, Any, TypeVar
import mcp.types as types
from mcp.shared.experimental.tasks.polling import poll_until_terminal
if TYPE_CHECKING:
from mcp.client.session import ClientSession
ResultT = TypeVar("ResultT", bound=types.Result)
class ExperimentalClientFeatures:
"""
Experimental client features for tasks and other experimental APIs.
WARNING: These APIs are experimental and may change without notice.
Access via session.experimental:
status = await session.experimental.get_task(task_id)
"""
def __init__(self, session: "ClientSession") -> None:
self._session = session
async def call_tool_as_task(
self,
name: str,
arguments: dict[str, Any] | None = None,
*,
ttl: int = 60000,
meta: dict[str, Any] | None = None,
) -> types.CreateTaskResult:
"""Call a tool as a task, returning a CreateTaskResult for polling.
This is a convenience method for calling tools that support task execution.
The server will return a task reference instead of the immediate result,
which can then be polled via `get_task()` and retrieved via `get_task_result()`.
Args:
name: The tool name
arguments: Tool arguments
ttl: Task time-to-live in milliseconds (default: 60000 = 1 minute)
meta: Optional metadata to include in the request
Returns:
CreateTaskResult containing the task reference
Example:
# Create task
result = await session.experimental.call_tool_as_task(
"long_running_tool", {"input": "data"}
)
task_id = result.task.taskId
# Poll for completion
while True:
status = await session.experimental.get_task(task_id)
if status.status == "completed":
break
await asyncio.sleep(0.5)
# Get result
final = await session.experimental.get_task_result(task_id, CallToolResult)
"""
_meta: types.RequestParams.Meta | None = None
if meta is not None:
_meta = types.RequestParams.Meta(**meta)
return await self._session.send_request(
types.ClientRequest(
types.CallToolRequest(
params=types.CallToolRequestParams(
name=name,
arguments=arguments,
task=types.TaskMetadata(ttl=ttl),
_meta=_meta,
),
)
),
types.CreateTaskResult,
)
async def get_task(self, task_id: str) -> types.GetTaskResult:
"""
Get the current status of a task.
Args:
task_id: The task identifier
Returns:
GetTaskResult containing the task status and metadata
"""
return await self._session.send_request(
types.ClientRequest(
types.GetTaskRequest(
params=types.GetTaskRequestParams(taskId=task_id),
)
),
types.GetTaskResult,
)
async def get_task_result(
self,
task_id: str,
result_type: type[ResultT],
) -> ResultT:
"""
Get the result of a completed task.
The result type depends on the original request type:
- tools/call tasks return CallToolResult
- Other request types return their corresponding result type
Args:
task_id: The task identifier
result_type: The expected result type (e.g., CallToolResult)
Returns:
The task result, validated against result_type
"""
return await self._session.send_request(
types.ClientRequest(
types.GetTaskPayloadRequest(
params=types.GetTaskPayloadRequestParams(taskId=task_id),
)
),
result_type,
)
async def list_tasks(
self,
cursor: str | None = None,
) -> types.ListTasksResult:
"""
List all tasks.
Args:
cursor: Optional pagination cursor
Returns:
ListTasksResult containing tasks and optional next cursor
"""
params = types.PaginatedRequestParams(cursor=cursor) if cursor else None
return await self._session.send_request(
types.ClientRequest(
types.ListTasksRequest(params=params),
),
types.ListTasksResult,
)
async def cancel_task(self, task_id: str) -> types.CancelTaskResult:
"""
Cancel a running task.
Args:
task_id: The task identifier
Returns:
CancelTaskResult with the updated task state
"""
return await self._session.send_request(
types.ClientRequest(
types.CancelTaskRequest(
params=types.CancelTaskRequestParams(taskId=task_id),
)
),
types.CancelTaskResult,
)
async def poll_task(self, task_id: str) -> AsyncIterator[types.GetTaskResult]:
"""
Poll a task until it reaches a terminal status.
Yields GetTaskResult for each poll, allowing the caller to react to
status changes (e.g., handle input_required). Exits when task reaches
a terminal status (completed, failed, cancelled).
Respects the pollInterval hint from the server.
Args:
task_id: The task identifier
Yields:
GetTaskResult for each poll
Example:
async for status in session.experimental.poll_task(task_id):
print(f"Status: {status.status}")
if status.status == "input_required":
# Handle elicitation request via tasks/result
pass
# Task is now terminal, get the result
result = await session.experimental.get_task_result(task_id, CallToolResult)
"""
async for status in poll_until_terminal(self.get_task, task_id):
yield status
+45 -2
View File
@@ -8,6 +8,8 @@ from pydantic import AnyUrl, TypeAdapter
from typing_extensions import deprecated
import mcp.types as types
from mcp.client.experimental import ExperimentalClientFeatures
from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers
from mcp.shared.context import RequestContext
from mcp.shared.message import SessionMessage
from mcp.shared.session import BaseSession, ProgressFnT, RequestResponder
@@ -118,6 +120,8 @@ class ClientSession(
logging_callback: LoggingFnT | None = None,
message_handler: MessageHandlerFnT | None = None,
client_info: types.Implementation | None = None,
*,
experimental_task_handlers: ExperimentalTaskHandlers | None = None,
) -> None:
super().__init__(
read_stream,
@@ -134,6 +138,10 @@ class ClientSession(
self._message_handler = message_handler or _default_message_handler
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
self._server_capabilities: types.ServerCapabilities | None = None
self._experimental_features: ExperimentalClientFeatures | None = None
# Experimental: Task handlers (use defaults if not provided)
self._task_handlers = experimental_task_handlers or ExperimentalTaskHandlers()
async def initialize(self) -> types.InitializeResult:
sampling = types.SamplingCapability() if self._sampling_callback is not _default_sampling_callback else None
@@ -164,6 +172,7 @@ class ClientSession(
elicitation=elicitation,
experimental=None,
roots=roots,
tasks=self._task_handlers.build_capability(),
),
clientInfo=self._client_info,
),
@@ -188,6 +197,20 @@ class ClientSession(
"""
return self._server_capabilities
@property
def experimental(self) -> ExperimentalClientFeatures:
"""Experimental APIs for tasks and other features.
WARNING: These APIs are experimental and may change without notice.
Example:
status = await session.experimental.get_task(task_id)
result = await session.experimental.get_task_result(task_id, CallToolResult)
"""
if self._experimental_features is None:
self._experimental_features = ExperimentalClientFeatures(self)
return self._experimental_features
async def send_ping(self) -> types.EmptyResult:
"""Send a ping request."""
return await self.send_request(
@@ -521,16 +544,31 @@ class ClientSession(
lifespan_context=None,
)
# Delegate to experimental task handler if applicable
if self._task_handlers.handles_request(responder.request):
with responder:
await self._task_handlers.handle_request(ctx, responder)
return None
# Core request handling
match responder.request.root:
case types.CreateMessageRequest(params=params):
with responder:
response = await self._sampling_callback(ctx, params)
# Check if this is a task-augmented request
if params.task is not None:
response = await self._task_handlers.augmented_sampling(ctx, params, params.task)
else:
response = await self._sampling_callback(ctx, params)
client_response = ClientResponse.validate_python(response)
await responder.respond(client_response)
case types.ElicitRequest(params=params):
with responder:
response = await self._elicitation_callback(ctx, params)
# Check if this is a task-augmented request
if params.task is not None:
response = await self._task_handlers.augmented_elicitation(ctx, params, params.task)
else:
response = await self._elicitation_callback(ctx, params)
client_response = ClientResponse.validate_python(response)
await responder.respond(client_response)
@@ -544,6 +582,11 @@ class ClientSession(
with responder:
return await responder.respond(types.ClientResult(root=types.EmptyResult()))
case _: # pragma: no cover
pass # Task requests handled above by _task_handlers
return None
async def _handle_incoming(
self,
req: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
+11
View File
@@ -0,0 +1,11 @@
"""
Server-side experimental features.
WARNING: These APIs are experimental and may change without notice.
Import directly from submodules:
- mcp.server.experimental.task_context.ServerTaskContext
- mcp.server.experimental.task_support.TaskSupport
- mcp.server.experimental.task_result_handler.TaskResultHandler
- mcp.server.experimental.request_context.Experimental
"""
@@ -0,0 +1,238 @@
"""
Experimental request context features.
This module provides the Experimental class which gives access to experimental
features within a request context, such as task-augmented request handling.
WARNING: These APIs are experimental and may change without notice.
"""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from mcp.server.experimental.task_context import ServerTaskContext
from mcp.server.experimental.task_support import TaskSupport
from mcp.server.session import ServerSession
from mcp.shared.exceptions import McpError
from mcp.shared.experimental.tasks.helpers import MODEL_IMMEDIATE_RESPONSE_KEY, is_terminal
from mcp.types import (
METHOD_NOT_FOUND,
TASK_FORBIDDEN,
TASK_REQUIRED,
ClientCapabilities,
CreateTaskResult,
ErrorData,
Result,
TaskExecutionMode,
TaskMetadata,
Tool,
)
@dataclass
class Experimental:
"""
Experimental features context for task-augmented requests.
Provides helpers for validating task execution compatibility and
running tasks with automatic lifecycle management.
WARNING: This API is experimental and may change without notice.
"""
task_metadata: TaskMetadata | None = None
_client_capabilities: ClientCapabilities | None = field(default=None, repr=False)
_session: ServerSession | None = field(default=None, repr=False)
_task_support: TaskSupport | None = field(default=None, repr=False)
@property
def is_task(self) -> bool:
"""Check if this request is task-augmented."""
return self.task_metadata is not None
@property
def client_supports_tasks(self) -> bool:
"""Check if the client declared task support."""
if self._client_capabilities is None:
return False
return self._client_capabilities.tasks is not None
def validate_task_mode(
self,
tool_task_mode: TaskExecutionMode | None,
*,
raise_error: bool = True,
) -> ErrorData | None:
"""
Validate that the request is compatible with the tool's task execution mode.
Per MCP spec:
- "required": Clients MUST invoke as task. Server returns -32601 if not.
- "forbidden" (or None): Clients MUST NOT invoke as task. Server returns -32601 if they do.
- "optional": Either is acceptable.
Args:
tool_task_mode: The tool's execution.taskSupport value
("forbidden", "optional", "required", or None)
raise_error: If True, raises McpError on validation failure. If False, returns ErrorData.
Returns:
None if valid, ErrorData if invalid and raise_error=False
Raises:
McpError: If invalid and raise_error=True
"""
mode = tool_task_mode or TASK_FORBIDDEN
error: ErrorData | None = None
if mode == TASK_REQUIRED and not self.is_task:
error = ErrorData(
code=METHOD_NOT_FOUND,
message="This tool requires task-augmented invocation",
)
elif mode == TASK_FORBIDDEN and self.is_task:
error = ErrorData(
code=METHOD_NOT_FOUND,
message="This tool does not support task-augmented invocation",
)
if error is not None and raise_error:
raise McpError(error)
return error
def validate_for_tool(
self,
tool: Tool,
*,
raise_error: bool = True,
) -> ErrorData | None:
"""
Validate that the request is compatible with the given tool.
Convenience wrapper around validate_task_mode that extracts the mode from a Tool.
Args:
tool: The Tool definition
raise_error: If True, raises McpError on validation failure.
Returns:
None if valid, ErrorData if invalid and raise_error=False
"""
mode = tool.execution.taskSupport if tool.execution else None
return self.validate_task_mode(mode, raise_error=raise_error)
def can_use_tool(self, tool_task_mode: TaskExecutionMode | None) -> bool:
"""
Check if this client can use a tool with the given task mode.
Useful for filtering tool lists or providing warnings.
Returns False if tool requires "required" but client doesn't support tasks.
Args:
tool_task_mode: The tool's execution.taskSupport value
Returns:
True if the client can use this tool, False otherwise
"""
mode = tool_task_mode or TASK_FORBIDDEN
if mode == TASK_REQUIRED and not self.client_supports_tasks:
return False
return True
async def run_task(
self,
work: Callable[[ServerTaskContext], Awaitable[Result]],
*,
task_id: str | None = None,
model_immediate_response: str | None = None,
) -> CreateTaskResult:
"""
Create a task, spawn background work, and return CreateTaskResult immediately.
This is the recommended way to handle task-augmented tool calls. It:
1. Creates a task in the store
2. Spawns the work function in a background task
3. Returns CreateTaskResult immediately
The work function receives a ServerTaskContext with:
- elicit() for sending elicitation requests
- create_message() for sampling requests
- update_status() for progress updates
- complete()/fail() for finishing the task
When work() returns a Result, the task is auto-completed with that result.
If work() raises an exception, the task is auto-failed.
Args:
work: Async function that does the actual work
task_id: Optional task ID (generated if not provided)
model_immediate_response: Optional string to include in _meta as
io.modelcontextprotocol/model-immediate-response
Returns:
CreateTaskResult to return to the client
Raises:
RuntimeError: If task support is not enabled or task_metadata is missing
Example:
@server.call_tool()
async def handle_tool(name: str, args: dict):
ctx = server.request_context
async def work(task: ServerTaskContext) -> CallToolResult:
result = await task.elicit(
message="Are you sure?",
requestedSchema={"type": "object", ...}
)
confirmed = result.content.get("confirm", False)
return CallToolResult(content=[TextContent(text="Done" if confirmed else "Cancelled")])
return await ctx.experimental.run_task(work)
WARNING: This API is experimental and may change without notice.
"""
if self._task_support is None:
raise RuntimeError("Task support not enabled. Call server.experimental.enable_tasks() first.")
if self._session is None:
raise RuntimeError("Session not available.")
if self.task_metadata is None:
raise RuntimeError(
"Request is not task-augmented (no task field in params). "
"The client must send a task-augmented request."
)
support = self._task_support
# Access task_group via TaskSupport - raises if not in run() context
task_group = support.task_group
task = await support.store.create_task(self.task_metadata, task_id)
task_ctx = ServerTaskContext(
task=task,
store=support.store,
session=self._session,
queue=support.queue,
handler=support.handler,
)
async def execute() -> None:
try:
result = await work(task_ctx)
if not is_terminal(task_ctx.task.status):
await task_ctx.complete(result)
except Exception as e:
if not is_terminal(task_ctx.task.status):
await task_ctx.fail(str(e))
task_group.start_soon(execute)
meta: dict[str, Any] | None = None
if model_immediate_response is not None:
meta = {MODEL_IMMEDIATE_RESPONSE_KEY: model_immediate_response}
return CreateTaskResult(task=task, **{"_meta": meta} if meta else {})
@@ -0,0 +1,220 @@
"""
Experimental server session features for server→client task operations.
This module provides the server-side equivalent of ExperimentalClientFeatures,
allowing the server to send task-augmented requests to the client and poll for results.
WARNING: These APIs are experimental and may change without notice.
"""
from collections.abc import AsyncIterator
from typing import TYPE_CHECKING, Any, TypeVar
import mcp.types as types
from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages
from mcp.shared.experimental.tasks.capabilities import (
require_task_augmented_elicitation,
require_task_augmented_sampling,
)
from mcp.shared.experimental.tasks.polling import poll_until_terminal
if TYPE_CHECKING:
from mcp.server.session import ServerSession
ResultT = TypeVar("ResultT", bound=types.Result)
class ExperimentalServerSessionFeatures:
"""
Experimental server session features for server→client task operations.
This provides the server-side equivalent of ExperimentalClientFeatures,
allowing the server to send task-augmented requests to the client and
poll for results.
WARNING: These APIs are experimental and may change without notice.
Access via session.experimental:
result = await session.experimental.elicit_as_task(...)
"""
def __init__(self, session: "ServerSession") -> None:
self._session = session
async def get_task(self, task_id: str) -> types.GetTaskResult:
"""
Send tasks/get to the client to get task status.
Args:
task_id: The task identifier
Returns:
GetTaskResult containing the task status
"""
return await self._session.send_request(
types.ServerRequest(types.GetTaskRequest(params=types.GetTaskRequestParams(taskId=task_id))),
types.GetTaskResult,
)
async def get_task_result(
self,
task_id: str,
result_type: type[ResultT],
) -> ResultT:
"""
Send tasks/result to the client to retrieve the final result.
Args:
task_id: The task identifier
result_type: The expected result type
Returns:
The task result, validated against result_type
"""
return await self._session.send_request(
types.ServerRequest(types.GetTaskPayloadRequest(params=types.GetTaskPayloadRequestParams(taskId=task_id))),
result_type,
)
async def poll_task(self, task_id: str) -> AsyncIterator[types.GetTaskResult]:
"""
Poll a client task until it reaches terminal status.
Yields GetTaskResult for each poll, allowing the caller to react to
status changes. Exits when task reaches a terminal status.
Respects the pollInterval hint from the client.
Args:
task_id: The task identifier
Yields:
GetTaskResult for each poll
"""
async for status in poll_until_terminal(self.get_task, task_id):
yield status
async def elicit_as_task(
self,
message: str,
requestedSchema: types.ElicitRequestedSchema,
*,
ttl: int = 60000,
) -> types.ElicitResult:
"""
Send a task-augmented elicitation to the client and poll until complete.
The client will create a local task, process the elicitation asynchronously,
and return the result when ready. This method handles the full flow:
1. Send elicitation with task field
2. Receive CreateTaskResult from client
3. Poll client's task until terminal
4. Retrieve and return the final ElicitResult
Args:
message: The message to present to the user
requestedSchema: Schema defining the expected response
ttl: Task time-to-live in milliseconds
Returns:
The client's elicitation response
Raises:
McpError: If client doesn't support task-augmented elicitation
"""
client_caps = self._session.client_params.capabilities if self._session.client_params else None
require_task_augmented_elicitation(client_caps)
create_result = await self._session.send_request(
types.ServerRequest(
types.ElicitRequest(
params=types.ElicitRequestFormParams(
message=message,
requestedSchema=requestedSchema,
task=types.TaskMetadata(ttl=ttl),
)
)
),
types.CreateTaskResult,
)
task_id = create_result.task.taskId
async for _ in self.poll_task(task_id):
pass
return await self.get_task_result(task_id, types.ElicitResult)
async def create_message_as_task(
self,
messages: list[types.SamplingMessage],
*,
max_tokens: int,
ttl: int = 60000,
system_prompt: str | None = None,
include_context: types.IncludeContext | None = None,
temperature: float | None = None,
stop_sequences: list[str] | None = None,
metadata: dict[str, Any] | None = None,
model_preferences: types.ModelPreferences | None = None,
tools: list[types.Tool] | None = None,
tool_choice: types.ToolChoice | None = None,
) -> types.CreateMessageResult:
"""
Send a task-augmented sampling request and poll until complete.
The client will create a local task, process the sampling request
asynchronously, and return the result when ready.
Args:
messages: The conversation messages for sampling
max_tokens: Maximum tokens in the response
ttl: Task time-to-live in milliseconds
system_prompt: Optional system prompt
include_context: Context inclusion strategy
temperature: Sampling temperature
stop_sequences: Stop sequences
metadata: Additional metadata
model_preferences: Model selection preferences
tools: Optional list of tools the LLM can use during sampling
tool_choice: Optional control over tool usage behavior
Returns:
The sampling result from the client
Raises:
McpError: If client doesn't support task-augmented sampling or tools
ValueError: If tool_use or tool_result message structure is invalid
"""
client_caps = self._session.client_params.capabilities if self._session.client_params else None
require_task_augmented_sampling(client_caps)
validate_sampling_tools(client_caps, tools, tool_choice)
validate_tool_use_result_messages(messages)
create_result = await self._session.send_request(
types.ServerRequest(
types.CreateMessageRequest(
params=types.CreateMessageRequestParams(
messages=messages,
maxTokens=max_tokens,
systemPrompt=system_prompt,
includeContext=include_context,
temperature=temperature,
stopSequences=stop_sequences,
metadata=metadata,
modelPreferences=model_preferences,
tools=tools,
toolChoice=tool_choice,
task=types.TaskMetadata(ttl=ttl),
)
)
),
types.CreateTaskResult,
)
task_id = create_result.task.taskId
async for _ in self.poll_task(task_id):
pass
return await self.get_task_result(task_id, types.CreateMessageResult)
+612
View File
@@ -0,0 +1,612 @@
"""
ServerTaskContext - Server-integrated task context with elicitation and sampling.
This wraps the pure TaskContext and adds server-specific functionality:
- Elicitation (task.elicit())
- Sampling (task.create_message())
- Status notifications
"""
from typing import Any
import anyio
from mcp.server.experimental.task_result_handler import TaskResultHandler
from mcp.server.session import ServerSession
from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages
from mcp.shared.exceptions import McpError
from mcp.shared.experimental.tasks.capabilities import (
require_task_augmented_elicitation,
require_task_augmented_sampling,
)
from mcp.shared.experimental.tasks.context import TaskContext
from mcp.shared.experimental.tasks.message_queue import QueuedMessage, TaskMessageQueue
from mcp.shared.experimental.tasks.resolver import Resolver
from mcp.shared.experimental.tasks.store import TaskStore
from mcp.types import (
INVALID_REQUEST,
TASK_STATUS_INPUT_REQUIRED,
TASK_STATUS_WORKING,
ClientCapabilities,
CreateMessageResult,
CreateTaskResult,
ElicitationCapability,
ElicitRequestedSchema,
ElicitResult,
ErrorData,
IncludeContext,
ModelPreferences,
RequestId,
Result,
SamplingCapability,
SamplingMessage,
ServerNotification,
Task,
TaskMetadata,
TaskStatusNotification,
TaskStatusNotificationParams,
Tool,
ToolChoice,
)
class ServerTaskContext:
"""
Server-integrated task context with elicitation and sampling.
This wraps a pure TaskContext and adds server-specific functionality:
- elicit() for sending elicitation requests to the client
- create_message() for sampling requests
- Status notifications via the session
Example:
async def my_task_work(task: ServerTaskContext) -> CallToolResult:
await task.update_status("Starting...")
result = await task.elicit(
message="Continue?",
requestedSchema={"type": "object", "properties": {"ok": {"type": "boolean"}}}
)
if result.content.get("ok"):
return CallToolResult(content=[TextContent(text="Done!")])
else:
return CallToolResult(content=[TextContent(text="Cancelled")])
"""
def __init__(
self,
*,
task: Task,
store: TaskStore,
session: ServerSession,
queue: TaskMessageQueue,
handler: TaskResultHandler | None = None,
):
"""
Create a ServerTaskContext.
Args:
task: The Task object
store: The task store
session: The server session
queue: The message queue for elicitation/sampling
handler: The result handler for response routing (required for elicit/create_message)
"""
self._ctx = TaskContext(task=task, store=store)
self._session = session
self._queue = queue
self._handler = handler
self._store = store
# Delegate pure properties to inner context
@property
def task_id(self) -> str:
"""The task identifier."""
return self._ctx.task_id
@property
def task(self) -> Task:
"""The current task state."""
return self._ctx.task
@property
def is_cancelled(self) -> bool:
"""Whether cancellation has been requested."""
return self._ctx.is_cancelled
def request_cancellation(self) -> None:
"""Request cancellation of this task."""
self._ctx.request_cancellation()
# Enhanced methods with notifications
async def update_status(self, message: str, *, notify: bool = True) -> None:
"""
Update the task's status message.
Args:
message: The new status message
notify: Whether to send a notification to the client
"""
await self._ctx.update_status(message)
if notify:
await self._send_notification()
async def complete(self, result: Result, *, notify: bool = True) -> None:
"""
Mark the task as completed with the given result.
Args:
result: The task result
notify: Whether to send a notification to the client
"""
await self._ctx.complete(result)
if notify:
await self._send_notification()
async def fail(self, error: str, *, notify: bool = True) -> None:
"""
Mark the task as failed with an error message.
Args:
error: The error message
notify: Whether to send a notification to the client
"""
await self._ctx.fail(error)
if notify:
await self._send_notification()
async def _send_notification(self) -> None:
"""Send a task status notification to the client."""
task = self._ctx.task
await self._session.send_notification(
ServerNotification(
TaskStatusNotification(
params=TaskStatusNotificationParams(
taskId=task.taskId,
status=task.status,
statusMessage=task.statusMessage,
createdAt=task.createdAt,
lastUpdatedAt=task.lastUpdatedAt,
ttl=task.ttl,
pollInterval=task.pollInterval,
)
)
)
)
# Server-specific methods: elicitation and sampling
def _check_elicitation_capability(self) -> None:
"""Check if the client supports elicitation."""
if not self._session.check_client_capability(ClientCapabilities(elicitation=ElicitationCapability())):
raise McpError(
ErrorData(
code=INVALID_REQUEST,
message="Client does not support elicitation capability",
)
)
def _check_sampling_capability(self) -> None:
"""Check if the client supports sampling."""
if not self._session.check_client_capability(ClientCapabilities(sampling=SamplingCapability())):
raise McpError(
ErrorData(
code=INVALID_REQUEST,
message="Client does not support sampling capability",
)
)
async def elicit(
self,
message: str,
requestedSchema: ElicitRequestedSchema,
) -> ElicitResult:
"""
Send an elicitation request via the task message queue.
This method:
1. Checks client capability
2. Updates task status to "input_required"
3. Queues the elicitation request
4. Waits for the response (delivered via tasks/result round-trip)
5. Updates task status back to "working"
6. Returns the result
Args:
message: The message to present to the user
requestedSchema: Schema defining the expected response structure
Returns:
The client's response
Raises:
McpError: If client doesn't support elicitation capability
"""
self._check_elicitation_capability()
if self._handler is None:
raise RuntimeError("handler is required for elicit(). Pass handler= to ServerTaskContext.")
# Update status to input_required
await self._store.update_task(self.task_id, status=TASK_STATUS_INPUT_REQUIRED)
# Build the request using session's helper
request = self._session._build_elicit_form_request( # pyright: ignore[reportPrivateUsage]
message=message,
requestedSchema=requestedSchema,
related_task_id=self.task_id,
)
request_id: RequestId = request.id
resolver: Resolver[dict[str, Any]] = Resolver()
self._handler._pending_requests[request_id] = resolver # pyright: ignore[reportPrivateUsage]
queued = QueuedMessage(
type="request",
message=request,
resolver=resolver,
original_request_id=request_id,
)
await self._queue.enqueue(self.task_id, queued)
try:
# Wait for response (routed back via TaskResultHandler)
response_data = await resolver.wait()
await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING)
return ElicitResult.model_validate(response_data)
except anyio.get_cancelled_exc_class(): # pragma: no cover
# Coverage can't track async exception handlers reliably.
# This path is tested in test_elicit_restores_status_on_cancellation
# which verifies status is restored to "working" after cancellation.
await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING)
raise
async def elicit_url(
self,
message: str,
url: str,
elicitation_id: str,
) -> ElicitResult:
"""
Send a URL mode elicitation request via the task message queue.
This directs the user to an external URL for out-of-band interactions
like OAuth flows, credential collection, or payment processing.
This method:
1. Checks client capability
2. Updates task status to "input_required"
3. Queues the elicitation request
4. Waits for the response (delivered via tasks/result round-trip)
5. Updates task status back to "working"
6. Returns the result
Args:
message: Human-readable explanation of why the interaction is needed
url: The URL the user should navigate to
elicitation_id: Unique identifier for tracking this elicitation
Returns:
The client's response indicating acceptance, decline, or cancellation
Raises:
McpError: If client doesn't support elicitation capability
RuntimeError: If handler is not configured
"""
self._check_elicitation_capability()
if self._handler is None:
raise RuntimeError("handler is required for elicit_url(). Pass handler= to ServerTaskContext.")
# Update status to input_required
await self._store.update_task(self.task_id, status=TASK_STATUS_INPUT_REQUIRED)
# Build the request using session's helper
request = self._session._build_elicit_url_request( # pyright: ignore[reportPrivateUsage]
message=message,
url=url,
elicitation_id=elicitation_id,
related_task_id=self.task_id,
)
request_id: RequestId = request.id
resolver: Resolver[dict[str, Any]] = Resolver()
self._handler._pending_requests[request_id] = resolver # pyright: ignore[reportPrivateUsage]
queued = QueuedMessage(
type="request",
message=request,
resolver=resolver,
original_request_id=request_id,
)
await self._queue.enqueue(self.task_id, queued)
try:
# Wait for response (routed back via TaskResultHandler)
response_data = await resolver.wait()
await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING)
return ElicitResult.model_validate(response_data)
except anyio.get_cancelled_exc_class(): # pragma: no cover
await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING)
raise
async def create_message(
self,
messages: list[SamplingMessage],
*,
max_tokens: int,
system_prompt: str | None = None,
include_context: IncludeContext | None = None,
temperature: float | None = None,
stop_sequences: list[str] | None = None,
metadata: dict[str, Any] | None = None,
model_preferences: ModelPreferences | None = None,
tools: list[Tool] | None = None,
tool_choice: ToolChoice | None = None,
) -> CreateMessageResult:
"""
Send a sampling request via the task message queue.
This method:
1. Checks client capability
2. Updates task status to "input_required"
3. Queues the sampling request
4. Waits for the response (delivered via tasks/result round-trip)
5. Updates task status back to "working"
6. Returns the result
Args:
messages: The conversation messages for sampling
max_tokens: Maximum tokens in the response
system_prompt: Optional system prompt
include_context: Context inclusion strategy
temperature: Sampling temperature
stop_sequences: Stop sequences
metadata: Additional metadata
model_preferences: Model selection preferences
tools: Optional list of tools the LLM can use during sampling
tool_choice: Optional control over tool usage behavior
Returns:
The sampling result from the client
Raises:
McpError: If client doesn't support sampling capability or tools
ValueError: If tool_use or tool_result message structure is invalid
"""
self._check_sampling_capability()
client_caps = self._session.client_params.capabilities if self._session.client_params else None
validate_sampling_tools(client_caps, tools, tool_choice)
validate_tool_use_result_messages(messages)
if self._handler is None:
raise RuntimeError("handler is required for create_message(). Pass handler= to ServerTaskContext.")
# Update status to input_required
await self._store.update_task(self.task_id, status=TASK_STATUS_INPUT_REQUIRED)
# Build the request using session's helper
request = self._session._build_create_message_request( # pyright: ignore[reportPrivateUsage]
messages=messages,
max_tokens=max_tokens,
system_prompt=system_prompt,
include_context=include_context,
temperature=temperature,
stop_sequences=stop_sequences,
metadata=metadata,
model_preferences=model_preferences,
tools=tools,
tool_choice=tool_choice,
related_task_id=self.task_id,
)
request_id: RequestId = request.id
resolver: Resolver[dict[str, Any]] = Resolver()
self._handler._pending_requests[request_id] = resolver # pyright: ignore[reportPrivateUsage]
queued = QueuedMessage(
type="request",
message=request,
resolver=resolver,
original_request_id=request_id,
)
await self._queue.enqueue(self.task_id, queued)
try:
# Wait for response (routed back via TaskResultHandler)
response_data = await resolver.wait()
await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING)
return CreateMessageResult.model_validate(response_data)
except anyio.get_cancelled_exc_class(): # pragma: no cover
# Coverage can't track async exception handlers reliably.
# This path is tested in test_create_message_restores_status_on_cancellation
# which verifies status is restored to "working" after cancellation.
await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING)
raise
async def elicit_as_task(
self,
message: str,
requestedSchema: ElicitRequestedSchema,
*,
ttl: int = 60000,
) -> ElicitResult:
"""
Send a task-augmented elicitation via the queue, then poll client.
This is for use inside a task-augmented tool call when you want the client
to handle the elicitation as its own task. The elicitation request is queued
and delivered when the client calls tasks/result. After the client responds
with CreateTaskResult, we poll the client's task until complete.
Args:
message: The message to present to the user
requestedSchema: Schema defining the expected response structure
ttl: Task time-to-live in milliseconds for the client's task
Returns:
The client's elicitation response
Raises:
McpError: If client doesn't support task-augmented elicitation
RuntimeError: If handler is not configured
"""
client_caps = self._session.client_params.capabilities if self._session.client_params else None
require_task_augmented_elicitation(client_caps)
if self._handler is None:
raise RuntimeError("handler is required for elicit_as_task()")
# Update status to input_required
await self._store.update_task(self.task_id, status=TASK_STATUS_INPUT_REQUIRED)
request = self._session._build_elicit_form_request( # pyright: ignore[reportPrivateUsage]
message=message,
requestedSchema=requestedSchema,
related_task_id=self.task_id,
task=TaskMetadata(ttl=ttl),
)
request_id: RequestId = request.id
resolver: Resolver[dict[str, Any]] = Resolver()
self._handler._pending_requests[request_id] = resolver # pyright: ignore[reportPrivateUsage]
queued = QueuedMessage(
type="request",
message=request,
resolver=resolver,
original_request_id=request_id,
)
await self._queue.enqueue(self.task_id, queued)
try:
# Wait for initial response (CreateTaskResult from client)
response_data = await resolver.wait()
create_result = CreateTaskResult.model_validate(response_data)
client_task_id = create_result.task.taskId
# Poll the client's task using session.experimental
async for _ in self._session.experimental.poll_task(client_task_id):
pass
# Get final result from client
result = await self._session.experimental.get_task_result(
client_task_id,
ElicitResult,
)
await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING)
return result
except anyio.get_cancelled_exc_class(): # pragma: no cover
await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING)
raise
async def create_message_as_task(
self,
messages: list[SamplingMessage],
*,
max_tokens: int,
ttl: int = 60000,
system_prompt: str | None = None,
include_context: IncludeContext | None = None,
temperature: float | None = None,
stop_sequences: list[str] | None = None,
metadata: dict[str, Any] | None = None,
model_preferences: ModelPreferences | None = None,
tools: list[Tool] | None = None,
tool_choice: ToolChoice | None = None,
) -> CreateMessageResult:
"""
Send a task-augmented sampling request via the queue, then poll client.
This is for use inside a task-augmented tool call when you want the client
to handle the sampling as its own task. The request is queued and delivered
when the client calls tasks/result. After the client responds with
CreateTaskResult, we poll the client's task until complete.
Args:
messages: The conversation messages for sampling
max_tokens: Maximum tokens in the response
ttl: Task time-to-live in milliseconds for the client's task
system_prompt: Optional system prompt
include_context: Context inclusion strategy
temperature: Sampling temperature
stop_sequences: Stop sequences
metadata: Additional metadata
model_preferences: Model selection preferences
tools: Optional list of tools the LLM can use during sampling
tool_choice: Optional control over tool usage behavior
Returns:
The sampling result from the client
Raises:
McpError: If client doesn't support task-augmented sampling or tools
ValueError: If tool_use or tool_result message structure is invalid
RuntimeError: If handler is not configured
"""
client_caps = self._session.client_params.capabilities if self._session.client_params else None
require_task_augmented_sampling(client_caps)
validate_sampling_tools(client_caps, tools, tool_choice)
validate_tool_use_result_messages(messages)
if self._handler is None:
raise RuntimeError("handler is required for create_message_as_task()")
# Update status to input_required
await self._store.update_task(self.task_id, status=TASK_STATUS_INPUT_REQUIRED)
# Build request WITH task field for task-augmented sampling
request = self._session._build_create_message_request( # pyright: ignore[reportPrivateUsage]
messages=messages,
max_tokens=max_tokens,
system_prompt=system_prompt,
include_context=include_context,
temperature=temperature,
stop_sequences=stop_sequences,
metadata=metadata,
model_preferences=model_preferences,
tools=tools,
tool_choice=tool_choice,
related_task_id=self.task_id,
task=TaskMetadata(ttl=ttl),
)
request_id: RequestId = request.id
resolver: Resolver[dict[str, Any]] = Resolver()
self._handler._pending_requests[request_id] = resolver # pyright: ignore[reportPrivateUsage]
queued = QueuedMessage(
type="request",
message=request,
resolver=resolver,
original_request_id=request_id,
)
await self._queue.enqueue(self.task_id, queued)
try:
# Wait for initial response (CreateTaskResult from client)
response_data = await resolver.wait()
create_result = CreateTaskResult.model_validate(response_data)
client_task_id = create_result.task.taskId
# Poll the client's task using session.experimental
async for _ in self._session.experimental.poll_task(client_task_id):
pass
# Get final result from client
result = await self._session.experimental.get_task_result(
client_task_id,
CreateMessageResult,
)
await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING)
return result
except anyio.get_cancelled_exc_class(): # pragma: no cover
await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING)
raise
@@ -0,0 +1,235 @@
"""
TaskResultHandler - Integrated handler for tasks/result endpoint.
This implements the dequeue-send-wait pattern from the MCP Tasks spec:
1. Dequeue all pending messages for the task
2. Send them to the client via transport with relatedRequestId routing
3. Wait if task is not in terminal state
4. Return final result when task completes
This is the core of the task message queue pattern.
"""
import logging
from typing import Any
import anyio
from mcp.server.session import ServerSession
from mcp.shared.exceptions import McpError
from mcp.shared.experimental.tasks.helpers import RELATED_TASK_METADATA_KEY, is_terminal
from mcp.shared.experimental.tasks.message_queue import TaskMessageQueue
from mcp.shared.experimental.tasks.resolver import Resolver
from mcp.shared.experimental.tasks.store import TaskStore
from mcp.shared.message import ServerMessageMetadata, SessionMessage
from mcp.types import (
INVALID_PARAMS,
ErrorData,
GetTaskPayloadRequest,
GetTaskPayloadResult,
JSONRPCMessage,
RelatedTaskMetadata,
RequestId,
)
logger = logging.getLogger(__name__)
class TaskResultHandler:
"""
Handler for tasks/result that implements the message queue pattern.
This handler:
1. Dequeues pending messages (elicitations, notifications) for the task
2. Sends them to the client via the response stream
3. Waits for responses and resolves them back to callers
4. Blocks until task reaches terminal state
5. Returns the final result
Usage:
# Create handler with store and queue
handler = TaskResultHandler(task_store, message_queue)
# Register it with the server
@server.experimental.get_task_result()
async def handle_task_result(req: GetTaskPayloadRequest) -> GetTaskPayloadResult:
ctx = server.request_context
return await handler.handle(req, ctx.session, ctx.request_id)
# Or use the convenience method
handler.register(server)
"""
def __init__(
self,
store: TaskStore,
queue: TaskMessageQueue,
):
self._store = store
self._queue = queue
# Map from internal request ID to resolver for routing responses
self._pending_requests: dict[RequestId, Resolver[dict[str, Any]]] = {}
async def send_message(
self,
session: ServerSession,
message: SessionMessage,
) -> None:
"""
Send a message via the session.
This is a helper for delivering queued task messages.
"""
await session.send_message(message)
async def handle(
self,
request: GetTaskPayloadRequest,
session: ServerSession,
request_id: RequestId,
) -> GetTaskPayloadResult:
"""
Handle a tasks/result request.
This implements the dequeue-send-wait loop:
1. Dequeue all pending messages
2. Send each via transport with relatedRequestId = this request's ID
3. If task not terminal, wait for status change
4. Loop until task is terminal
5. Return final result
Args:
request: The GetTaskPayloadRequest
session: The server session for sending messages
request_id: The request ID for relatedRequestId routing
Returns:
GetTaskPayloadResult with the task's final payload
"""
task_id = request.params.taskId
while True:
task = await self._store.get_task(task_id)
if task is None:
raise McpError(
ErrorData(
code=INVALID_PARAMS,
message=f"Task not found: {task_id}",
)
)
await self._deliver_queued_messages(task_id, session, request_id)
# If task is terminal, return result
if is_terminal(task.status):
result = await self._store.get_result(task_id)
# GetTaskPayloadResult is a Result with extra="allow"
# The stored result contains the actual payload data
# Per spec: tasks/result MUST include _meta with related-task metadata
related_task = RelatedTaskMetadata(taskId=task_id)
related_task_meta: dict[str, Any] = {RELATED_TASK_METADATA_KEY: related_task.model_dump(by_alias=True)}
if result is not None:
result_data = result.model_dump(by_alias=True)
existing_meta: dict[str, Any] = result_data.get("_meta") or {}
result_data["_meta"] = {**existing_meta, **related_task_meta}
return GetTaskPayloadResult.model_validate(result_data)
return GetTaskPayloadResult.model_validate({"_meta": related_task_meta})
# Wait for task update (status change or new messages)
await self._wait_for_task_update(task_id)
async def _deliver_queued_messages(
self,
task_id: str,
session: ServerSession,
request_id: RequestId,
) -> None:
"""
Dequeue and send all pending messages for a task.
Each message is sent via the session's write stream with
relatedRequestId set so responses route back to this stream.
"""
while True:
message = await self._queue.dequeue(task_id)
if message is None:
break
# If this is a request (not notification), wait for response
if message.type == "request" and message.resolver is not None:
# Store the resolver so we can route the response back
original_id = message.original_request_id
if original_id is not None:
self._pending_requests[original_id] = message.resolver
logger.debug("Delivering queued message for task %s: %s", task_id, message.type)
# Send the message with relatedRequestId for routing
session_message = SessionMessage(
message=JSONRPCMessage(message.message),
metadata=ServerMessageMetadata(related_request_id=request_id),
)
await self.send_message(session, session_message)
async def _wait_for_task_update(self, task_id: str) -> None:
"""
Wait for task to be updated (status change or new message).
Races between store update and queue message - first one wins.
"""
async with anyio.create_task_group() as tg:
async def wait_for_store() -> None:
try:
await self._store.wait_for_update(task_id)
except Exception:
pass
finally:
tg.cancel_scope.cancel()
async def wait_for_queue() -> None:
try:
await self._queue.wait_for_message(task_id)
except Exception:
pass
finally:
tg.cancel_scope.cancel()
tg.start_soon(wait_for_store)
tg.start_soon(wait_for_queue)
def route_response(self, request_id: RequestId, response: dict[str, Any]) -> bool:
"""
Route a response back to the waiting resolver.
This is called when a response arrives for a queued request.
Args:
request_id: The request ID from the response
response: The response data
Returns:
True if response was routed, False if no pending request
"""
resolver = self._pending_requests.pop(request_id, None)
if resolver is not None and not resolver.done():
resolver.set_result(response)
return True
return False
def route_error(self, request_id: RequestId, error: ErrorData) -> bool:
"""
Route an error back to the waiting resolver.
Args:
request_id: The request ID from the error response
error: The error data
Returns:
True if error was routed, False if no pending request
"""
resolver = self._pending_requests.pop(request_id, None)
if resolver is not None and not resolver.done():
resolver.set_exception(McpError(error))
return True
return False
+115
View File
@@ -0,0 +1,115 @@
"""
TaskSupport - Configuration for experimental task support.
This module provides the TaskSupport class which encapsulates all the
infrastructure needed for task-augmented requests: store, queue, and handler.
"""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
import anyio
from anyio.abc import TaskGroup
from mcp.server.experimental.task_result_handler import TaskResultHandler
from mcp.server.session import ServerSession
from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore
from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue, TaskMessageQueue
from mcp.shared.experimental.tasks.store import TaskStore
@dataclass
class TaskSupport:
"""
Configuration for experimental task support.
Encapsulates the task store, message queue, result handler, and task group
for spawning background work.
When enabled on a server, this automatically:
- Configures response routing for each session
- Provides default handlers for task operations
- Manages a task group for background task execution
Example:
# Simple in-memory setup
server.experimental.enable_tasks()
# Custom store/queue for distributed systems
server.experimental.enable_tasks(
store=RedisTaskStore(redis_url),
queue=RedisTaskMessageQueue(redis_url),
)
"""
store: TaskStore
queue: TaskMessageQueue
handler: TaskResultHandler = field(init=False)
_task_group: TaskGroup | None = field(init=False, default=None)
def __post_init__(self) -> None:
"""Create the result handler from store and queue."""
self.handler = TaskResultHandler(self.store, self.queue)
@property
def task_group(self) -> TaskGroup:
"""Get the task group for spawning background work.
Raises:
RuntimeError: If not within a run() context
"""
if self._task_group is None:
raise RuntimeError("TaskSupport not running. Ensure Server.run() is active.")
return self._task_group
@asynccontextmanager
async def run(self) -> AsyncIterator[None]:
"""
Run the task support lifecycle.
This creates a task group for spawning background task work.
Called automatically by Server.run().
Usage:
async with task_support.run():
# Task group is now available
...
"""
async with anyio.create_task_group() as tg:
self._task_group = tg
try:
yield
finally:
self._task_group = None
def configure_session(self, session: ServerSession) -> None:
"""
Configure a session for task support.
This registers the result handler as a response router so that
responses to queued requests (elicitation, sampling) are routed
back to the waiting resolvers.
Called automatically by Server.run() for each new session.
Args:
session: The session to configure
"""
session.add_response_router(self.handler)
@classmethod
def in_memory(cls) -> "TaskSupport":
"""
Create in-memory task support.
Suitable for development, testing, and single-process servers.
For distributed systems, provide custom store and queue implementations.
Returns:
TaskSupport configured with in-memory store and queue
"""
return cls(
store=InMemoryTaskStore(),
queue=InMemoryTaskMessageQueue(),
)
+288
View File
@@ -0,0 +1,288 @@
"""Experimental handlers for the low-level MCP server.
WARNING: These APIs are experimental and may change without notice.
"""
from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING
from mcp.server.experimental.task_support import TaskSupport
from mcp.server.lowlevel.func_inspection import create_call_wrapper
from mcp.shared.exceptions import McpError
from mcp.shared.experimental.tasks.helpers import cancel_task
from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore
from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue, TaskMessageQueue
from mcp.shared.experimental.tasks.store import TaskStore
from mcp.types import (
INVALID_PARAMS,
CancelTaskRequest,
CancelTaskResult,
ErrorData,
GetTaskPayloadRequest,
GetTaskPayloadResult,
GetTaskRequest,
GetTaskResult,
ListTasksRequest,
ListTasksResult,
ServerCapabilities,
ServerResult,
ServerTasksCapability,
ServerTasksRequestsCapability,
TasksCancelCapability,
TasksListCapability,
TasksToolsCapability,
)
if TYPE_CHECKING:
from mcp.server.lowlevel.server import Server
logger = logging.getLogger(__name__)
class ExperimentalHandlers:
"""Experimental request/notification handlers.
WARNING: These APIs are experimental and may change without notice.
"""
def __init__(
self,
server: Server,
request_handlers: dict[type, Callable[..., Awaitable[ServerResult]]],
notification_handlers: dict[type, Callable[..., Awaitable[None]]],
):
self._server = server
self._request_handlers = request_handlers
self._notification_handlers = notification_handlers
self._task_support: TaskSupport | None = None
@property
def task_support(self) -> TaskSupport | None:
"""Get the task support configuration, if enabled."""
return self._task_support
def update_capabilities(self, capabilities: ServerCapabilities) -> None:
# Only add tasks capability if handlers are registered
if not any(
req_type in self._request_handlers
for req_type in [GetTaskRequest, ListTasksRequest, CancelTaskRequest, GetTaskPayloadRequest]
):
return
capabilities.tasks = ServerTasksCapability()
if ListTasksRequest in self._request_handlers:
capabilities.tasks.list = TasksListCapability()
if CancelTaskRequest in self._request_handlers:
capabilities.tasks.cancel = TasksCancelCapability()
capabilities.tasks.requests = ServerTasksRequestsCapability(
tools=TasksToolsCapability()
) # assuming always supported for now
def enable_tasks(
self,
store: TaskStore | None = None,
queue: TaskMessageQueue | None = None,
) -> TaskSupport:
"""
Enable experimental task support.
This sets up the task infrastructure and auto-registers default handlers
for tasks/get, tasks/result, tasks/list, and tasks/cancel.
Args:
store: Custom TaskStore implementation (defaults to InMemoryTaskStore)
queue: Custom TaskMessageQueue implementation (defaults to InMemoryTaskMessageQueue)
Returns:
The TaskSupport configuration object
Example:
# Simple in-memory setup
server.experimental.enable_tasks()
# Custom store/queue for distributed systems
server.experimental.enable_tasks(
store=RedisTaskStore(redis_url),
queue=RedisTaskMessageQueue(redis_url),
)
WARNING: This API is experimental and may change without notice.
"""
if store is None:
store = InMemoryTaskStore()
if queue is None:
queue = InMemoryTaskMessageQueue()
self._task_support = TaskSupport(store=store, queue=queue)
# Auto-register default handlers
self._register_default_task_handlers()
return self._task_support
def _register_default_task_handlers(self) -> None:
"""Register default handlers for task operations."""
assert self._task_support is not None
support = self._task_support
# Register get_task handler if not already registered
if GetTaskRequest not in self._request_handlers:
async def _default_get_task(req: GetTaskRequest) -> ServerResult:
task = await support.store.get_task(req.params.taskId)
if task is None:
raise McpError(
ErrorData(
code=INVALID_PARAMS,
message=f"Task not found: {req.params.taskId}",
)
)
return ServerResult(
GetTaskResult(
taskId=task.taskId,
status=task.status,
statusMessage=task.statusMessage,
createdAt=task.createdAt,
lastUpdatedAt=task.lastUpdatedAt,
ttl=task.ttl,
pollInterval=task.pollInterval,
)
)
self._request_handlers[GetTaskRequest] = _default_get_task
# Register get_task_result handler if not already registered
if GetTaskPayloadRequest not in self._request_handlers:
async def _default_get_task_result(req: GetTaskPayloadRequest) -> ServerResult:
ctx = self._server.request_context
result = await support.handler.handle(req, ctx.session, ctx.request_id)
return ServerResult(result)
self._request_handlers[GetTaskPayloadRequest] = _default_get_task_result
# Register list_tasks handler if not already registered
if ListTasksRequest not in self._request_handlers:
async def _default_list_tasks(req: ListTasksRequest) -> ServerResult:
cursor = req.params.cursor if req.params else None
tasks, next_cursor = await support.store.list_tasks(cursor)
return ServerResult(ListTasksResult(tasks=tasks, nextCursor=next_cursor))
self._request_handlers[ListTasksRequest] = _default_list_tasks
# Register cancel_task handler if not already registered
if CancelTaskRequest not in self._request_handlers:
async def _default_cancel_task(req: CancelTaskRequest) -> ServerResult:
result = await cancel_task(support.store, req.params.taskId)
return ServerResult(result)
self._request_handlers[CancelTaskRequest] = _default_cancel_task
def list_tasks(
self,
) -> Callable[
[Callable[[ListTasksRequest], Awaitable[ListTasksResult]]],
Callable[[ListTasksRequest], Awaitable[ListTasksResult]],
]:
"""Register a handler for listing tasks.
WARNING: This API is experimental and may change without notice.
"""
def decorator(
func: Callable[[ListTasksRequest], Awaitable[ListTasksResult]],
) -> Callable[[ListTasksRequest], Awaitable[ListTasksResult]]:
logger.debug("Registering handler for ListTasksRequest")
wrapper = create_call_wrapper(func, ListTasksRequest)
async def handler(req: ListTasksRequest) -> ServerResult:
result = await wrapper(req)
return ServerResult(result)
self._request_handlers[ListTasksRequest] = handler
return func
return decorator
def get_task(
self,
) -> Callable[
[Callable[[GetTaskRequest], Awaitable[GetTaskResult]]], Callable[[GetTaskRequest], Awaitable[GetTaskResult]]
]:
"""Register a handler for getting task status.
WARNING: This API is experimental and may change without notice.
"""
def decorator(
func: Callable[[GetTaskRequest], Awaitable[GetTaskResult]],
) -> Callable[[GetTaskRequest], Awaitable[GetTaskResult]]:
logger.debug("Registering handler for GetTaskRequest")
wrapper = create_call_wrapper(func, GetTaskRequest)
async def handler(req: GetTaskRequest) -> ServerResult:
result = await wrapper(req)
return ServerResult(result)
self._request_handlers[GetTaskRequest] = handler
return func
return decorator
def get_task_result(
self,
) -> Callable[
[Callable[[GetTaskPayloadRequest], Awaitable[GetTaskPayloadResult]]],
Callable[[GetTaskPayloadRequest], Awaitable[GetTaskPayloadResult]],
]:
"""Register a handler for getting task results/payload.
WARNING: This API is experimental and may change without notice.
"""
def decorator(
func: Callable[[GetTaskPayloadRequest], Awaitable[GetTaskPayloadResult]],
) -> Callable[[GetTaskPayloadRequest], Awaitable[GetTaskPayloadResult]]:
logger.debug("Registering handler for GetTaskPayloadRequest")
wrapper = create_call_wrapper(func, GetTaskPayloadRequest)
async def handler(req: GetTaskPayloadRequest) -> ServerResult:
result = await wrapper(req)
return ServerResult(result)
self._request_handlers[GetTaskPayloadRequest] = handler
return func
return decorator
def cancel_task(
self,
) -> Callable[
[Callable[[CancelTaskRequest], Awaitable[CancelTaskResult]]],
Callable[[CancelTaskRequest], Awaitable[CancelTaskResult]],
]:
"""Register a handler for cancelling tasks.
WARNING: This API is experimental and may change without notice.
"""
def decorator(
func: Callable[[CancelTaskRequest], Awaitable[CancelTaskResult]],
) -> Callable[[CancelTaskRequest], Awaitable[CancelTaskResult]]:
logger.debug("Registering handler for CancelTaskRequest")
wrapper = create_call_wrapper(func, CancelTaskRequest)
async def handler(req: CancelTaskRequest) -> ServerResult:
result = await wrapper(req)
return ServerResult(result)
self._request_handlers[CancelTaskRequest] = handler
return func
return decorator
+51 -6
View File
@@ -67,6 +67,7 @@ messages from the client.
from __future__ import annotations as _annotations
import base64
import contextvars
import json
import logging
@@ -82,6 +83,8 @@ from pydantic import AnyUrl
from typing_extensions import TypeVar
import mcp.types as types
from mcp.server.experimental.request_context import Experimental
from mcp.server.lowlevel.experimental import ExperimentalHandlers
from mcp.server.lowlevel.func_inspection import create_call_wrapper
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.server.models import InitializationOptions
@@ -155,6 +158,7 @@ class Server(Generic[LifespanResultT, RequestT]):
}
self.notification_handlers: dict[type, Callable[..., Awaitable[None]]] = {}
self._tool_cache: dict[str, types.Tool] = {}
self._experimental_handlers: ExperimentalHandlers | None = None
logger.debug("Initializing server %r", name)
def create_initialization_options(
@@ -220,7 +224,7 @@ class Server(Generic[LifespanResultT, RequestT]):
if types.CompleteRequest in self.request_handlers:
completions_capability = types.CompletionsCapability()
return types.ServerCapabilities(
capabilities = types.ServerCapabilities(
prompts=prompts_capability,
resources=resources_capability,
tools=tools_capability,
@@ -228,6 +232,9 @@ class Server(Generic[LifespanResultT, RequestT]):
experimental=experimental_capabilities,
completions=completions_capability,
)
if self._experimental_handlers:
self._experimental_handlers.update_capabilities(capabilities)
return capabilities
@property
def request_context(
@@ -236,6 +243,18 @@ class Server(Generic[LifespanResultT, RequestT]):
"""If called outside of a request context, this will raise a LookupError."""
return request_ctx.get()
@property
def experimental(self) -> ExperimentalHandlers:
"""Experimental APIs for tasks and other features.
WARNING: These APIs are experimental and may change without notice.
"""
# We create this inline so we only add these capabilities _if_ they're actually used
if self._experimental_handlers is None:
self._experimental_handlers = ExperimentalHandlers(self, self.request_handlers, self.notification_handlers)
return self._experimental_handlers
def list_prompts(self):
def decorator(
func: Callable[[], Awaitable[list[types.Prompt]]]
@@ -328,8 +347,6 @@ class Server(Generic[LifespanResultT, RequestT]):
mimeType=mime_type or "text/plain",
)
case bytes() as data: # pragma: no cover
import base64
return types.BlobResourceContents(
uri=req.params.uri,
blob=base64.b64encode(data).decode(),
@@ -483,7 +500,13 @@ class Server(Generic[LifespanResultT, RequestT]):
def decorator(
func: Callable[
...,
Awaitable[UnstructuredContent | StructuredContent | CombinationContent | types.CallToolResult],
Awaitable[
UnstructuredContent
| StructuredContent
| CombinationContent
| types.CallToolResult
| types.CreateTaskResult
],
],
):
logger.debug("Registering handler for CallToolRequest")
@@ -509,6 +532,9 @@ class Server(Generic[LifespanResultT, RequestT]):
maybe_structured_content: StructuredContent | None
if isinstance(results, types.CallToolResult):
return types.ServerResult(results)
elif isinstance(results, types.CreateTaskResult):
# Task-augmented execution returns task info instead of result
return types.ServerResult(results)
elif isinstance(results, tuple) and len(results) == 2:
# tool returned both structured and unstructured content
unstructured_content, maybe_structured_content = cast(CombinationContent, results)
@@ -627,6 +653,12 @@ class Server(Generic[LifespanResultT, RequestT]):
)
)
# Configure task support for this session if enabled
task_support = self._experimental_handlers.task_support if self._experimental_handlers else None
if task_support is not None:
task_support.configure_session(session)
await stack.enter_async_context(task_support.run())
async with anyio.create_task_group() as tg:
async for message in session.incoming_messages:
logger.debug("Received message: %s", message)
@@ -669,13 +701,14 @@ class Server(Generic[LifespanResultT, RequestT]):
async def _handle_request(
self,
message: RequestResponder[types.ClientRequest, types.ServerResult],
req: Any,
req: types.ClientRequestType,
session: ServerSession,
lifespan_context: LifespanResultT,
raise_exceptions: bool,
):
logger.info("Processing request of type %s", type(req).__name__)
if handler := self.request_handlers.get(type(req)): # type: ignore
if handler := self.request_handlers.get(type(req)):
logger.debug("Dispatching request of type %s", type(req).__name__)
token = None
@@ -689,12 +722,24 @@ class Server(Generic[LifespanResultT, RequestT]):
# Set our global state that can be retrieved via
# app.get_request_context()
client_capabilities = session.client_params.capabilities if session.client_params else None
task_support = self._experimental_handlers.task_support if self._experimental_handlers else None
# Get task metadata from request params if present
task_metadata = None
if hasattr(req, "params") and req.params is not None:
task_metadata = getattr(req.params, "task", None)
token = request_ctx.set(
RequestContext(
message.request_id,
message.request_meta,
session,
lifespan_context,
Experimental(
task_metadata=task_metadata,
_client_capabilities=client_capabilities,
_session=session,
_task_support=task_support,
),
request=request_data,
)
)
+207 -53
View File
@@ -46,8 +46,11 @@ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStre
from pydantic import AnyUrl
import mcp.types as types
from mcp.server.experimental.session_features import ExperimentalServerSessionFeatures
from mcp.server.models import InitializationOptions
from mcp.shared.exceptions import McpError
from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages
from mcp.shared.experimental.tasks.capabilities import check_tasks_capability
from mcp.shared.experimental.tasks.helpers import RELATED_TASK_METADATA_KEY
from mcp.shared.message import ServerMessageMetadata, SessionMessage
from mcp.shared.session import (
BaseSession,
@@ -80,6 +83,7 @@ class ServerSession(
):
_initialized: InitializationState = InitializationState.NotInitialized
_client_params: types.InitializeRequestParams | None = None
_experimental_features: ExperimentalServerSessionFeatures | None = None
def __init__(
self,
@@ -103,15 +107,23 @@ class ServerSession(
def client_params(self) -> types.InitializeRequestParams | None:
return self._client_params # pragma: no cover
@property
def experimental(self) -> ExperimentalServerSessionFeatures:
"""Experimental APIs for server→client task operations.
WARNING: These APIs are experimental and may change without notice.
"""
if self._experimental_features is None:
self._experimental_features = ExperimentalServerSessionFeatures(self)
return self._experimental_features
def check_client_capability(self, capability: types.ClientCapabilities) -> bool: # pragma: no cover
"""Check if the client supports a specific capability."""
if self._client_params is None:
return False
# Get client capabilities from initialization params
client_caps = self._client_params.capabilities
# Check each specified capability in the passed in capability object
if capability.roots is not None:
if client_caps.roots is None:
return False
@@ -121,25 +133,27 @@ class ServerSession(
if capability.sampling is not None:
if client_caps.sampling is None:
return False
if capability.sampling.context is not None:
if client_caps.sampling.context is None:
return False
if capability.sampling.tools is not None:
if client_caps.sampling.tools is None:
return False
if capability.elicitation is not None:
if client_caps.elicitation is None:
if capability.sampling.context is not None and client_caps.sampling.context is None:
return False
if capability.sampling.tools is not None and client_caps.sampling.tools is None:
return False
if capability.elicitation is not None and client_caps.elicitation is None:
return False
if capability.experimental is not None:
if client_caps.experimental is None:
return False
# Check each experimental capability
for exp_key, exp_value in capability.experimental.items():
if exp_key not in client_caps.experimental or client_caps.experimental[exp_key] != exp_value:
return False
if capability.tasks is not None:
if client_caps.tasks is None:
return False
if not check_tasks_capability(capability.tasks, client_caps.tasks):
return False
return True
async def _receive_loop(self) -> None:
@@ -257,47 +271,12 @@ class ServerSession(
The sampling result from the client.
Raises:
McpError: If tool_use or tool_result blocks are misused when tools are provided.
McpError: If tools are provided but client doesn't support them.
ValueError: If tool_use or tool_result message structure is invalid.
"""
if tools is not None or tool_choice is not None:
has_tools_cap = self.check_client_capability(
types.ClientCapabilities(sampling=types.SamplingCapability(tools=types.SamplingToolsCapability()))
)
if not has_tools_cap:
raise McpError(
types.ErrorData(
code=types.INVALID_PARAMS,
message="Client does not support sampling tools capability",
)
)
# Validate tool_use/tool_result message structure per SEP-1577:
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1577
# This validation runs regardless of whether `tools` is in this request,
# since a tool loop continuation may omit `tools` while still containing
# tool_result content that must match previous tool_use.
if messages:
last_content = messages[-1].content_as_list
has_tool_results = any(c.type == "tool_result" for c in last_content)
previous_content = messages[-2].content_as_list if len(messages) >= 2 else None
has_previous_tool_use = previous_content and any(c.type == "tool_use" for c in previous_content)
if has_tool_results:
# Per spec: "SamplingMessage with tool result content blocks
# MUST NOT contain other content types."
if any(c.type != "tool_result" for c in last_content):
raise ValueError("The last message must contain only tool_result content if any is present")
if previous_content is None:
raise ValueError("tool_result requires a previous message containing tool_use")
if not has_previous_tool_use:
raise ValueError("tool_result blocks do not match any tool_use in the previous message")
if has_previous_tool_use and previous_content:
tool_use_ids = {c.id for c in previous_content if c.type == "tool_use"}
tool_result_ids = {c.toolUseId for c in last_content if c.type == "tool_result"}
if tool_use_ids != tool_result_ids:
raise ValueError("ids of tool_result blocks and tool_use blocks from previous message do not match")
client_caps = self._client_params.capabilities if self._client_params else None
validate_sampling_tools(client_caps, tools, tool_choice)
validate_tool_use_result_messages(messages)
return await self.send_request(
request=types.ServerRequest(
@@ -481,6 +460,181 @@ class ServerSession(
related_request_id,
)
def _build_elicit_form_request(
self,
message: str,
requestedSchema: types.ElicitRequestedSchema,
related_task_id: str | None = None,
task: types.TaskMetadata | None = None,
) -> types.JSONRPCRequest:
"""Build a form mode elicitation request without sending it.
Args:
message: The message to present to the user
requestedSchema: Schema defining the expected response structure
related_task_id: If provided, adds io.modelcontextprotocol/related-task metadata
task: If provided, makes this a task-augmented request
Returns:
A JSONRPCRequest ready to be sent or queued
"""
params = types.ElicitRequestFormParams(
message=message,
requestedSchema=requestedSchema,
task=task,
)
params_data = params.model_dump(by_alias=True, mode="json", exclude_none=True)
# Add related-task metadata if associated with a parent task
if related_task_id is not None:
# Defensive: model_dump() never includes _meta, but guard against future changes
if "_meta" not in params_data: # pragma: no cover
params_data["_meta"] = {}
params_data["_meta"][RELATED_TASK_METADATA_KEY] = types.RelatedTaskMetadata(
taskId=related_task_id
).model_dump(by_alias=True)
request_id = f"task-{related_task_id}-{id(params)}" if related_task_id else self._request_id
if related_task_id is None:
self._request_id += 1
return types.JSONRPCRequest(
jsonrpc="2.0",
id=request_id,
method="elicitation/create",
params=params_data,
)
def _build_elicit_url_request(
self,
message: str,
url: str,
elicitation_id: str,
related_task_id: str | None = None,
) -> types.JSONRPCRequest:
"""Build a URL mode elicitation request without sending it.
Args:
message: Human-readable explanation of why the interaction is needed
url: The URL the user should navigate to
elicitation_id: Unique identifier for tracking this elicitation
related_task_id: If provided, adds io.modelcontextprotocol/related-task metadata
Returns:
A JSONRPCRequest ready to be sent or queued
"""
params = types.ElicitRequestURLParams(
message=message,
url=url,
elicitationId=elicitation_id,
)
params_data = params.model_dump(by_alias=True, mode="json", exclude_none=True)
# Add related-task metadata if associated with a parent task
if related_task_id is not None:
# Defensive: model_dump() never includes _meta, but guard against future changes
if "_meta" not in params_data: # pragma: no cover
params_data["_meta"] = {}
params_data["_meta"][RELATED_TASK_METADATA_KEY] = types.RelatedTaskMetadata(
taskId=related_task_id
).model_dump(by_alias=True)
request_id = f"task-{related_task_id}-{id(params)}" if related_task_id else self._request_id
if related_task_id is None:
self._request_id += 1
return types.JSONRPCRequest(
jsonrpc="2.0",
id=request_id,
method="elicitation/create",
params=params_data,
)
def _build_create_message_request(
self,
messages: list[types.SamplingMessage],
*,
max_tokens: int,
system_prompt: str | None = None,
include_context: types.IncludeContext | None = None,
temperature: float | None = None,
stop_sequences: list[str] | None = None,
metadata: dict[str, Any] | None = None,
model_preferences: types.ModelPreferences | None = None,
tools: list[types.Tool] | None = None,
tool_choice: types.ToolChoice | None = None,
related_task_id: str | None = None,
task: types.TaskMetadata | None = None,
) -> types.JSONRPCRequest:
"""Build a sampling/createMessage request without sending it.
Args:
messages: The conversation messages to send
max_tokens: Maximum number of tokens to generate
system_prompt: Optional system prompt
include_context: Optional context inclusion setting
temperature: Optional sampling temperature
stop_sequences: Optional stop sequences
metadata: Optional metadata to pass through to the LLM provider
model_preferences: Optional model selection preferences
tools: Optional list of tools the LLM can use during sampling
tool_choice: Optional control over tool usage behavior
related_task_id: If provided, adds io.modelcontextprotocol/related-task metadata
task: If provided, makes this a task-augmented request
Returns:
A JSONRPCRequest ready to be sent or queued
"""
params = types.CreateMessageRequestParams(
messages=messages,
systemPrompt=system_prompt,
includeContext=include_context,
temperature=temperature,
maxTokens=max_tokens,
stopSequences=stop_sequences,
metadata=metadata,
modelPreferences=model_preferences,
tools=tools,
toolChoice=tool_choice,
task=task,
)
params_data = params.model_dump(by_alias=True, mode="json", exclude_none=True)
# Add related-task metadata if associated with a parent task
if related_task_id is not None:
# Defensive: model_dump() never includes _meta, but guard against future changes
if "_meta" not in params_data: # pragma: no cover
params_data["_meta"] = {}
params_data["_meta"][RELATED_TASK_METADATA_KEY] = types.RelatedTaskMetadata(
taskId=related_task_id
).model_dump(by_alias=True)
request_id = f"task-{related_task_id}-{id(params)}" if related_task_id else self._request_id
if related_task_id is None:
self._request_id += 1
return types.JSONRPCRequest(
jsonrpc="2.0",
id=request_id,
method="sampling/createMessage",
params=params_data,
)
async def send_message(self, message: SessionMessage) -> None:
"""Send a raw session message.
This is primarily used by TaskResultHandler to deliver queued messages
(elicitation/sampling requests) to the client during task execution.
WARNING: This is a low-level experimental method that may change without
notice. Prefer using higher-level methods like send_notification() or
send_request() for normal operations.
Args:
message: The session message to send
"""
await self._write_stream.send(message)
async def _handle_incoming(self, req: ServerRequestResponder) -> None:
await self._incoming_message_stream_writer.send(req)
+104
View File
@@ -0,0 +1,104 @@
"""
Shared validation functions for server requests.
This module provides validation logic for sampling and elicitation requests
that is shared across normal and task-augmented code paths.
"""
from mcp.shared.exceptions import McpError
from mcp.types import (
INVALID_PARAMS,
ClientCapabilities,
ErrorData,
SamplingMessage,
Tool,
ToolChoice,
)
def check_sampling_tools_capability(client_caps: ClientCapabilities | None) -> bool:
"""
Check if the client supports sampling tools capability.
Args:
client_caps: The client's declared capabilities
Returns:
True if client supports sampling.tools, False otherwise
"""
if client_caps is None:
return False
if client_caps.sampling is None:
return False
if client_caps.sampling.tools is None:
return False
return True
def validate_sampling_tools(
client_caps: ClientCapabilities | None,
tools: list[Tool] | None,
tool_choice: ToolChoice | None,
) -> None:
"""
Validate that the client supports sampling tools if tools are being used.
Args:
client_caps: The client's declared capabilities
tools: The tools list, if provided
tool_choice: The tool choice setting, if provided
Raises:
McpError: If tools/tool_choice are provided but client doesn't support them
"""
if tools is not None or tool_choice is not None:
if not check_sampling_tools_capability(client_caps):
raise McpError(
ErrorData(
code=INVALID_PARAMS,
message="Client does not support sampling tools capability",
)
)
def validate_tool_use_result_messages(messages: list[SamplingMessage]) -> None:
"""
Validate tool_use/tool_result message structure per SEP-1577.
This validation ensures:
1. Messages with tool_result content contain ONLY tool_result content
2. tool_result messages are preceded by a message with tool_use
3. tool_result IDs match the tool_use IDs from the previous message
See: https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1577
Args:
messages: The list of sampling messages to validate
Raises:
ValueError: If the message structure is invalid
"""
if not messages:
return
last_content = messages[-1].content_as_list
has_tool_results = any(c.type == "tool_result" for c in last_content)
previous_content = messages[-2].content_as_list if len(messages) >= 2 else None
has_previous_tool_use = previous_content and any(c.type == "tool_use" for c in previous_content)
if has_tool_results:
# Per spec: "SamplingMessage with tool result content blocks
# MUST NOT contain other content types."
if any(c.type != "tool_result" for c in last_content):
raise ValueError("The last message must contain only tool_result content if any is present")
if previous_content is None:
raise ValueError("tool_result requires a previous message containing tool_use")
if not has_previous_tool_use:
raise ValueError("tool_result blocks do not match any tool_use in the previous message")
if has_previous_tool_use and previous_content:
tool_use_ids = {c.id for c in previous_content if c.type == "tool_use"}
tool_result_ids = {c.toolUseId for c in last_content if c.type == "tool_result"}
if tool_use_ids != tool_result_ids:
raise ValueError("ids of tool_result blocks and tool_use blocks from previous message do not match")
+10 -1
View File
@@ -1,4 +1,8 @@
from dataclasses import dataclass
"""
Request context for MCP handlers.
"""
from dataclasses import dataclass, field
from typing import Any, Generic
from typing_extensions import TypeVar
@@ -17,4 +21,9 @@ class RequestContext(Generic[SessionT, LifespanContextT, RequestT]):
meta: RequestParams.Meta | None
session: SessionT
lifespan_context: LifespanContextT
# NOTE: This is typed as Any to avoid circular imports. The actual type is
# mcp.server.experimental.request_context.Experimental, but importing it here
# triggers mcp.server.__init__ -> fastmcp -> tools -> back to this module.
# The Server sets this to an Experimental instance at runtime.
experimental: Any = field(default=None)
request: RequestT | None = None
+7
View File
@@ -0,0 +1,7 @@
"""
Pure experimental MCP features (no server dependencies).
WARNING: These APIs are experimental and may change without notice.
For server-integrated experimental features, use mcp.server.experimental.
"""
@@ -0,0 +1,12 @@
"""
Pure task state management for MCP.
WARNING: These APIs are experimental and may change without notice.
Import directly from submodules:
- mcp.shared.experimental.tasks.store.TaskStore
- mcp.shared.experimental.tasks.context.TaskContext
- mcp.shared.experimental.tasks.in_memory_task_store.InMemoryTaskStore
- mcp.shared.experimental.tasks.message_queue.TaskMessageQueue
- mcp.shared.experimental.tasks.helpers.is_terminal
"""
@@ -0,0 +1,115 @@
"""
Tasks capability checking utilities.
This module provides functions for checking and requiring task-related
capabilities. All tasks capability logic is centralized here to keep
the main session code clean.
WARNING: These APIs are experimental and may change without notice.
"""
from mcp.shared.exceptions import McpError
from mcp.types import (
INVALID_REQUEST,
ClientCapabilities,
ClientTasksCapability,
ErrorData,
)
def check_tasks_capability(
required: ClientTasksCapability,
client: ClientTasksCapability,
) -> bool:
"""
Check if client's tasks capability matches the required capability.
Args:
required: The capability being checked for
client: The client's declared capabilities
Returns:
True if client has the required capability, False otherwise
"""
if required.requests is None:
return True
if client.requests is None:
return False
# Check elicitation.create
if required.requests.elicitation is not None:
if client.requests.elicitation is None:
return False
if required.requests.elicitation.create is not None:
if client.requests.elicitation.create is None:
return False
# Check sampling.createMessage
if required.requests.sampling is not None:
if client.requests.sampling is None:
return False
if required.requests.sampling.createMessage is not None:
if client.requests.sampling.createMessage is None:
return False
return True
def has_task_augmented_elicitation(caps: ClientCapabilities) -> bool:
"""Check if capabilities include task-augmented elicitation support."""
if caps.tasks is None:
return False
if caps.tasks.requests is None:
return False
if caps.tasks.requests.elicitation is None:
return False
return caps.tasks.requests.elicitation.create is not None
def has_task_augmented_sampling(caps: ClientCapabilities) -> bool:
"""Check if capabilities include task-augmented sampling support."""
if caps.tasks is None:
return False
if caps.tasks.requests is None:
return False
if caps.tasks.requests.sampling is None:
return False
return caps.tasks.requests.sampling.createMessage is not None
def require_task_augmented_elicitation(client_caps: ClientCapabilities | None) -> None:
"""
Raise McpError if client doesn't support task-augmented elicitation.
Args:
client_caps: The client's declared capabilities, or None if not initialized
Raises:
McpError: If client doesn't support task-augmented elicitation
"""
if client_caps is None or not has_task_augmented_elicitation(client_caps):
raise McpError(
ErrorData(
code=INVALID_REQUEST,
message="Client does not support task-augmented elicitation",
)
)
def require_task_augmented_sampling(client_caps: ClientCapabilities | None) -> None:
"""
Raise McpError if client doesn't support task-augmented sampling.
Args:
client_caps: The client's declared capabilities, or None if not initialized
Raises:
McpError: If client doesn't support task-augmented sampling
"""
if client_caps is None or not has_task_augmented_sampling(client_caps):
raise McpError(
ErrorData(
code=INVALID_REQUEST,
message="Client does not support task-augmented sampling",
)
)
@@ -0,0 +1,101 @@
"""
TaskContext - Pure task state management.
This module provides TaskContext, which manages task state without any
server/session dependencies. It can be used standalone for distributed
workers or wrapped by ServerTaskContext for full server integration.
"""
from mcp.shared.experimental.tasks.store import TaskStore
from mcp.types import TASK_STATUS_COMPLETED, TASK_STATUS_FAILED, Result, Task
class TaskContext:
"""
Pure task state management - no session dependencies.
This class handles:
- Task state (status, result)
- Cancellation tracking
- Store interactions
For server-integrated features (elicit, create_message, notifications),
use ServerTaskContext from mcp.server.experimental.
Example (distributed worker):
async def worker_job(task_id: str):
store = RedisTaskStore(redis_url)
task = await store.get_task(task_id)
ctx = TaskContext(task=task, store=store)
await ctx.update_status("Working...")
result = await do_work()
await ctx.complete(result)
"""
def __init__(self, task: Task, store: TaskStore):
self._task = task
self._store = store
self._cancelled = False
@property
def task_id(self) -> str:
"""The task identifier."""
return self._task.taskId
@property
def task(self) -> Task:
"""The current task state."""
return self._task
@property
def is_cancelled(self) -> bool:
"""Whether cancellation has been requested."""
return self._cancelled
def request_cancellation(self) -> None:
"""
Request cancellation of this task.
This sets is_cancelled=True. Task work should check this
periodically and exit gracefully if set.
"""
self._cancelled = True
async def update_status(self, message: str) -> None:
"""
Update the task's status message.
Args:
message: The new status message
"""
self._task = await self._store.update_task(
self.task_id,
status_message=message,
)
async def complete(self, result: Result) -> None:
"""
Mark the task as completed with the given result.
Args:
result: The task result
"""
await self._store.store_result(self.task_id, result)
self._task = await self._store.update_task(
self.task_id,
status=TASK_STATUS_COMPLETED,
)
async def fail(self, error: str) -> None:
"""
Mark the task as failed with an error message.
Args:
error: The error message
"""
self._task = await self._store.update_task(
self.task_id,
status=TASK_STATUS_FAILED,
status_message=error,
)
@@ -0,0 +1,181 @@
"""
Helper functions for pure task management.
These helpers work with pure TaskContext and don't require server dependencies.
For server-integrated task helpers, use mcp.server.experimental.
"""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from uuid import uuid4
from mcp.shared.exceptions import McpError
from mcp.shared.experimental.tasks.context import TaskContext
from mcp.shared.experimental.tasks.store import TaskStore
from mcp.types import (
INVALID_PARAMS,
TASK_STATUS_CANCELLED,
TASK_STATUS_COMPLETED,
TASK_STATUS_FAILED,
TASK_STATUS_WORKING,
CancelTaskResult,
ErrorData,
Task,
TaskMetadata,
TaskStatus,
)
# Metadata key for model-immediate-response (per MCP spec)
# Servers MAY include this in CreateTaskResult._meta to provide an immediate
# response string while the task executes in the background.
MODEL_IMMEDIATE_RESPONSE_KEY = "io.modelcontextprotocol/model-immediate-response"
# Metadata key for associating requests with a task (per MCP spec)
RELATED_TASK_METADATA_KEY = "io.modelcontextprotocol/related-task"
def is_terminal(status: TaskStatus) -> bool:
"""
Check if a task status represents a terminal state.
Terminal states are those where the task has finished and will not change.
Args:
status: The task status to check
Returns:
True if the status is terminal (completed, failed, or cancelled)
"""
return status in (TASK_STATUS_COMPLETED, TASK_STATUS_FAILED, TASK_STATUS_CANCELLED)
async def cancel_task(
store: TaskStore,
task_id: str,
) -> CancelTaskResult:
"""
Cancel a task with spec-compliant validation.
Per spec: "Receivers MUST reject cancellation of terminal status tasks
with -32602 (Invalid params)"
This helper validates that the task exists and is not in a terminal state
before setting it to "cancelled".
Args:
store: The task store
task_id: The task identifier to cancel
Returns:
CancelTaskResult with the cancelled task state
Raises:
McpError: With INVALID_PARAMS (-32602) if:
- Task does not exist
- Task is already in a terminal state (completed, failed, cancelled)
Example:
@server.experimental.cancel_task()
async def handle_cancel(request: CancelTaskRequest) -> CancelTaskResult:
return await cancel_task(store, request.params.taskId)
"""
task = await store.get_task(task_id)
if task is None:
raise McpError(
ErrorData(
code=INVALID_PARAMS,
message=f"Task not found: {task_id}",
)
)
if is_terminal(task.status):
raise McpError(
ErrorData(
code=INVALID_PARAMS,
message=f"Cannot cancel task in terminal state '{task.status}'",
)
)
# Update task to cancelled status
cancelled_task = await store.update_task(task_id, status=TASK_STATUS_CANCELLED)
return CancelTaskResult(**cancelled_task.model_dump())
def generate_task_id() -> str:
"""Generate a unique task ID."""
return str(uuid4())
def create_task_state(
metadata: TaskMetadata,
task_id: str | None = None,
) -> Task:
"""
Create a Task object with initial state.
This is a helper for TaskStore implementations.
Args:
metadata: Task metadata
task_id: Optional task ID (generated if not provided)
Returns:
A new Task in "working" status
"""
now = datetime.now(timezone.utc)
return Task(
taskId=task_id or generate_task_id(),
status=TASK_STATUS_WORKING,
createdAt=now,
lastUpdatedAt=now,
ttl=metadata.ttl,
pollInterval=500, # Default 500ms poll interval
)
@asynccontextmanager
async def task_execution(
task_id: str,
store: TaskStore,
) -> AsyncIterator[TaskContext]:
"""
Context manager for safe task execution (pure, no server dependencies).
Loads a task from the store and provides a TaskContext for the work.
If an unhandled exception occurs, the task is automatically marked as failed
and the exception is suppressed (since the failure is captured in task state).
This is useful for distributed workers that don't have a server session.
Args:
task_id: The task identifier to execute
store: The task store (must be accessible by the worker)
Yields:
TaskContext for updating status and completing/failing the task
Raises:
ValueError: If the task is not found in the store
Example (distributed worker):
async def worker_process(task_id: str):
store = RedisTaskStore(redis_url)
async with task_execution(task_id, store) as ctx:
await ctx.update_status("Working...")
result = await do_work()
await ctx.complete(result)
"""
task = await store.get_task(task_id)
if task is None:
raise ValueError(f"Task {task_id} not found")
ctx = TaskContext(task, store)
try:
yield ctx
except Exception as e:
# Auto-fail the task if an exception occurs and task isn't already terminal
# Exception is suppressed since failure is captured in task state
if not is_terminal(ctx.task.status):
await ctx.fail(str(e))
# Don't re-raise - the failure is recorded in task state
@@ -0,0 +1,219 @@
"""
In-memory implementation of TaskStore for demonstration purposes.
This implementation stores all tasks in memory and provides automatic cleanup
based on the TTL duration specified in the task metadata using lazy expiration.
Note: This is not suitable for production use as all data is lost on restart.
For production, consider implementing TaskStore with a database or distributed cache.
"""
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
import anyio
from mcp.shared.experimental.tasks.helpers import create_task_state, is_terminal
from mcp.shared.experimental.tasks.store import TaskStore
from mcp.types import Result, Task, TaskMetadata, TaskStatus
@dataclass
class StoredTask:
"""Internal storage representation of a task."""
task: Task
result: Result | None = None
# Time when this task should be removed (None = never)
expires_at: datetime | None = field(default=None)
class InMemoryTaskStore(TaskStore):
"""
A simple in-memory implementation of TaskStore.
Features:
- Automatic TTL-based cleanup (lazy expiration)
- Thread-safe for single-process async use
- Pagination support for list_tasks
Limitations:
- All data lost on restart
- Not suitable for distributed systems
- No persistence
For production, implement TaskStore with Redis, PostgreSQL, etc.
"""
def __init__(self, page_size: int = 10) -> None:
self._tasks: dict[str, StoredTask] = {}
self._page_size = page_size
self._update_events: dict[str, anyio.Event] = {}
def _calculate_expiry(self, ttl_ms: int | None) -> datetime | None:
"""Calculate expiry time from TTL in milliseconds."""
if ttl_ms is None:
return None
return datetime.now(timezone.utc) + timedelta(milliseconds=ttl_ms)
def _is_expired(self, stored: StoredTask) -> bool:
"""Check if a task has expired."""
if stored.expires_at is None:
return False
return datetime.now(timezone.utc) >= stored.expires_at
def _cleanup_expired(self) -> None:
"""Remove all expired tasks. Called lazily during access operations."""
expired_ids = [task_id for task_id, stored in self._tasks.items() if self._is_expired(stored)]
for task_id in expired_ids:
del self._tasks[task_id]
async def create_task(
self,
metadata: TaskMetadata,
task_id: str | None = None,
) -> Task:
"""Create a new task with the given metadata."""
# Cleanup expired tasks on access
self._cleanup_expired()
task = create_task_state(metadata, task_id)
if task.taskId in self._tasks:
raise ValueError(f"Task with ID {task.taskId} already exists")
stored = StoredTask(
task=task,
expires_at=self._calculate_expiry(metadata.ttl),
)
self._tasks[task.taskId] = stored
# Return a copy to prevent external modification
return Task(**task.model_dump())
async def get_task(self, task_id: str) -> Task | None:
"""Get a task by ID."""
# Cleanup expired tasks on access
self._cleanup_expired()
stored = self._tasks.get(task_id)
if stored is None:
return None
# Return a copy to prevent external modification
return Task(**stored.task.model_dump())
async def update_task(
self,
task_id: str,
status: TaskStatus | None = None,
status_message: str | None = None,
) -> Task:
"""Update a task's status and/or message."""
stored = self._tasks.get(task_id)
if stored is None:
raise ValueError(f"Task with ID {task_id} not found")
# Per spec: Terminal states MUST NOT transition to any other status
if status is not None and status != stored.task.status and is_terminal(stored.task.status):
raise ValueError(f"Cannot transition from terminal status '{stored.task.status}'")
status_changed = False
if status is not None and stored.task.status != status:
stored.task.status = status
status_changed = True
if status_message is not None:
stored.task.statusMessage = status_message
# Update lastUpdatedAt on any change
stored.task.lastUpdatedAt = datetime.now(timezone.utc)
# If task is now terminal and has TTL, reset expiry timer
if status is not None and is_terminal(status) and stored.task.ttl is not None:
stored.expires_at = self._calculate_expiry(stored.task.ttl)
# Notify waiters if status changed
if status_changed:
await self.notify_update(task_id)
return Task(**stored.task.model_dump())
async def store_result(self, task_id: str, result: Result) -> None:
"""Store the result for a task."""
stored = self._tasks.get(task_id)
if stored is None:
raise ValueError(f"Task with ID {task_id} not found")
stored.result = result
async def get_result(self, task_id: str) -> Result | None:
"""Get the stored result for a task."""
stored = self._tasks.get(task_id)
if stored is None:
return None
return stored.result
async def list_tasks(
self,
cursor: str | None = None,
) -> tuple[list[Task], str | None]:
"""List tasks with pagination."""
# Cleanup expired tasks on access
self._cleanup_expired()
all_task_ids = list(self._tasks.keys())
start_index = 0
if cursor is not None:
try:
cursor_index = all_task_ids.index(cursor)
start_index = cursor_index + 1
except ValueError:
raise ValueError(f"Invalid cursor: {cursor}")
page_task_ids = all_task_ids[start_index : start_index + self._page_size]
tasks = [Task(**self._tasks[tid].task.model_dump()) for tid in page_task_ids]
# Determine next cursor
next_cursor = None
if start_index + self._page_size < len(all_task_ids) and page_task_ids:
next_cursor = page_task_ids[-1]
return tasks, next_cursor
async def delete_task(self, task_id: str) -> bool:
"""Delete a task."""
if task_id not in self._tasks:
return False
del self._tasks[task_id]
return True
async def wait_for_update(self, task_id: str) -> None:
"""Wait until the task status changes."""
if task_id not in self._tasks:
raise ValueError(f"Task with ID {task_id} not found")
# Create a fresh event for waiting (anyio.Event can't be cleared)
self._update_events[task_id] = anyio.Event()
event = self._update_events[task_id]
await event.wait()
async def notify_update(self, task_id: str) -> None:
"""Signal that a task has been updated."""
if task_id in self._update_events:
self._update_events[task_id].set()
# --- Testing/debugging helpers ---
def cleanup(self) -> None:
"""Cleanup all tasks (useful for testing or graceful shutdown)."""
self._tasks.clear()
self._update_events.clear()
def get_all_tasks(self) -> list[Task]:
"""Get all tasks (useful for debugging). Returns copies to prevent modification."""
self._cleanup_expired()
return [Task(**stored.task.model_dump()) for stored in self._tasks.values()]
@@ -0,0 +1,241 @@
"""
TaskMessageQueue - FIFO queue for task-related messages.
This implements the core message queue pattern from the MCP Tasks spec.
When a handler needs to send a request (like elicitation) during a task-augmented
request, the message is enqueued instead of sent directly. Messages are delivered
to the client only through the `tasks/result` endpoint.
This pattern enables:
1. Decoupling request handling from message delivery
2. Proper bidirectional communication via the tasks/result stream
3. Automatic status management (working <-> input_required)
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Literal
import anyio
from mcp.shared.experimental.tasks.resolver import Resolver
from mcp.types import JSONRPCNotification, JSONRPCRequest, RequestId
@dataclass
class QueuedMessage:
"""
A message queued for delivery via tasks/result.
Messages are stored with their type and a resolver for requests
that expect responses.
"""
type: Literal["request", "notification"]
"""Whether this is a request (expects response) or notification (one-way)."""
message: JSONRPCRequest | JSONRPCNotification
"""The JSON-RPC message to send."""
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
"""When the message was enqueued."""
resolver: Resolver[dict[str, Any]] | None = None
"""Resolver to set when response arrives (only for requests)."""
original_request_id: RequestId | None = None
"""The original request ID used internally, for routing responses back."""
class TaskMessageQueue(ABC):
"""
Abstract interface for task message queuing.
This is a FIFO queue that stores messages to be delivered via `tasks/result`.
When a task-augmented handler calls elicit() or sends a notification, the
message is enqueued here instead of being sent directly to the client.
The `tasks/result` handler then dequeues and sends these messages through
the transport, with `relatedRequestId` set to the tasks/result request ID
so responses are routed correctly.
Implementations can use in-memory storage, Redis, etc.
"""
@abstractmethod
async def enqueue(self, task_id: str, message: QueuedMessage) -> None:
"""
Add a message to the queue for a task.
Args:
task_id: The task identifier
message: The message to enqueue
"""
@abstractmethod
async def dequeue(self, task_id: str) -> QueuedMessage | None:
"""
Remove and return the next message from the queue.
Args:
task_id: The task identifier
Returns:
The next message, or None if queue is empty
"""
@abstractmethod
async def peek(self, task_id: str) -> QueuedMessage | None:
"""
Return the next message without removing it.
Args:
task_id: The task identifier
Returns:
The next message, or None if queue is empty
"""
@abstractmethod
async def is_empty(self, task_id: str) -> bool:
"""
Check if the queue is empty for a task.
Args:
task_id: The task identifier
Returns:
True if no messages are queued
"""
@abstractmethod
async def clear(self, task_id: str) -> list[QueuedMessage]:
"""
Remove and return all messages from the queue.
This is useful for cleanup when a task is cancelled or completed.
Args:
task_id: The task identifier
Returns:
All queued messages (may be empty)
"""
@abstractmethod
async def wait_for_message(self, task_id: str) -> None:
"""
Wait until a message is available in the queue.
This blocks until either:
1. A message is enqueued for this task
2. The wait is cancelled
Args:
task_id: The task identifier
"""
@abstractmethod
async def notify_message_available(self, task_id: str) -> None:
"""
Signal that a message is available for a task.
This wakes up any coroutines waiting in wait_for_message().
Args:
task_id: The task identifier
"""
class InMemoryTaskMessageQueue(TaskMessageQueue):
"""
In-memory implementation of TaskMessageQueue.
This is suitable for single-process servers. For distributed systems,
implement TaskMessageQueue with Redis, RabbitMQ, etc.
Features:
- FIFO ordering per task
- Async wait for message availability
- Thread-safe for single-process async use
"""
def __init__(self) -> None:
self._queues: dict[str, list[QueuedMessage]] = {}
self._events: dict[str, anyio.Event] = {}
def _get_queue(self, task_id: str) -> list[QueuedMessage]:
"""Get or create the queue for a task."""
if task_id not in self._queues:
self._queues[task_id] = []
return self._queues[task_id]
async def enqueue(self, task_id: str, message: QueuedMessage) -> None:
"""Add a message to the queue."""
queue = self._get_queue(task_id)
queue.append(message)
# Signal that a message is available
await self.notify_message_available(task_id)
async def dequeue(self, task_id: str) -> QueuedMessage | None:
"""Remove and return the next message."""
queue = self._get_queue(task_id)
if not queue:
return None
return queue.pop(0)
async def peek(self, task_id: str) -> QueuedMessage | None:
"""Return the next message without removing it."""
queue = self._get_queue(task_id)
if not queue:
return None
return queue[0]
async def is_empty(self, task_id: str) -> bool:
"""Check if the queue is empty."""
queue = self._get_queue(task_id)
return len(queue) == 0
async def clear(self, task_id: str) -> list[QueuedMessage]:
"""Remove and return all messages."""
queue = self._get_queue(task_id)
messages = list(queue)
queue.clear()
return messages
async def wait_for_message(self, task_id: str) -> None:
"""Wait until a message is available."""
# Check if there are already messages
if not await self.is_empty(task_id):
return
# Create a fresh event for waiting (anyio.Event can't be cleared)
self._events[task_id] = anyio.Event()
event = self._events[task_id]
# Double-check after creating event (avoid race condition)
if not await self.is_empty(task_id):
return
# Wait for a new message
await event.wait()
async def notify_message_available(self, task_id: str) -> None:
"""Signal that a message is available."""
if task_id in self._events:
self._events[task_id].set()
def cleanup(self, task_id: str | None = None) -> None:
"""
Clean up queues and events.
Args:
task_id: If provided, clean up only this task. Otherwise clean up all.
"""
if task_id is not None:
self._queues.pop(task_id, None)
self._events.pop(task_id, None)
else:
self._queues.clear()
self._events.clear()
@@ -0,0 +1,45 @@
"""
Shared polling utilities for task operations.
This module provides generic polling logic that works for both client→server
and server→client task polling.
WARNING: These APIs are experimental and may change without notice.
"""
from collections.abc import AsyncIterator, Awaitable, Callable
import anyio
from mcp.shared.experimental.tasks.helpers import is_terminal
from mcp.types import GetTaskResult
async def poll_until_terminal(
get_task: Callable[[str], Awaitable[GetTaskResult]],
task_id: str,
default_interval_ms: int = 500,
) -> AsyncIterator[GetTaskResult]:
"""
Poll a task until it reaches terminal status.
This is a generic utility that works for both client→server and server→client
polling. The caller provides the get_task function appropriate for their direction.
Args:
get_task: Async function that takes task_id and returns GetTaskResult
task_id: The task to poll
default_interval_ms: Fallback poll interval if server doesn't specify
Yields:
GetTaskResult for each poll
"""
while True:
status = await get_task(task_id)
yield status
if is_terminal(status.status):
break
interval_ms = status.pollInterval if status.pollInterval is not None else default_interval_ms
await anyio.sleep(interval_ms / 1000)
@@ -0,0 +1,60 @@
"""
Resolver - An anyio-compatible future-like object for async result passing.
This provides a simple way to pass a result (or exception) from one coroutine
to another without depending on asyncio.Future.
"""
from typing import Generic, TypeVar, cast
import anyio
T = TypeVar("T")
class Resolver(Generic[T]):
"""
A simple resolver for passing results between coroutines.
Unlike asyncio.Future, this works with any anyio-compatible async backend.
Usage:
resolver: Resolver[str] = Resolver()
# In one coroutine:
resolver.set_result("hello")
# In another coroutine:
result = await resolver.wait() # returns "hello"
"""
def __init__(self) -> None:
self._event = anyio.Event()
self._value: T | None = None
self._exception: BaseException | None = None
def set_result(self, value: T) -> None:
"""Set the result value and wake up waiters."""
if self._event.is_set():
raise RuntimeError("Resolver already completed")
self._value = value
self._event.set()
def set_exception(self, exc: BaseException) -> None:
"""Set an exception and wake up waiters."""
if self._event.is_set():
raise RuntimeError("Resolver already completed")
self._exception = exc
self._event.set()
async def wait(self) -> T:
"""Wait for the result and return it, or raise the exception."""
await self._event.wait()
if self._exception is not None:
raise self._exception
# If we reach here, set_result() was called, so _value is set
return cast(T, self._value)
def done(self) -> bool:
"""Return True if the resolver has been completed."""
return self._event.is_set()
+156
View File
@@ -0,0 +1,156 @@
"""
TaskStore - Abstract interface for task state storage.
"""
from abc import ABC, abstractmethod
from mcp.types import Result, Task, TaskMetadata, TaskStatus
class TaskStore(ABC):
"""
Abstract interface for task state storage.
This is a pure storage interface - it doesn't manage execution.
Implementations can use in-memory storage, databases, Redis, etc.
All methods are async to support various backends.
"""
@abstractmethod
async def create_task(
self,
metadata: TaskMetadata,
task_id: str | None = None,
) -> Task:
"""
Create a new task.
Args:
metadata: Task metadata (ttl, etc.)
task_id: Optional task ID. If None, implementation should generate one.
Returns:
The created Task with status="working"
Raises:
ValueError: If task_id already exists
"""
@abstractmethod
async def get_task(self, task_id: str) -> Task | None:
"""
Get a task by ID.
Args:
task_id: The task identifier
Returns:
The Task, or None if not found
"""
@abstractmethod
async def update_task(
self,
task_id: str,
status: TaskStatus | None = None,
status_message: str | None = None,
) -> Task:
"""
Update a task's status and/or message.
Args:
task_id: The task identifier
status: New status (if changing)
status_message: New status message (if changing)
Returns:
The updated Task
Raises:
ValueError: If task not found
ValueError: If attempting to transition from a terminal status
(completed, failed, cancelled). Per spec, terminal states
MUST NOT transition to any other status.
"""
@abstractmethod
async def store_result(self, task_id: str, result: Result) -> None:
"""
Store the result for a task.
Args:
task_id: The task identifier
result: The result to store
Raises:
ValueError: If task not found
"""
@abstractmethod
async def get_result(self, task_id: str) -> Result | None:
"""
Get the stored result for a task.
Args:
task_id: The task identifier
Returns:
The stored Result, or None if not available
"""
@abstractmethod
async def list_tasks(
self,
cursor: str | None = None,
) -> tuple[list[Task], str | None]:
"""
List tasks with pagination.
Args:
cursor: Optional cursor for pagination
Returns:
Tuple of (tasks, next_cursor). next_cursor is None if no more pages.
"""
@abstractmethod
async def delete_task(self, task_id: str) -> bool:
"""
Delete a task.
Args:
task_id: The task identifier
Returns:
True if deleted, False if not found
"""
@abstractmethod
async def wait_for_update(self, task_id: str) -> None:
"""
Wait until the task status changes.
This blocks until either:
1. The task status changes
2. The wait is cancelled
Used by tasks/result to wait for task completion or status changes.
Args:
task_id: The task identifier
Raises:
ValueError: If task not found
"""
@abstractmethod
async def notify_update(self, task_id: str) -> None:
"""
Signal that a task has been updated.
This wakes up any coroutines waiting in wait_for_update().
Args:
task_id: The task identifier
"""
+63
View File
@@ -0,0 +1,63 @@
"""
ResponseRouter - Protocol for pluggable response routing.
This module defines a protocol for routing JSON-RPC responses to alternative
handlers before falling back to the default response stream mechanism.
The primary use case is task-augmented requests: when a TaskSession enqueues
a request (like elicitation), the response needs to be routed back to the
waiting resolver instead of the normal response stream.
Design:
- Protocol-based for testability and flexibility
- Returns bool to indicate if response was handled
- Supports both success responses and errors
"""
from typing import Any, Protocol
from mcp.types import ErrorData, RequestId
class ResponseRouter(Protocol):
"""
Protocol for routing responses to alternative handlers.
Implementations check if they have a pending request for the given ID
and deliver the response/error to the appropriate handler.
Example:
class TaskResultHandler(ResponseRouter):
def route_response(self, request_id, response):
resolver = self._pending_requests.pop(request_id, None)
if resolver:
resolver.set_result(response)
return True
return False
"""
def route_response(self, request_id: RequestId, response: dict[str, Any]) -> bool:
"""
Try to route a response to a pending request handler.
Args:
request_id: The JSON-RPC request ID from the response
response: The response result data
Returns:
True if the response was handled, False otherwise
"""
... # pragma: no cover
def route_error(self, request_id: RequestId, error: ErrorData) -> bool:
"""
Try to route an error to a pending request handler.
Args:
request_id: The JSON-RPC request ID from the error response
error: The error data
Returns:
True if the error was handled, False otherwise
"""
... # pragma: no cover
+57 -7
View File
@@ -13,6 +13,7 @@ from typing_extensions import Self
from mcp.shared.exceptions import McpError
from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage
from mcp.shared.response_router import ResponseRouter
from mcp.types import (
CONNECTION_CLOSED,
INVALID_PARAMS,
@@ -179,6 +180,7 @@ class BaseSession(
_request_id: int
_in_flight: dict[RequestId, RequestResponder[ReceiveRequestT, SendResultT]]
_progress_callbacks: dict[RequestId, ProgressFnT]
_response_routers: list["ResponseRouter"]
def __init__(
self,
@@ -198,8 +200,24 @@ class BaseSession(
self._session_read_timeout_seconds = read_timeout_seconds
self._in_flight = {}
self._progress_callbacks = {}
self._response_routers = []
self._exit_stack = AsyncExitStack()
def add_response_router(self, router: ResponseRouter) -> None:
"""
Register a response router to handle responses for non-standard requests.
Response routers are checked in order before falling back to the default
response stream mechanism. This is used by TaskResultHandler to route
responses for queued task requests back to their resolvers.
WARNING: This is an experimental API that may change without notice.
Args:
router: A ResponseRouter implementation
"""
self._response_routers.append(router)
async def __aenter__(self) -> Self:
self._task_group = anyio.create_task_group()
await self._task_group.__aenter__()
@@ -413,13 +431,7 @@ class BaseSession(
f"Failed to validate notification: {e}. Message was: {message.message.root}"
)
else: # Response or error
stream = self._response_streams.pop(message.message.root.id, None)
if stream: # pragma: no cover
await stream.send(message.message.root)
else: # pragma: no cover
await self._handle_incoming(
RuntimeError(f"Received response with an unknown request ID: {message}")
)
await self._handle_response(message)
except anyio.ClosedResourceError:
# This is expected when the client disconnects abruptly.
@@ -443,6 +455,44 @@ class BaseSession(
pass
self._response_streams.clear()
async def _handle_response(self, message: SessionMessage) -> None:
"""
Handle an incoming response or error message.
Checks response routers first (e.g., for task-related responses),
then falls back to the normal response stream mechanism.
"""
root = message.message.root
# This check is always true at runtime: the caller (_receive_loop) only invokes
# this method in the else branch after checking for JSONRPCRequest and
# JSONRPCNotification. However, the type checker can't infer this from the
# method signature, so we need this guard for type narrowing.
if not isinstance(root, JSONRPCResponse | JSONRPCError):
return # pragma: no cover
response_id: RequestId = root.id
# First, check response routers (e.g., TaskResultHandler)
if isinstance(root, JSONRPCError):
# Route error to routers
for router in self._response_routers:
if router.route_error(response_id, root.error):
return # Handled
else:
# Route success response to routers
response_data: dict[str, Any] = root.result or {}
for router in self._response_routers:
if router.route_response(response_id, response_data):
return # Handled
# Fall back to normal response streams
stream = self._response_streams.pop(response_id, None)
if stream: # pragma: no cover
await stream.send(root)
else: # pragma: no cover
await self._handle_incoming(RuntimeError(f"Received response with an unknown request ID: {message}"))
async def _received_request(self, responder: RequestResponder[ReceiveRequestT, SendResultT]) -> None:
"""
Can be overridden by subclasses to handle a request without needing to
+400 -51
View File
@@ -1,5 +1,6 @@
from collections.abc import Callable
from typing import Annotated, Any, Generic, Literal, TypeAlias, TypeVar
from datetime import datetime
from typing import Annotated, Any, Final, Generic, Literal, TypeAlias, TypeVar
from pydantic import BaseModel, ConfigDict, Field, FileUrl, RootModel
from pydantic.networks import AnyUrl, UrlConstraints
@@ -39,6 +40,23 @@ Role = Literal["user", "assistant"]
RequestId = Annotated[int, Field(strict=True)] | str
AnyFunction: TypeAlias = Callable[..., Any]
TaskExecutionMode = Literal["forbidden", "optional", "required"]
TASK_FORBIDDEN: Final[Literal["forbidden"]] = "forbidden"
TASK_OPTIONAL: Final[Literal["optional"]] = "optional"
TASK_REQUIRED: Final[Literal["required"]] = "required"
class TaskMetadata(BaseModel):
"""
Metadata for augmenting a request with task execution.
Include this in the `task` field of the request parameters.
"""
model_config = ConfigDict(extra="allow")
ttl: Annotated[int, Field(strict=True)] | None = None
"""Requested duration in milliseconds to retain task from creation."""
class RequestParams(BaseModel):
class Meta(BaseModel):
@@ -52,6 +70,16 @@ class RequestParams(BaseModel):
model_config = ConfigDict(extra="allow")
task: TaskMetadata | None = None
"""
If specified, the caller is requesting task-augmented execution for this request.
The request will return a CreateTaskResult immediately, and the actual result can be
retrieved later via tasks/result.
Task augmentation is subject to capability negotiation - receivers MUST declare support
for task augmentation of specific request types in their capabilities.
"""
meta: Meta | None = Field(alias="_meta", default=None)
@@ -321,6 +349,71 @@ class SamplingCapability(BaseModel):
model_config = ConfigDict(extra="allow")
class TasksListCapability(BaseModel):
"""Capability for tasks listing operations."""
model_config = ConfigDict(extra="allow")
class TasksCancelCapability(BaseModel):
"""Capability for tasks cancel operations."""
model_config = ConfigDict(extra="allow")
class TasksCreateMessageCapability(BaseModel):
"""Capability for tasks create messages."""
model_config = ConfigDict(extra="allow")
class TasksSamplingCapability(BaseModel):
"""Capability for tasks sampling operations."""
model_config = ConfigDict(extra="allow")
createMessage: TasksCreateMessageCapability | None = None
class TasksCreateElicitationCapability(BaseModel):
"""Capability for tasks create elicitation operations."""
model_config = ConfigDict(extra="allow")
class TasksElicitationCapability(BaseModel):
"""Capability for tasks elicitation operations."""
model_config = ConfigDict(extra="allow")
create: TasksCreateElicitationCapability | None = None
class ClientTasksRequestsCapability(BaseModel):
"""Capability for tasks requests operations."""
model_config = ConfigDict(extra="allow")
sampling: TasksSamplingCapability | None = None
elicitation: TasksElicitationCapability | None = None
class ClientTasksCapability(BaseModel):
"""Capability for client tasks operations."""
model_config = ConfigDict(extra="allow")
list: TasksListCapability | None = None
"""Whether this client supports tasks/list."""
cancel: TasksCancelCapability | None = None
"""Whether this client supports tasks/cancel."""
requests: ClientTasksRequestsCapability | None = None
"""Specifies which request types can be augmented with tasks."""
class ClientCapabilities(BaseModel):
"""Capabilities a client may support."""
@@ -335,6 +428,9 @@ class ClientCapabilities(BaseModel):
"""Present if the client supports elicitation from the user."""
roots: RootsCapability | None = None
"""Present if the client supports listing roots."""
tasks: ClientTasksCapability | None = None
"""Present if the client supports task-augmented requests."""
model_config = ConfigDict(extra="allow")
@@ -376,6 +472,37 @@ class CompletionsCapability(BaseModel):
model_config = ConfigDict(extra="allow")
class TasksCallCapability(BaseModel):
"""Capability for tasks call operations."""
model_config = ConfigDict(extra="allow")
class TasksToolsCapability(BaseModel):
"""Capability for tasks tools operations."""
model_config = ConfigDict(extra="allow")
call: TasksCallCapability | None = None
class ServerTasksRequestsCapability(BaseModel):
"""Capability for tasks requests operations."""
model_config = ConfigDict(extra="allow")
tools: TasksToolsCapability | None = None
class ServerTasksCapability(BaseModel):
"""Capability for server tasks operations."""
model_config = ConfigDict(extra="allow")
list: TasksListCapability | None = None
cancel: TasksCancelCapability | None = None
requests: ServerTasksRequestsCapability | None = None
class ServerCapabilities(BaseModel):
"""Capabilities that a server may support."""
@@ -391,9 +518,156 @@ class ServerCapabilities(BaseModel):
"""Present if the server offers any tools to call."""
completions: CompletionsCapability | None = None
"""Present if the server offers autocompletion suggestions for prompts and resources."""
tasks: ServerTasksCapability | None = None
"""Present if the server supports task-augmented requests."""
model_config = ConfigDict(extra="allow")
TaskStatus = Literal["working", "input_required", "completed", "failed", "cancelled"]
# Task status constants
TASK_STATUS_WORKING: Final[Literal["working"]] = "working"
TASK_STATUS_INPUT_REQUIRED: Final[Literal["input_required"]] = "input_required"
TASK_STATUS_COMPLETED: Final[Literal["completed"]] = "completed"
TASK_STATUS_FAILED: Final[Literal["failed"]] = "failed"
TASK_STATUS_CANCELLED: Final[Literal["cancelled"]] = "cancelled"
class RelatedTaskMetadata(BaseModel):
"""
Metadata for associating messages with a task.
Include this in the `_meta` field under the key `io.modelcontextprotocol/related-task`.
"""
model_config = ConfigDict(extra="allow")
taskId: str
"""The task identifier this message is associated with."""
class Task(BaseModel):
"""Data associated with a task."""
model_config = ConfigDict(extra="allow")
taskId: str
"""The task identifier."""
status: TaskStatus
"""Current task state."""
statusMessage: str | None = None
"""
Optional human-readable message describing the current task state.
This can provide context for any status, including:
- Reasons for "cancelled" status
- Summaries for "completed" status
- Diagnostic information for "failed" status (e.g., error details, what went wrong)
"""
createdAt: datetime # Pydantic will enforce ISO 8601 and re-serialize as a string later
"""ISO 8601 timestamp when the task was created."""
lastUpdatedAt: datetime
"""ISO 8601 timestamp when the task was last updated."""
ttl: Annotated[int, Field(strict=True)] | None
"""Actual retention duration from creation in milliseconds, null for unlimited."""
pollInterval: Annotated[int, Field(strict=True)] | None = None
"""Suggested polling interval in milliseconds."""
class CreateTaskResult(Result):
"""A response to a task-augmented request."""
task: Task
class GetTaskRequestParams(RequestParams):
model_config = ConfigDict(extra="allow")
taskId: str
"""The task identifier to query."""
class GetTaskRequest(Request[GetTaskRequestParams, Literal["tasks/get"]]):
"""A request to retrieve the state of a task."""
method: Literal["tasks/get"] = "tasks/get"
params: GetTaskRequestParams
class GetTaskResult(Result, Task):
"""The response to a tasks/get request."""
class GetTaskPayloadRequestParams(RequestParams):
model_config = ConfigDict(extra="allow")
taskId: str
"""The task identifier to retrieve results for."""
class GetTaskPayloadRequest(Request[GetTaskPayloadRequestParams, Literal["tasks/result"]]):
"""A request to retrieve the result of a completed task."""
method: Literal["tasks/result"] = "tasks/result"
params: GetTaskPayloadRequestParams
class GetTaskPayloadResult(Result):
"""
The response to a tasks/result request.
The structure matches the result type of the original request.
For example, a tools/call task would return the CallToolResult structure.
"""
class CancelTaskRequestParams(RequestParams):
model_config = ConfigDict(extra="allow")
taskId: str
"""The task identifier to cancel."""
class CancelTaskRequest(Request[CancelTaskRequestParams, Literal["tasks/cancel"]]):
"""A request to cancel a task."""
method: Literal["tasks/cancel"] = "tasks/cancel"
params: CancelTaskRequestParams
class CancelTaskResult(Result, Task):
"""The response to a tasks/cancel request."""
class ListTasksRequest(PaginatedRequest[Literal["tasks/list"]]):
"""A request to retrieve a list of tasks."""
method: Literal["tasks/list"] = "tasks/list"
class ListTasksResult(PaginatedResult):
"""The response to a tasks/list request."""
tasks: list[Task]
class TaskStatusNotificationParams(NotificationParams, Task):
"""Parameters for a `notifications/tasks/status` notification."""
class TaskStatusNotification(Notification[TaskStatusNotificationParams, Literal["notifications/tasks/status"]]):
"""
An optional notification from the receiver to the requestor, informing them that a task's status has changed.
Receivers are not required to send these notifications
"""
method: Literal["notifications/tasks/status"] = "notifications/tasks/status"
params: TaskStatusNotificationParams
class InitializeRequestParams(RequestParams):
"""Parameters for the initialize request."""
@@ -1011,9 +1285,29 @@ class ToolAnnotations(BaseModel):
of a memory tool is not.
Default: true
"""
model_config = ConfigDict(extra="allow")
class ToolExecution(BaseModel):
"""Execution-related properties for a tool."""
model_config = ConfigDict(extra="allow")
taskSupport: TaskExecutionMode | None = None
"""
Indicates whether this tool supports task-augmented execution.
This allows clients to handle long-running operations through polling
the task system.
- "forbidden": Tool does not support task-augmented execution (default when absent)
- "optional": Tool may support task-augmented execution
- "required": Tool requires task-augmented execution
Default: "forbidden"
"""
class Tool(BaseMetadata):
"""Definition for a tool the client can call."""
@@ -1035,6 +1329,9 @@ class Tool(BaseMetadata):
See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields)
for notes on _meta usage.
"""
execution: ToolExecution | None = None
model_config = ConfigDict(extra="allow")
@@ -1419,10 +1716,17 @@ class RootsListChangedNotification(
class CancelledNotificationParams(NotificationParams):
"""Parameters for cancellation notifications."""
requestId: RequestId
"""The ID of the request to cancel."""
requestId: RequestId | None = None
"""
The ID of the request to cancel.
This MUST correspond to the ID of a request previously issued in the same direction.
This MUST be provided for cancelling non-task requests.
This MUST NOT be used for cancelling tasks (use the `tasks/cancel` request instead).
"""
reason: str | None = None
"""An optional string describing the reason for the cancellation."""
model_config = ConfigDict(extra="allow")
@@ -1462,29 +1766,41 @@ class ElicitCompleteNotification(
params: ElicitCompleteNotificationParams
class ClientRequest(
RootModel[
PingRequest
| InitializeRequest
| CompleteRequest
| SetLevelRequest
| GetPromptRequest
| ListPromptsRequest
| ListResourcesRequest
| ListResourceTemplatesRequest
| ReadResourceRequest
| SubscribeRequest
| UnsubscribeRequest
| CallToolRequest
| ListToolsRequest
]
):
ClientRequestType: TypeAlias = (
PingRequest
| InitializeRequest
| CompleteRequest
| SetLevelRequest
| GetPromptRequest
| ListPromptsRequest
| ListResourcesRequest
| ListResourceTemplatesRequest
| ReadResourceRequest
| SubscribeRequest
| UnsubscribeRequest
| CallToolRequest
| ListToolsRequest
| GetTaskRequest
| GetTaskPayloadRequest
| ListTasksRequest
| CancelTaskRequest
)
class ClientRequest(RootModel[ClientRequestType]):
pass
class ClientNotification(
RootModel[CancelledNotification | ProgressNotification | InitializedNotification | RootsListChangedNotification]
):
ClientNotificationType: TypeAlias = (
CancelledNotification
| ProgressNotification
| InitializedNotification
| RootsListChangedNotification
| TaskStatusNotification
)
class ClientNotification(RootModel[ClientNotificationType]):
pass
@@ -1585,41 +1901,74 @@ class ElicitationRequiredErrorData(BaseModel):
model_config = ConfigDict(extra="allow")
class ClientResult(RootModel[EmptyResult | CreateMessageResult | ListRootsResult | ElicitResult]):
ClientResultType: TypeAlias = (
EmptyResult
| CreateMessageResult
| ListRootsResult
| ElicitResult
| GetTaskResult
| GetTaskPayloadResult
| ListTasksResult
| CancelTaskResult
| CreateTaskResult
)
class ClientResult(RootModel[ClientResultType]):
pass
class ServerRequest(RootModel[PingRequest | CreateMessageRequest | ListRootsRequest | ElicitRequest]):
ServerRequestType: TypeAlias = (
PingRequest
| CreateMessageRequest
| ListRootsRequest
| ElicitRequest
| GetTaskRequest
| GetTaskPayloadRequest
| ListTasksRequest
| CancelTaskRequest
)
class ServerRequest(RootModel[ServerRequestType]):
pass
class ServerNotification(
RootModel[
CancelledNotification
| ProgressNotification
| LoggingMessageNotification
| ResourceUpdatedNotification
| ResourceListChangedNotification
| ToolListChangedNotification
| PromptListChangedNotification
| ElicitCompleteNotification
]
):
ServerNotificationType: TypeAlias = (
CancelledNotification
| ProgressNotification
| LoggingMessageNotification
| ResourceUpdatedNotification
| ResourceListChangedNotification
| ToolListChangedNotification
| PromptListChangedNotification
| ElicitCompleteNotification
| TaskStatusNotification
)
class ServerNotification(RootModel[ServerNotificationType]):
pass
class ServerResult(
RootModel[
EmptyResult
| InitializeResult
| CompleteResult
| GetPromptResult
| ListPromptsResult
| ListResourcesResult
| ListResourceTemplatesResult
| ReadResourceResult
| CallToolResult
| ListToolsResult
]
):
ServerResultType: TypeAlias = (
EmptyResult
| InitializeResult
| CompleteResult
| GetPromptResult
| ListPromptsResult
| ListResourcesResult
| ListResourceTemplatesResult
| ReadResourceResult
| CallToolResult
| ListToolsResult
| GetTaskResult
| GetTaskPayloadResult
| ListTasksResult
| CancelTaskResult
| CreateTaskResult
)
class ServerResult(RootModel[ServerResultType]):
pass
View File
+1
View File
@@ -0,0 +1 @@
"""Tests for MCP task support."""
@@ -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
@@ -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()
@@ -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"
@@ -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()
@@ -0,0 +1,183 @@
"""Tests for TaskContext and helper functions."""
import pytest
from mcp.shared.experimental.tasks.context import TaskContext
from mcp.shared.experimental.tasks.helpers import create_task_state, task_execution
from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore
from mcp.types import CallToolResult, TaskMetadata, TextContent
@pytest.mark.anyio
async def test_task_context_properties() -> None:
"""Test TaskContext basic properties."""
store = InMemoryTaskStore()
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
ctx = TaskContext(task, store)
assert ctx.task_id == task.taskId
assert ctx.task.taskId == task.taskId
assert ctx.task.status == "working"
assert ctx.is_cancelled is False
store.cleanup()
@pytest.mark.anyio
async def test_task_context_update_status() -> None:
"""Test TaskContext.update_status."""
store = InMemoryTaskStore()
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
ctx = TaskContext(task, store)
await ctx.update_status("Processing step 1...")
# Check status message was updated
updated = await store.get_task(task.taskId)
assert updated is not None
assert updated.statusMessage == "Processing step 1..."
store.cleanup()
@pytest.mark.anyio
async def test_task_context_complete() -> None:
"""Test TaskContext.complete."""
store = InMemoryTaskStore()
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
ctx = TaskContext(task, store)
result = CallToolResult(content=[TextContent(type="text", text="Done!")])
await ctx.complete(result)
# Check task status
updated = await store.get_task(task.taskId)
assert updated is not None
assert updated.status == "completed"
# Check result is stored
stored_result = await store.get_result(task.taskId)
assert stored_result is not None
store.cleanup()
@pytest.mark.anyio
async def test_task_context_fail() -> None:
"""Test TaskContext.fail."""
store = InMemoryTaskStore()
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
ctx = TaskContext(task, store)
await ctx.fail("Something went wrong!")
# Check task status
updated = await store.get_task(task.taskId)
assert updated is not None
assert updated.status == "failed"
assert updated.statusMessage == "Something went wrong!"
store.cleanup()
@pytest.mark.anyio
async def test_task_context_cancellation() -> None:
"""Test TaskContext cancellation request."""
store = InMemoryTaskStore()
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
ctx = TaskContext(task, store)
assert ctx.is_cancelled is False
ctx.request_cancellation()
assert ctx.is_cancelled is True
store.cleanup()
def test_create_task_state_generates_id() -> None:
"""create_task_state generates a unique task ID when none provided."""
task1 = create_task_state(TaskMetadata(ttl=60000))
task2 = create_task_state(TaskMetadata(ttl=60000))
assert task1.taskId != task2.taskId
def test_create_task_state_uses_provided_id() -> None:
"""create_task_state uses the provided task ID."""
task = create_task_state(TaskMetadata(ttl=60000), task_id="my-task-123")
assert task.taskId == "my-task-123"
def test_create_task_state_null_ttl() -> None:
"""create_task_state handles null TTL."""
task = create_task_state(TaskMetadata(ttl=None))
assert task.ttl is None
def test_create_task_state_has_created_at() -> None:
"""create_task_state sets createdAt timestamp."""
task = create_task_state(TaskMetadata(ttl=60000))
assert task.createdAt is not None
@pytest.mark.anyio
async def test_task_execution_provides_context() -> None:
"""task_execution provides a TaskContext for the task."""
store = InMemoryTaskStore()
await store.create_task(TaskMetadata(ttl=60000), task_id="exec-test-1")
async with task_execution("exec-test-1", store) as ctx:
assert ctx.task_id == "exec-test-1"
assert ctx.task.status == "working"
store.cleanup()
@pytest.mark.anyio
async def test_task_execution_auto_fails_on_exception() -> None:
"""task_execution automatically fails task on unhandled exception."""
store = InMemoryTaskStore()
await store.create_task(TaskMetadata(ttl=60000), task_id="exec-fail-1")
async with task_execution("exec-fail-1", store):
raise RuntimeError("Oops!")
# Task should be failed
failed_task = await store.get_task("exec-fail-1")
assert failed_task is not None
assert failed_task.status == "failed"
assert "Oops!" in (failed_task.statusMessage or "")
store.cleanup()
@pytest.mark.anyio
async def test_task_execution_doesnt_fail_if_already_terminal() -> None:
"""task_execution doesn't re-fail if task already terminal."""
store = InMemoryTaskStore()
await store.create_task(TaskMetadata(ttl=60000), task_id="exec-term-1")
async with task_execution("exec-term-1", store) as ctx:
# Complete the task first
await ctx.complete(CallToolResult(content=[TextContent(type="text", text="Done")]))
# Then raise - shouldn't change status
raise RuntimeError("This shouldn't matter")
# Task should remain completed
final_task = await store.get_task("exec-term-1")
assert final_task is not None
assert final_task.status == "completed"
store.cleanup()
@pytest.mark.anyio
async def test_task_execution_not_found() -> None:
"""task_execution raises ValueError for non-existent task."""
store = InMemoryTaskStore()
with pytest.raises(ValueError, match="not found"):
async with task_execution("nonexistent", store):
...
@@ -0,0 +1,357 @@
"""End-to-end integration tests for tasks functionality.
These tests demonstrate the full task lifecycle:
1. Client sends task-augmented request (tools/call with task metadata)
2. Server creates task and returns CreateTaskResult immediately
3. Background work executes (using task_execution context manager)
4. Client polls with tasks/get
5. Client retrieves result with tasks/result
"""
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 (
TASK_REQUIRED,
CallToolRequest,
CallToolRequestParams,
CallToolResult,
ClientRequest,
ClientResult,
CreateTaskResult,
GetTaskPayloadRequest,
GetTaskPayloadRequestParams,
GetTaskPayloadResult,
GetTaskRequest,
GetTaskRequestParams,
GetTaskResult,
ListTasksRequest,
ListTasksResult,
ServerNotification,
ServerRequest,
TaskMetadata,
TextContent,
Tool,
ToolExecution,
)
@dataclass
class AppContext:
"""Application context passed via lifespan_context."""
task_group: TaskGroup
store: InMemoryTaskStore
# Events to signal when tasks complete (for testing without sleeps)
task_done_events: dict[str, Event] = field(default_factory=lambda: {})
@pytest.mark.anyio
async def test_task_lifecycle_with_task_execution() -> None:
"""
Test the complete task lifecycle using the task_execution pattern.
This demonstrates the recommended way to implement task-augmented tools:
1. Create task in store
2. Spawn work using task_execution() context manager
3. Return CreateTaskResult immediately
4. Work executes in background, auto-fails on exception
"""
# Note: We bypass the normal lifespan mechanism and pass context directly to _handle_message
server: Server[AppContext, Any] = Server("test-tasks") # type: ignore[assignment]
store = InMemoryTaskStore()
@server.list_tools()
async def list_tools():
return [
Tool(
name="process_data",
description="Process data asynchronously",
inputSchema={
"type": "object",
"properties": {"input": {"type": "string"}},
},
execution=ToolExecution(taskSupport=TASK_REQUIRED),
)
]
@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 name == "process_data" and ctx.experimental.is_task:
# 1. Create task in store
task_metadata = ctx.experimental.task_metadata
assert task_metadata is not None
task = await app.store.create_task(task_metadata)
# 2. Create event to signal completion (for testing)
done_event = Event()
app.task_done_events[task.taskId] = done_event
# 3. Define work function using task_execution for safety
async def do_work():
async with task_execution(task.taskId, app.store) as task_ctx:
await task_ctx.update_status("Processing input...")
# Simulate work
input_value = arguments.get("input", "")
result_text = f"Processed: {input_value.upper()}"
await task_ctx.complete(CallToolResult(content=[TextContent(type="text", text=result_text)]))
# Signal completion
done_event.set()
# 4. Spawn work in task group (from lifespan_context)
app.task_group.start_soon(do_work)
# 5. Return CreateTaskResult immediately
return CreateTaskResult(task=task)
raise NotImplementedError
# Register task query handlers (delegate to store)
@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.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 as GetTaskPayloadResult (which accepts extra fields)
return GetTaskPayloadResult(**result.model_dump())
@server.experimental.list_tasks()
async def handle_list_tasks(request: ListTasksRequest) -> ListTasksResult:
raise NotImplementedError
# Set up client-server communication
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 cover
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:
# Create app context with task group and store
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()
# === Step 1: Send task-augmented tool call ===
create_result = await client_session.send_request(
ClientRequest(
CallToolRequest(
params=CallToolRequestParams(
name="process_data",
arguments={"input": "hello world"},
task=TaskMetadata(ttl=60000),
),
)
),
CreateTaskResult,
)
assert isinstance(create_result, CreateTaskResult)
assert create_result.task.status == "working"
task_id = create_result.task.taskId
# === Step 2: Wait for task to complete ===
await app_context.task_done_events[task_id].wait()
task_status = await client_session.send_request(
ClientRequest(GetTaskRequest(params=GetTaskRequestParams(taskId=task_id))),
GetTaskResult,
)
assert task_status.taskId == task_id
assert task_status.status == "completed"
# === Step 3: Retrieve the actual result ===
task_result = await client_session.send_request(
ClientRequest(GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=task_id))),
CallToolResult,
)
assert len(task_result.content) == 1
content = task_result.content[0]
assert isinstance(content, TextContent)
assert content.text == "Processed: HELLO WORLD"
tg.cancel_scope.cancel()
@pytest.mark.anyio
async def test_task_auto_fails_on_exception() -> None:
"""Test that task_execution automatically fails the task on unhandled exception."""
# Note: We bypass the normal lifespan mechanism and pass context directly to _handle_message
server: Server[AppContext, Any] = Server("test-tasks-failure") # type: ignore[assignment]
store = InMemoryTaskStore()
@server.list_tools()
async def list_tools():
return [
Tool(
name="failing_task",
description="A task that fails",
inputSchema={"type": "object", "properties": {}},
)
]
@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 name == "failing_task" and ctx.experimental.is_task:
task_metadata = ctx.experimental.task_metadata
assert task_metadata is not None
task = await app.store.create_task(task_metadata)
# Create event to signal completion (for testing)
done_event = Event()
app.task_done_events[task.taskId] = done_event
async def do_failing_work():
async with task_execution(task.taskId, app.store) as task_ctx:
await task_ctx.update_status("About to fail...")
raise RuntimeError("Something went wrong!")
# Note: complete() is never called, but task_execution
# will automatically call fail() due to the exception
# This line is reached because task_execution suppresses the exception
done_event.set()
app.task_group.start_soon(do_failing_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 cover
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()
# Send task request
create_result = await client_session.send_request(
ClientRequest(
CallToolRequest(
params=CallToolRequestParams(
name="failing_task",
arguments={},
task=TaskMetadata(ttl=60000),
),
)
),
CreateTaskResult,
)
task_id = create_result.task.taskId
# Wait for task to complete (even though it fails)
await app_context.task_done_events[task_id].wait()
# Check that task was auto-failed
task_status = await client_session.send_request(
ClientRequest(GetTaskRequest(params=GetTaskRequestParams(taskId=task_id))),
GetTaskResult,
)
assert task_status.status == "failed"
assert task_status.statusMessage == "Something went wrong!"
tg.cancel_scope.cancel()
@@ -0,0 +1,538 @@
"""
Tests for the simplified task API: enable_tasks() + run_task()
This tests the recommended user flow:
1. server.experimental.enable_tasks() - one-line setup
2. ctx.experimental.run_task(work) - spawns work, returns CreateTaskResult
3. work function uses ServerTaskContext for elicit/create_message
These are integration tests that verify the complete flow works end-to-end.
"""
from typing import Any
from unittest.mock import Mock
import anyio
import pytest
from anyio import Event
from mcp.client.session import ClientSession
from mcp.server import Server
from mcp.server.experimental.request_context import Experimental
from mcp.server.experimental.task_context import ServerTaskContext
from mcp.server.experimental.task_support import TaskSupport
from mcp.server.lowlevel import NotificationOptions
from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore
from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue
from mcp.shared.message import SessionMessage
from mcp.types import (
TASK_REQUIRED,
CallToolResult,
CancelTaskRequest,
CancelTaskResult,
CreateTaskResult,
GetTaskPayloadRequest,
GetTaskPayloadResult,
GetTaskRequest,
GetTaskResult,
ListTasksRequest,
ListTasksResult,
TextContent,
Tool,
ToolExecution,
)
@pytest.mark.anyio
async def test_run_task_basic_flow() -> None:
"""
Test the basic run_task flow without elicitation.
1. enable_tasks() sets up handlers
2. Client calls tool with task field
3. run_task() spawns work, returns CreateTaskResult
4. Work completes in background
5. Client polls and sees completed status
"""
server = Server("test-run-task")
# One-line setup
server.experimental.enable_tasks()
# Track when work completes and capture received meta
work_completed = Event()
received_meta: list[str | None] = [None]
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="simple_task",
description="A simple task",
inputSchema={"type": "object", "properties": {"input": {"type": "string"}}},
execution=ToolExecution(taskSupport=TASK_REQUIRED),
)
]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult | CreateTaskResult:
ctx = server.request_context
ctx.experimental.validate_task_mode(TASK_REQUIRED)
# Capture the meta from the request (if present)
if ctx.meta is not None and ctx.meta.model_extra: # pragma: no branch
received_meta[0] = ctx.meta.model_extra.get("custom_field")
async def work(task: ServerTaskContext) -> CallToolResult:
await task.update_status("Working...")
input_val = arguments.get("input", "default")
result = CallToolResult(content=[TextContent(type="text", text=f"Processed: {input_val}")])
work_completed.set()
return result
return await ctx.experimental.run_task(work)
# 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 run_server() -> None:
await server.run(
client_to_server_receive,
server_to_client_send,
server.create_initialization_options(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
)
async def run_client() -> None:
async with ClientSession(server_to_client_receive, client_to_server_send) as client_session:
# Initialize
await client_session.initialize()
# Call tool as task (with meta to test that code path)
result = await client_session.experimental.call_tool_as_task(
"simple_task",
{"input": "hello"},
meta={"custom_field": "test_value"},
)
# Should get CreateTaskResult
task_id = result.task.taskId
assert result.task.status == "working"
# Wait for work to complete
with anyio.fail_after(5):
await work_completed.wait()
# Poll until task status is completed
with anyio.fail_after(5):
while True:
task_status = await client_session.experimental.get_task(task_id)
if task_status.status == "completed": # pragma: no branch
break
async with anyio.create_task_group() as tg:
tg.start_soon(run_server)
tg.start_soon(run_client)
# Verify the meta was passed through correctly
assert received_meta[0] == "test_value"
@pytest.mark.anyio
async def test_run_task_auto_fails_on_exception() -> None:
"""
Test that run_task automatically fails the task when work raises.
"""
server = Server("test-run-task-fail")
server.experimental.enable_tasks()
work_failed = Event()
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="failing_task",
description="A task that fails",
inputSchema={"type": "object"},
execution=ToolExecution(taskSupport=TASK_REQUIRED),
)
]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult | CreateTaskResult:
ctx = server.request_context
ctx.experimental.validate_task_mode(TASK_REQUIRED)
async def work(task: ServerTaskContext) -> CallToolResult:
work_failed.set()
raise RuntimeError("Something went wrong!")
return await ctx.experimental.run_task(work)
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 run_server() -> None:
await server.run(
client_to_server_receive,
server_to_client_send,
server.create_initialization_options(),
)
async def run_client() -> None:
async with ClientSession(server_to_client_receive, client_to_server_send) as client_session:
await client_session.initialize()
result = await client_session.experimental.call_tool_as_task("failing_task", {})
task_id = result.task.taskId
# Wait for work to fail
with anyio.fail_after(5):
await work_failed.wait()
# Poll until task status is failed
with anyio.fail_after(5):
while True:
task_status = await client_session.experimental.get_task(task_id)
if task_status.status == "failed": # pragma: no branch
break
assert "Something went wrong" in (task_status.statusMessage or "")
async with anyio.create_task_group() as tg:
tg.start_soon(run_server)
tg.start_soon(run_client)
@pytest.mark.anyio
async def test_enable_tasks_auto_registers_handlers() -> None:
"""
Test that enable_tasks() auto-registers get_task, list_tasks, cancel_task handlers.
"""
server = Server("test-enable-tasks")
# Before enable_tasks, no task capabilities
caps_before = server.get_capabilities(NotificationOptions(), {})
assert caps_before.tasks is None
# Enable tasks
server.experimental.enable_tasks()
# After enable_tasks, should have task capabilities
caps_after = server.get_capabilities(NotificationOptions(), {})
assert caps_after.tasks is not None
assert caps_after.tasks.list is not None
assert caps_after.tasks.cancel is not None
@pytest.mark.anyio
async def test_enable_tasks_with_custom_store_and_queue() -> None:
"""Test that enable_tasks() uses provided store and queue instead of defaults."""
server = Server("test-custom-store-queue")
# Create custom store and queue
custom_store = InMemoryTaskStore()
custom_queue = InMemoryTaskMessageQueue()
# Enable tasks with custom implementations
task_support = server.experimental.enable_tasks(store=custom_store, queue=custom_queue)
# Verify our custom implementations are used
assert task_support.store is custom_store
assert task_support.queue is custom_queue
@pytest.mark.anyio
async def test_enable_tasks_skips_default_handlers_when_custom_registered() -> None:
"""Test that enable_tasks() doesn't override already-registered handlers."""
server = Server("test-custom-handlers")
# Register custom handlers BEFORE enable_tasks (never called, just for registration)
@server.experimental.get_task()
async def custom_get_task(req: GetTaskRequest) -> GetTaskResult:
raise NotImplementedError
@server.experimental.get_task_result()
async def custom_get_task_result(req: GetTaskPayloadRequest) -> GetTaskPayloadResult:
raise NotImplementedError
@server.experimental.list_tasks()
async def custom_list_tasks(req: ListTasksRequest) -> ListTasksResult:
raise NotImplementedError
@server.experimental.cancel_task()
async def custom_cancel_task(req: CancelTaskRequest) -> CancelTaskResult:
raise NotImplementedError
# Now enable tasks - should NOT override our custom handlers
server.experimental.enable_tasks()
# Verify our custom handlers are still registered (not replaced by defaults)
# The handlers dict should contain our custom handlers
assert GetTaskRequest in server.request_handlers
assert GetTaskPayloadRequest in server.request_handlers
assert ListTasksRequest in server.request_handlers
assert CancelTaskRequest in server.request_handlers
@pytest.mark.anyio
async def test_run_task_without_enable_tasks_raises() -> None:
"""Test that run_task raises when enable_tasks() wasn't called."""
experimental = Experimental(
task_metadata=None,
_client_capabilities=None,
_session=None,
_task_support=None, # Not enabled
)
async def work(task: ServerTaskContext) -> CallToolResult:
raise NotImplementedError
with pytest.raises(RuntimeError, match="Task support not enabled"):
await experimental.run_task(work)
@pytest.mark.anyio
async def test_task_support_task_group_before_run_raises() -> None:
"""Test that accessing task_group before run() raises RuntimeError."""
task_support = TaskSupport.in_memory()
with pytest.raises(RuntimeError, match="TaskSupport not running"):
_ = task_support.task_group
@pytest.mark.anyio
async def test_run_task_without_session_raises() -> None:
"""Test that run_task raises when session is not available."""
task_support = TaskSupport.in_memory()
experimental = Experimental(
task_metadata=None,
_client_capabilities=None,
_session=None, # No session
_task_support=task_support,
)
async def work(task: ServerTaskContext) -> CallToolResult:
raise NotImplementedError
with pytest.raises(RuntimeError, match="Session not available"):
await experimental.run_task(work)
@pytest.mark.anyio
async def test_run_task_without_task_metadata_raises() -> None:
"""Test that run_task raises when request is not task-augmented."""
task_support = TaskSupport.in_memory()
mock_session = Mock()
experimental = Experimental(
task_metadata=None, # Not a task-augmented request
_client_capabilities=None,
_session=mock_session,
_task_support=task_support,
)
async def work(task: ServerTaskContext) -> CallToolResult:
raise NotImplementedError
with pytest.raises(RuntimeError, match="Request is not task-augmented"):
await experimental.run_task(work)
@pytest.mark.anyio
async def test_run_task_with_model_immediate_response() -> None:
"""Test that run_task includes model_immediate_response in CreateTaskResult._meta."""
server = Server("test-run-task-immediate")
server.experimental.enable_tasks()
work_completed = Event()
immediate_response_text = "Processing your request..."
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="task_with_immediate",
description="A task with immediate response",
inputSchema={"type": "object"},
execution=ToolExecution(taskSupport=TASK_REQUIRED),
)
]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult | CreateTaskResult:
ctx = server.request_context
ctx.experimental.validate_task_mode(TASK_REQUIRED)
async def work(task: ServerTaskContext) -> CallToolResult:
work_completed.set()
return CallToolResult(content=[TextContent(type="text", text="Done")])
return await ctx.experimental.run_task(work, model_immediate_response=immediate_response_text)
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 run_server() -> None:
await server.run(
client_to_server_receive,
server_to_client_send,
server.create_initialization_options(),
)
async def run_client() -> None:
async with ClientSession(server_to_client_receive, client_to_server_send) as client_session:
await client_session.initialize()
result = await client_session.experimental.call_tool_as_task("task_with_immediate", {})
# Verify the immediate response is in _meta
assert result.meta is not None
assert "io.modelcontextprotocol/model-immediate-response" in result.meta
assert result.meta["io.modelcontextprotocol/model-immediate-response"] == immediate_response_text
with anyio.fail_after(5):
await work_completed.wait()
async with anyio.create_task_group() as tg:
tg.start_soon(run_server)
tg.start_soon(run_client)
@pytest.mark.anyio
async def test_run_task_doesnt_complete_if_already_terminal() -> None:
"""Test that run_task doesn't auto-complete if work manually completed the task."""
server = Server("test-already-complete")
server.experimental.enable_tasks()
work_completed = Event()
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="manual_complete_task",
description="A task that manually completes",
inputSchema={"type": "object"},
execution=ToolExecution(taskSupport=TASK_REQUIRED),
)
]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult | CreateTaskResult:
ctx = server.request_context
ctx.experimental.validate_task_mode(TASK_REQUIRED)
async def work(task: ServerTaskContext) -> CallToolResult:
# Manually complete the task before returning
manual_result = CallToolResult(content=[TextContent(type="text", text="Manually completed")])
await task.complete(manual_result, notify=False)
work_completed.set()
# Return a different result - but it should be ignored since task is already terminal
return CallToolResult(content=[TextContent(type="text", text="This should be ignored")])
return await ctx.experimental.run_task(work)
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 run_server() -> None:
await server.run(
client_to_server_receive,
server_to_client_send,
server.create_initialization_options(),
)
async def run_client() -> None:
async with ClientSession(server_to_client_receive, client_to_server_send) as client_session:
await client_session.initialize()
result = await client_session.experimental.call_tool_as_task("manual_complete_task", {})
task_id = result.task.taskId
with anyio.fail_after(5):
await work_completed.wait()
# Poll until task status is completed
with anyio.fail_after(5):
while True:
status = await client_session.experimental.get_task(task_id)
if status.status == "completed": # pragma: no branch
break
async with anyio.create_task_group() as tg:
tg.start_soon(run_server)
tg.start_soon(run_client)
@pytest.mark.anyio
async def test_run_task_doesnt_fail_if_already_terminal() -> None:
"""Test that run_task doesn't auto-fail if work manually failed/cancelled the task."""
server = Server("test-already-failed")
server.experimental.enable_tasks()
work_completed = Event()
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="manual_cancel_task",
description="A task that manually cancels then raises",
inputSchema={"type": "object"},
execution=ToolExecution(taskSupport=TASK_REQUIRED),
)
]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult | CreateTaskResult:
ctx = server.request_context
ctx.experimental.validate_task_mode(TASK_REQUIRED)
async def work(task: ServerTaskContext) -> CallToolResult:
# Manually fail the task first
await task.fail("Manually failed", notify=False)
work_completed.set()
# Then raise - but the auto-fail should be skipped since task is already terminal
raise RuntimeError("This error should not change status")
return await ctx.experimental.run_task(work)
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 run_server() -> None:
await server.run(
client_to_server_receive,
server_to_client_send,
server.create_initialization_options(),
)
async def run_client() -> None:
async with ClientSession(server_to_client_receive, client_to_server_send) as client_session:
await client_session.initialize()
result = await client_session.experimental.call_tool_as_task("manual_cancel_task", {})
task_id = result.task.taskId
with anyio.fail_after(5):
await work_completed.wait()
# Poll until task status is failed
with anyio.fail_after(5):
while True:
status = await client_session.experimental.get_task(task_id)
if status.status == "failed": # pragma: no branch
break
# Task should still be failed (from manual fail, not auto-fail from exception)
assert status.statusMessage == "Manually failed" # Not "This error should not change status"
async with anyio.create_task_group() as tg:
tg.start_soon(run_server)
tg.start_soon(run_client)
@@ -0,0 +1,965 @@
"""Tests for server-side task support (handlers, capabilities, integration)."""
from datetime import datetime, timezone
from typing import Any
import anyio
import pytest
from mcp.client.session import ClientSession
from mcp.server import Server
from mcp.server.lowlevel import NotificationOptions
from mcp.server.models import InitializationOptions
from mcp.server.session import ServerSession
from mcp.shared.exceptions import McpError
from mcp.shared.message import ServerMessageMetadata, SessionMessage
from mcp.shared.response_router import ResponseRouter
from mcp.shared.session import RequestResponder
from mcp.types import (
INVALID_REQUEST,
TASK_FORBIDDEN,
TASK_OPTIONAL,
TASK_REQUIRED,
CallToolRequest,
CallToolRequestParams,
CallToolResult,
CancelTaskRequest,
CancelTaskRequestParams,
CancelTaskResult,
ClientRequest,
ClientResult,
ErrorData,
GetTaskPayloadRequest,
GetTaskPayloadRequestParams,
GetTaskPayloadResult,
GetTaskRequest,
GetTaskRequestParams,
GetTaskResult,
JSONRPCError,
JSONRPCMessage,
JSONRPCNotification,
JSONRPCResponse,
ListTasksRequest,
ListTasksResult,
ListToolsRequest,
ListToolsResult,
SamplingMessage,
ServerCapabilities,
ServerNotification,
ServerRequest,
ServerResult,
Task,
TaskMetadata,
TextContent,
Tool,
ToolExecution,
)
@pytest.mark.anyio
async def test_list_tasks_handler() -> None:
"""Test that experimental list_tasks handler works."""
server = Server("test")
now = datetime.now(timezone.utc)
test_tasks = [
Task(
taskId="task-1",
status="working",
createdAt=now,
lastUpdatedAt=now,
ttl=60000,
pollInterval=1000,
),
Task(
taskId="task-2",
status="completed",
createdAt=now,
lastUpdatedAt=now,
ttl=60000,
pollInterval=1000,
),
]
@server.experimental.list_tasks()
async def handle_list_tasks(request: ListTasksRequest) -> ListTasksResult:
return ListTasksResult(tasks=test_tasks)
handler = server.request_handlers[ListTasksRequest]
request = ListTasksRequest(method="tasks/list")
result = await handler(request)
assert isinstance(result, ServerResult)
assert isinstance(result.root, ListTasksResult)
assert len(result.root.tasks) == 2
assert result.root.tasks[0].taskId == "task-1"
assert result.root.tasks[1].taskId == "task-2"
@pytest.mark.anyio
async def test_get_task_handler() -> None:
"""Test that experimental get_task handler works."""
server = Server("test")
@server.experimental.get_task()
async def handle_get_task(request: GetTaskRequest) -> GetTaskResult:
now = datetime.now(timezone.utc)
return GetTaskResult(
taskId=request.params.taskId,
status="working",
createdAt=now,
lastUpdatedAt=now,
ttl=60000,
pollInterval=1000,
)
handler = server.request_handlers[GetTaskRequest]
request = GetTaskRequest(
method="tasks/get",
params=GetTaskRequestParams(taskId="test-task-123"),
)
result = await handler(request)
assert isinstance(result, ServerResult)
assert isinstance(result.root, GetTaskResult)
assert result.root.taskId == "test-task-123"
assert result.root.status == "working"
@pytest.mark.anyio
async def test_get_task_result_handler() -> None:
"""Test that experimental get_task_result handler works."""
server = Server("test")
@server.experimental.get_task_result()
async def handle_get_task_result(request: GetTaskPayloadRequest) -> GetTaskPayloadResult:
return GetTaskPayloadResult()
handler = server.request_handlers[GetTaskPayloadRequest]
request = GetTaskPayloadRequest(
method="tasks/result",
params=GetTaskPayloadRequestParams(taskId="test-task-123"),
)
result = await handler(request)
assert isinstance(result, ServerResult)
assert isinstance(result.root, GetTaskPayloadResult)
@pytest.mark.anyio
async def test_cancel_task_handler() -> None:
"""Test that experimental cancel_task handler works."""
server = Server("test")
@server.experimental.cancel_task()
async def handle_cancel_task(request: CancelTaskRequest) -> CancelTaskResult:
now = datetime.now(timezone.utc)
return CancelTaskResult(
taskId=request.params.taskId,
status="cancelled",
createdAt=now,
lastUpdatedAt=now,
ttl=60000,
)
handler = server.request_handlers[CancelTaskRequest]
request = CancelTaskRequest(
method="tasks/cancel",
params=CancelTaskRequestParams(taskId="test-task-123"),
)
result = await handler(request)
assert isinstance(result, ServerResult)
assert isinstance(result.root, CancelTaskResult)
assert result.root.taskId == "test-task-123"
assert result.root.status == "cancelled"
@pytest.mark.anyio
async def test_server_capabilities_include_tasks() -> None:
"""Test that server capabilities include tasks when handlers are registered."""
server = Server("test")
@server.experimental.list_tasks()
async def handle_list_tasks(request: ListTasksRequest) -> ListTasksResult:
raise NotImplementedError
@server.experimental.cancel_task()
async def handle_cancel_task(request: CancelTaskRequest) -> CancelTaskResult:
raise NotImplementedError
capabilities = server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
)
assert capabilities.tasks is not None
assert capabilities.tasks.list is not None
assert capabilities.tasks.cancel is not None
assert capabilities.tasks.requests is not None
assert capabilities.tasks.requests.tools is not None
@pytest.mark.anyio
async def test_server_capabilities_partial_tasks() -> None:
"""Test capabilities with only some task handlers registered."""
server = Server("test")
@server.experimental.list_tasks()
async def handle_list_tasks(request: ListTasksRequest) -> ListTasksResult:
raise NotImplementedError
# Only list_tasks registered, not cancel_task
capabilities = server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
)
assert capabilities.tasks is not None
assert capabilities.tasks.list is not None
assert capabilities.tasks.cancel is None # Not registered
@pytest.mark.anyio
async def test_tool_with_task_execution_metadata() -> None:
"""Test that tools can declare task execution mode."""
server = Server("test")
@server.list_tools()
async def list_tools():
return [
Tool(
name="quick_tool",
description="Fast tool",
inputSchema={"type": "object", "properties": {}},
execution=ToolExecution(taskSupport=TASK_FORBIDDEN),
),
Tool(
name="long_tool",
description="Long running tool",
inputSchema={"type": "object", "properties": {}},
execution=ToolExecution(taskSupport=TASK_REQUIRED),
),
Tool(
name="flexible_tool",
description="Can be either",
inputSchema={"type": "object", "properties": {}},
execution=ToolExecution(taskSupport=TASK_OPTIONAL),
),
]
tools_handler = server.request_handlers[ListToolsRequest]
request = ListToolsRequest(method="tools/list")
result = await tools_handler(request)
assert isinstance(result, ServerResult)
assert isinstance(result.root, ListToolsResult)
tools = result.root.tools
assert tools[0].execution is not None
assert tools[0].execution.taskSupport == TASK_FORBIDDEN
assert tools[1].execution is not None
assert tools[1].execution.taskSupport == TASK_REQUIRED
assert tools[2].execution is not None
assert tools[2].execution.taskSupport == TASK_OPTIONAL
@pytest.mark.anyio
async def test_task_metadata_in_call_tool_request() -> None:
"""Test that task metadata is accessible via RequestContext when calling a tool."""
server = Server("test")
captured_task_metadata: TaskMetadata | None = None
@server.list_tools()
async def list_tools():
return [
Tool(
name="long_task",
description="A long running task",
inputSchema={"type": "object", "properties": {}},
execution=ToolExecution(taskSupport="optional"),
)
]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
nonlocal captured_task_metadata
ctx = server.request_context
captured_task_metadata = ctx.experimental.task_metadata
return [TextContent(type="text", text="done")]
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():
async with ServerSession(
client_to_server_receive,
server_to_client_send,
InitializationOptions(
server_name="test-server",
server_version="1.0.0",
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
),
) as server_session:
async with anyio.create_task_group() as tg:
async def handle_messages():
async for message in server_session.incoming_messages:
await server._handle_message(message, server_session, {}, False)
tg.start_soon(handle_messages)
await anyio.sleep_forever()
async with anyio.create_task_group() as tg:
tg.start_soon(run_server)
async with ClientSession(
server_to_client_receive,
client_to_server_send,
message_handler=message_handler,
) as client_session:
await client_session.initialize()
# Call tool with task metadata
await client_session.send_request(
ClientRequest(
CallToolRequest(
params=CallToolRequestParams(
name="long_task",
arguments={},
task=TaskMetadata(ttl=60000),
),
)
),
CallToolResult,
)
tg.cancel_scope.cancel()
assert captured_task_metadata is not None
assert captured_task_metadata.ttl == 60000
@pytest.mark.anyio
async def test_task_metadata_is_task_property() -> None:
"""Test that RequestContext.experimental.is_task works correctly."""
server = Server("test")
is_task_values: list[bool] = []
@server.list_tools()
async def list_tools():
return [
Tool(
name="test_tool",
description="Test tool",
inputSchema={"type": "object", "properties": {}},
)
]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
ctx = server.request_context
is_task_values.append(ctx.experimental.is_task)
return [TextContent(type="text", text="done")]
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():
async with ServerSession(
client_to_server_receive,
server_to_client_send,
InitializationOptions(
server_name="test-server",
server_version="1.0.0",
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
),
) as server_session:
async with anyio.create_task_group() as tg:
async def handle_messages():
async for message in server_session.incoming_messages:
await server._handle_message(message, server_session, {}, False)
tg.start_soon(handle_messages)
await anyio.sleep_forever()
async with anyio.create_task_group() as tg:
tg.start_soon(run_server)
async with ClientSession(
server_to_client_receive,
client_to_server_send,
message_handler=message_handler,
) as client_session:
await client_session.initialize()
# Call without task metadata
await client_session.send_request(
ClientRequest(
CallToolRequest(
params=CallToolRequestParams(name="test_tool", arguments={}),
)
),
CallToolResult,
)
# Call with task metadata
await client_session.send_request(
ClientRequest(
CallToolRequest(
params=CallToolRequestParams(
name="test_tool",
arguments={},
task=TaskMetadata(ttl=60000),
),
)
),
CallToolResult,
)
tg.cancel_scope.cancel()
assert len(is_task_values) == 2
assert is_task_values[0] is False # First call without task
assert is_task_values[1] is True # Second call with task
@pytest.mark.anyio
async def test_update_capabilities_no_handlers() -> None:
"""Test that update_capabilities returns early when no task handlers are registered."""
server = Server("test-no-handlers")
# Access experimental to initialize it, but don't register any task handlers
_ = server.experimental
caps = server.get_capabilities(NotificationOptions(), {})
# Without any task handlers registered, tasks capability should be None
assert caps.tasks is None
@pytest.mark.anyio
async def test_default_task_handlers_via_enable_tasks() -> None:
"""Test that enable_tasks() auto-registers working default handlers.
This exercises the default handlers in lowlevel/experimental.py:
- _default_get_task (task not found)
- _default_get_task_result
- _default_list_tasks
- _default_cancel_task
"""
server = Server("test-default-handlers")
# Enable tasks with default handlers (no custom handlers registered)
task_support = server.experimental.enable_tasks()
store = task_support.store
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10)
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10)
async def message_handler(
message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception,
) -> None: ... # pragma: no branch
async def run_server() -> None:
async with task_support.run():
async with ServerSession(
client_to_server_receive,
server_to_client_send,
InitializationOptions(
server_name="test-server",
server_version="1.0.0",
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
),
) as server_session:
task_support.configure_session(server_session)
async for message in server_session.incoming_messages:
await server._handle_message(message, server_session, {}, False)
async with anyio.create_task_group() as tg:
tg.start_soon(run_server)
async with ClientSession(
server_to_client_receive,
client_to_server_send,
message_handler=message_handler,
) as client_session:
await client_session.initialize()
# Create a task directly in the store for testing
task = await store.create_task(TaskMetadata(ttl=60000))
# Test list_tasks (default handler)
list_result = await client_session.send_request(
ClientRequest(ListTasksRequest()),
ListTasksResult,
)
assert len(list_result.tasks) == 1
assert list_result.tasks[0].taskId == task.taskId
# Test get_task (default handler - found)
get_result = await client_session.send_request(
ClientRequest(GetTaskRequest(params=GetTaskRequestParams(taskId=task.taskId))),
GetTaskResult,
)
assert get_result.taskId == task.taskId
assert get_result.status == "working"
# Test get_task (default handler - not found path)
with pytest.raises(McpError, match="not found"):
await client_session.send_request(
ClientRequest(GetTaskRequest(params=GetTaskRequestParams(taskId="nonexistent-task"))),
GetTaskResult,
)
# Create a completed task to test get_task_result
completed_task = await store.create_task(TaskMetadata(ttl=60000))
await store.store_result(
completed_task.taskId, CallToolResult(content=[TextContent(type="text", text="Test result")])
)
await store.update_task(completed_task.taskId, status="completed")
# Test get_task_result (default handler)
payload_result = await client_session.send_request(
ClientRequest(GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=completed_task.taskId))),
GetTaskPayloadResult,
)
# The result should have the related-task metadata
assert payload_result.meta is not None
assert "io.modelcontextprotocol/related-task" in payload_result.meta
# Test cancel_task (default handler)
cancel_result = await client_session.send_request(
ClientRequest(CancelTaskRequest(params=CancelTaskRequestParams(taskId=task.taskId))),
CancelTaskResult,
)
assert cancel_result.taskId == task.taskId
assert cancel_result.status == "cancelled"
tg.cancel_scope.cancel()
@pytest.mark.anyio
async def test_build_elicit_form_request() -> None:
"""Test that _build_elicit_form_request builds a proper elicitation request."""
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10)
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10)
try:
async with ServerSession(
client_to_server_receive,
server_to_client_send,
InitializationOptions(
server_name="test-server",
server_version="1.0.0",
capabilities=ServerCapabilities(),
),
) as server_session:
# Test without task_id
request = server_session._build_elicit_form_request(
message="Test message",
requestedSchema={"type": "object", "properties": {"answer": {"type": "string"}}},
)
assert request.method == "elicitation/create"
assert request.params is not None
assert request.params["message"] == "Test message"
# Test with related_task_id (adds related-task metadata)
request_with_task = server_session._build_elicit_form_request(
message="Task message",
requestedSchema={"type": "object"},
related_task_id="test-task-123",
)
assert request_with_task.method == "elicitation/create"
assert request_with_task.params is not None
assert "_meta" in request_with_task.params
assert "io.modelcontextprotocol/related-task" in request_with_task.params["_meta"]
assert (
request_with_task.params["_meta"]["io.modelcontextprotocol/related-task"]["taskId"] == "test-task-123"
)
finally: # pragma: no cover
await server_to_client_send.aclose()
await server_to_client_receive.aclose()
await client_to_server_send.aclose()
await client_to_server_receive.aclose()
@pytest.mark.anyio
async def test_build_elicit_url_request() -> None:
"""Test that _build_elicit_url_request builds a proper URL mode elicitation request."""
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10)
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10)
try:
async with ServerSession(
client_to_server_receive,
server_to_client_send,
InitializationOptions(
server_name="test-server",
server_version="1.0.0",
capabilities=ServerCapabilities(),
),
) as server_session:
# Test without related_task_id
request = server_session._build_elicit_url_request(
message="Please authorize with GitHub",
url="https://github.com/login/oauth/authorize",
elicitation_id="oauth-123",
)
assert request.method == "elicitation/create"
assert request.params is not None
assert request.params["message"] == "Please authorize with GitHub"
assert request.params["url"] == "https://github.com/login/oauth/authorize"
assert request.params["elicitationId"] == "oauth-123"
assert request.params["mode"] == "url"
# Test with related_task_id (adds related-task metadata)
request_with_task = server_session._build_elicit_url_request(
message="OAuth required",
url="https://example.com/oauth",
elicitation_id="oauth-456",
related_task_id="test-task-789",
)
assert request_with_task.method == "elicitation/create"
assert request_with_task.params is not None
assert "_meta" in request_with_task.params
assert "io.modelcontextprotocol/related-task" in request_with_task.params["_meta"]
assert (
request_with_task.params["_meta"]["io.modelcontextprotocol/related-task"]["taskId"] == "test-task-789"
)
finally: # pragma: no cover
await server_to_client_send.aclose()
await server_to_client_receive.aclose()
await client_to_server_send.aclose()
await client_to_server_receive.aclose()
@pytest.mark.anyio
async def test_build_create_message_request() -> None:
"""Test that _build_create_message_request builds a proper sampling request."""
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10)
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10)
try:
async with ServerSession(
client_to_server_receive,
server_to_client_send,
InitializationOptions(
server_name="test-server",
server_version="1.0.0",
capabilities=ServerCapabilities(),
),
) as server_session:
messages = [
SamplingMessage(role="user", content=TextContent(type="text", text="Hello")),
]
# Test without task_id
request = server_session._build_create_message_request(
messages=messages,
max_tokens=100,
system_prompt="You are helpful",
)
assert request.method == "sampling/createMessage"
assert request.params is not None
assert request.params["maxTokens"] == 100
# Test with related_task_id (adds related-task metadata)
request_with_task = server_session._build_create_message_request(
messages=messages,
max_tokens=50,
related_task_id="sampling-task-456",
)
assert request_with_task.method == "sampling/createMessage"
assert request_with_task.params is not None
assert "_meta" in request_with_task.params
assert "io.modelcontextprotocol/related-task" in request_with_task.params["_meta"]
assert (
request_with_task.params["_meta"]["io.modelcontextprotocol/related-task"]["taskId"]
== "sampling-task-456"
)
finally: # pragma: no cover
await server_to_client_send.aclose()
await server_to_client_receive.aclose()
await client_to_server_send.aclose()
await client_to_server_receive.aclose()
@pytest.mark.anyio
async def test_send_message() -> None:
"""Test that send_message sends a raw session message."""
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10)
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10)
try:
async with ServerSession(
client_to_server_receive,
server_to_client_send,
InitializationOptions(
server_name="test-server",
server_version="1.0.0",
capabilities=ServerCapabilities(),
),
) as server_session:
# Create a test message
notification = JSONRPCNotification(jsonrpc="2.0", method="test/notification")
message = SessionMessage(
message=JSONRPCMessage(notification),
metadata=ServerMessageMetadata(related_request_id="test-req-1"),
)
# Send the message
await server_session.send_message(message)
# Verify it was sent to the stream
received = await server_to_client_receive.receive()
assert isinstance(received.message.root, JSONRPCNotification)
assert received.message.root.method == "test/notification"
finally: # pragma: no cover
await server_to_client_send.aclose()
await server_to_client_receive.aclose()
await client_to_server_send.aclose()
await client_to_server_receive.aclose()
@pytest.mark.anyio
async def test_response_routing_success() -> None:
"""Test that response routing works for success responses."""
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10)
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10)
# Track routed responses with event for synchronization
routed_responses: list[dict[str, Any]] = []
response_received = anyio.Event()
class TestRouter(ResponseRouter):
def route_response(self, request_id: str | int, response: dict[str, Any]) -> bool:
routed_responses.append({"id": request_id, "response": response})
response_received.set()
return True # Handled
def route_error(self, request_id: str | int, error: ErrorData) -> bool:
raise NotImplementedError
try:
async with ServerSession(
client_to_server_receive,
server_to_client_send,
InitializationOptions(
server_name="test-server",
server_version="1.0.0",
capabilities=ServerCapabilities(),
),
) as server_session:
router = TestRouter()
server_session.add_response_router(router)
# Simulate receiving a response from client
response = JSONRPCResponse(jsonrpc="2.0", id="test-req-1", result={"status": "ok"})
message = SessionMessage(message=JSONRPCMessage(response))
# Send from "client" side
await client_to_server_send.send(message)
# Wait for response to be routed
with anyio.fail_after(5):
await response_received.wait()
# Verify response was routed
assert len(routed_responses) == 1
assert routed_responses[0]["id"] == "test-req-1"
assert routed_responses[0]["response"]["status"] == "ok"
finally: # pragma: no cover
await server_to_client_send.aclose()
await server_to_client_receive.aclose()
await client_to_server_send.aclose()
await client_to_server_receive.aclose()
@pytest.mark.anyio
async def test_response_routing_error() -> None:
"""Test that error routing works for error responses."""
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10)
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10)
# Track routed errors with event for synchronization
routed_errors: list[dict[str, Any]] = []
error_received = anyio.Event()
class TestRouter(ResponseRouter):
def route_response(self, request_id: str | int, response: dict[str, Any]) -> bool:
raise NotImplementedError
def route_error(self, request_id: str | int, error: ErrorData) -> bool:
routed_errors.append({"id": request_id, "error": error})
error_received.set()
return True # Handled
try:
async with ServerSession(
client_to_server_receive,
server_to_client_send,
InitializationOptions(
server_name="test-server",
server_version="1.0.0",
capabilities=ServerCapabilities(),
),
) as server_session:
router = TestRouter()
server_session.add_response_router(router)
# Simulate receiving an error response from client
error_data = ErrorData(code=INVALID_REQUEST, message="Test error")
error_response = JSONRPCError(jsonrpc="2.0", id="test-req-2", error=error_data)
message = SessionMessage(message=JSONRPCMessage(error_response))
# Send from "client" side
await client_to_server_send.send(message)
# Wait for error to be routed
with anyio.fail_after(5):
await error_received.wait()
# Verify error was routed
assert len(routed_errors) == 1
assert routed_errors[0]["id"] == "test-req-2"
assert routed_errors[0]["error"].message == "Test error"
finally: # pragma: no cover
await server_to_client_send.aclose()
await server_to_client_receive.aclose()
await client_to_server_send.aclose()
await client_to_server_receive.aclose()
@pytest.mark.anyio
async def test_response_routing_skips_non_matching_routers() -> None:
"""Test that routing continues to next router when first doesn't match."""
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10)
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10)
# Track which routers were called
router_calls: list[str] = []
response_received = anyio.Event()
class NonMatchingRouter(ResponseRouter):
def route_response(self, request_id: str | int, response: dict[str, Any]) -> bool:
router_calls.append("non_matching_response")
return False # Doesn't handle it
def route_error(self, request_id: str | int, error: ErrorData) -> bool:
raise NotImplementedError
class MatchingRouter(ResponseRouter):
def route_response(self, request_id: str | int, response: dict[str, Any]) -> bool:
router_calls.append("matching_response")
response_received.set()
return True # Handles it
def route_error(self, request_id: str | int, error: ErrorData) -> bool:
raise NotImplementedError
try:
async with ServerSession(
client_to_server_receive,
server_to_client_send,
InitializationOptions(
server_name="test-server",
server_version="1.0.0",
capabilities=ServerCapabilities(),
),
) as server_session:
# Add non-matching router first, then matching router
server_session.add_response_router(NonMatchingRouter())
server_session.add_response_router(MatchingRouter())
# Send a response - should skip first router and be handled by second
response = JSONRPCResponse(jsonrpc="2.0", id="test-req-1", result={"status": "ok"})
message = SessionMessage(message=JSONRPCMessage(response))
await client_to_server_send.send(message)
with anyio.fail_after(5):
await response_received.wait()
# Verify both routers were called (first returned False, second returned True)
assert router_calls == ["non_matching_response", "matching_response"]
finally: # pragma: no cover
await server_to_client_send.aclose()
await server_to_client_receive.aclose()
await client_to_server_send.aclose()
await client_to_server_receive.aclose()
@pytest.mark.anyio
async def test_error_routing_skips_non_matching_routers() -> None:
"""Test that error routing continues to next router when first doesn't match."""
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10)
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10)
# Track which routers were called
router_calls: list[str] = []
error_received = anyio.Event()
class NonMatchingRouter(ResponseRouter):
def route_response(self, request_id: str | int, response: dict[str, Any]) -> bool:
raise NotImplementedError
def route_error(self, request_id: str | int, error: ErrorData) -> bool:
router_calls.append("non_matching_error")
return False # Doesn't handle it
class MatchingRouter(ResponseRouter):
def route_response(self, request_id: str | int, response: dict[str, Any]) -> bool:
raise NotImplementedError
def route_error(self, request_id: str | int, error: ErrorData) -> bool:
router_calls.append("matching_error")
error_received.set()
return True # Handles it
try:
async with ServerSession(
client_to_server_receive,
server_to_client_send,
InitializationOptions(
server_name="test-server",
server_version="1.0.0",
capabilities=ServerCapabilities(),
),
) as server_session:
# Add non-matching router first, then matching router
server_session.add_response_router(NonMatchingRouter())
server_session.add_response_router(MatchingRouter())
# Send an error - should skip first router and be handled by second
error_data = ErrorData(code=INVALID_REQUEST, message="Test error")
error_response = JSONRPCError(jsonrpc="2.0", id="test-req-2", error=error_data)
message = SessionMessage(message=JSONRPCMessage(error_response))
await client_to_server_send.send(message)
with anyio.fail_after(5):
await error_received.wait()
# Verify both routers were called (first returned False, second returned True)
assert router_calls == ["non_matching_error", "matching_error"]
finally: # pragma: no cover
await server_to_client_send.aclose()
await server_to_client_receive.aclose()
await client_to_server_send.aclose()
await client_to_server_receive.aclose()
@@ -0,0 +1,709 @@
"""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.taskId == "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?", requestedSchema={"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?", requestedSchema={"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?",
requestedSchema={"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.taskId)
# Verify task is in input_required status
updated_task = await store.get_task(task.taskId)
assert updated_task is not None
assert updated_task.status == "input_required"
# Dequeue and simulate response
msg = await queue.dequeue(task.taskId)
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.taskId)
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.taskId)
# Verify task is in input_required status
updated_task = await store.get_task(task.taskId)
assert updated_task is not None
assert updated_task.status == "input_required"
# Dequeue and simulate response
msg = await queue.dequeue(task.taskId)
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.taskId)
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.taskId)
# Verify task is in input_required status
updated_task = await store.get_task(task.taskId)
assert updated_task is not None
assert updated_task.status == "input_required"
# Dequeue and simulate response
msg = await queue.dequeue(task.taskId)
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.taskId)
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?",
requestedSchema={"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.taskId)
# Verify task is in input_required status
updated_task = await store.get_task(task.taskId)
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.taskId)
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.taskId)
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.taskId)
# Verify task is in input_required status
updated_task = await store.get_task(task.taskId)
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.taskId)
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.taskId)
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(
protocolVersion="2025-01-01",
capabilities=ClientCapabilities(
tasks=ClientTasksCapability(
requests=ClientTasksRequestsCapability(
elicitation=TasksElicitationCapability(create=TasksCreateElicitationCapability())
)
)
),
clientInfo=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?", requestedSchema={"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(
protocolVersion="2025-01-01",
capabilities=ClientCapabilities(
tasks=ClientTasksCapability(
requests=ClientTasksRequestsCapability(
sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability())
)
)
),
clientInfo=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()
@@ -0,0 +1,406 @@
"""Tests for InMemoryTaskStore."""
from collections.abc import AsyncIterator
from datetime import datetime, timedelta, timezone
import pytest
from mcp.shared.exceptions import McpError
from mcp.shared.experimental.tasks.helpers import cancel_task
from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore
from mcp.types import INVALID_PARAMS, CallToolResult, TaskMetadata, TextContent
@pytest.fixture
async def store() -> AsyncIterator[InMemoryTaskStore]:
"""Provide a clean InMemoryTaskStore for each test with automatic cleanup."""
store = InMemoryTaskStore()
yield store
store.cleanup()
@pytest.mark.anyio
async def test_create_and_get(store: InMemoryTaskStore) -> None:
"""Test InMemoryTaskStore create and get operations."""
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
assert task.taskId is not None
assert task.status == "working"
assert task.ttl == 60000
retrieved = await store.get_task(task.taskId)
assert retrieved is not None
assert retrieved.taskId == task.taskId
assert retrieved.status == "working"
@pytest.mark.anyio
async def test_create_with_custom_id(store: InMemoryTaskStore) -> None:
"""Test InMemoryTaskStore create with custom task ID."""
task = await store.create_task(
metadata=TaskMetadata(ttl=60000),
task_id="my-custom-id",
)
assert task.taskId == "my-custom-id"
assert task.status == "working"
retrieved = await store.get_task("my-custom-id")
assert retrieved is not None
assert retrieved.taskId == "my-custom-id"
@pytest.mark.anyio
async def test_create_duplicate_id_raises(store: InMemoryTaskStore) -> None:
"""Test that creating a task with duplicate ID raises."""
await store.create_task(metadata=TaskMetadata(ttl=60000), task_id="duplicate")
with pytest.raises(ValueError, match="already exists"):
await store.create_task(metadata=TaskMetadata(ttl=60000), task_id="duplicate")
@pytest.mark.anyio
async def test_get_nonexistent_returns_none(store: InMemoryTaskStore) -> None:
"""Test that getting a nonexistent task returns None."""
retrieved = await store.get_task("nonexistent")
assert retrieved is None
@pytest.mark.anyio
async def test_update_status(store: InMemoryTaskStore) -> None:
"""Test InMemoryTaskStore status updates."""
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
updated = await store.update_task(task.taskId, status="completed", status_message="All done!")
assert updated.status == "completed"
assert updated.statusMessage == "All done!"
retrieved = await store.get_task(task.taskId)
assert retrieved is not None
assert retrieved.status == "completed"
assert retrieved.statusMessage == "All done!"
@pytest.mark.anyio
async def test_update_nonexistent_raises(store: InMemoryTaskStore) -> None:
"""Test that updating a nonexistent task raises."""
with pytest.raises(ValueError, match="not found"):
await store.update_task("nonexistent", status="completed")
@pytest.mark.anyio
async def test_store_and_get_result(store: InMemoryTaskStore) -> None:
"""Test InMemoryTaskStore result storage and retrieval."""
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
# Store result
result = CallToolResult(content=[TextContent(type="text", text="Result data")])
await store.store_result(task.taskId, result)
# Retrieve result
retrieved_result = await store.get_result(task.taskId)
assert retrieved_result == result
@pytest.mark.anyio
async def test_get_result_nonexistent_returns_none(store: InMemoryTaskStore) -> None:
"""Test that getting result for nonexistent task returns None."""
result = await store.get_result("nonexistent")
assert result is None
@pytest.mark.anyio
async def test_get_result_no_result_returns_none(store: InMemoryTaskStore) -> None:
"""Test that getting result when none stored returns None."""
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
result = await store.get_result(task.taskId)
assert result is None
@pytest.mark.anyio
async def test_list_tasks(store: InMemoryTaskStore) -> None:
"""Test InMemoryTaskStore list operation."""
# Create multiple tasks
for _ in range(3):
await store.create_task(metadata=TaskMetadata(ttl=60000))
tasks, next_cursor = await store.list_tasks()
assert len(tasks) == 3
assert next_cursor is None # Less than page size
@pytest.mark.anyio
async def test_list_tasks_pagination() -> None:
"""Test InMemoryTaskStore pagination."""
# Needs custom page_size, can't use fixture
store = InMemoryTaskStore(page_size=2)
# Create 5 tasks
for _ in range(5):
await store.create_task(metadata=TaskMetadata(ttl=60000))
# First page
tasks, next_cursor = await store.list_tasks()
assert len(tasks) == 2
assert next_cursor is not None
# Second page
tasks, next_cursor = await store.list_tasks(cursor=next_cursor)
assert len(tasks) == 2
assert next_cursor is not None
# Third page (last)
tasks, next_cursor = await store.list_tasks(cursor=next_cursor)
assert len(tasks) == 1
assert next_cursor is None
store.cleanup()
@pytest.mark.anyio
async def test_list_tasks_invalid_cursor(store: InMemoryTaskStore) -> None:
"""Test that invalid cursor raises."""
await store.create_task(metadata=TaskMetadata(ttl=60000))
with pytest.raises(ValueError, match="Invalid cursor"):
await store.list_tasks(cursor="invalid-cursor")
@pytest.mark.anyio
async def test_delete_task(store: InMemoryTaskStore) -> None:
"""Test InMemoryTaskStore delete operation."""
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
deleted = await store.delete_task(task.taskId)
assert deleted is True
retrieved = await store.get_task(task.taskId)
assert retrieved is None
# Delete non-existent
deleted = await store.delete_task(task.taskId)
assert deleted is False
@pytest.mark.anyio
async def test_get_all_tasks_helper(store: InMemoryTaskStore) -> None:
"""Test the get_all_tasks debugging helper."""
await store.create_task(metadata=TaskMetadata(ttl=60000))
await store.create_task(metadata=TaskMetadata(ttl=60000))
all_tasks = store.get_all_tasks()
assert len(all_tasks) == 2
@pytest.mark.anyio
async def test_store_result_nonexistent_raises(store: InMemoryTaskStore) -> None:
"""Test that storing result for nonexistent task raises ValueError."""
result = CallToolResult(content=[TextContent(type="text", text="Result")])
with pytest.raises(ValueError, match="not found"):
await store.store_result("nonexistent-id", result)
@pytest.mark.anyio
async def test_create_task_with_null_ttl(store: InMemoryTaskStore) -> None:
"""Test creating task with null TTL (never expires)."""
task = await store.create_task(metadata=TaskMetadata(ttl=None))
assert task.ttl is None
# Task should persist (not expire)
retrieved = await store.get_task(task.taskId)
assert retrieved is not None
@pytest.mark.anyio
async def test_task_expiration_cleanup(store: InMemoryTaskStore) -> None:
"""Test that expired tasks are cleaned up lazily."""
# Create a task with very short TTL
task = await store.create_task(metadata=TaskMetadata(ttl=1)) # 1ms TTL
# Manually force the expiry to be in the past
stored = store._tasks.get(task.taskId)
assert stored is not None
stored.expires_at = datetime.now(timezone.utc) - timedelta(seconds=10)
# Task should still exist in internal dict but be expired
assert task.taskId in store._tasks
# Any access operation should clean up expired tasks
# list_tasks triggers cleanup
tasks, _ = await store.list_tasks()
# Expired task should be cleaned up
assert task.taskId not in store._tasks
assert len(tasks) == 0
@pytest.mark.anyio
async def test_task_with_null_ttl_never_expires(store: InMemoryTaskStore) -> None:
"""Test that tasks with null TTL never expire during cleanup."""
# Create task with null TTL
task = await store.create_task(metadata=TaskMetadata(ttl=None))
# Verify internal storage has no expiry
stored = store._tasks.get(task.taskId)
assert stored is not None
assert stored.expires_at is None
# Access operations should NOT remove this task
await store.list_tasks()
await store.get_task(task.taskId)
# Task should still exist
assert task.taskId in store._tasks
retrieved = await store.get_task(task.taskId)
assert retrieved is not None
@pytest.mark.anyio
async def test_terminal_task_ttl_reset(store: InMemoryTaskStore) -> None:
"""Test that TTL is reset when task enters terminal state."""
# Create task with short TTL
task = await store.create_task(metadata=TaskMetadata(ttl=60000)) # 60s
# Get the initial expiry
stored = store._tasks.get(task.taskId)
assert stored is not None
initial_expiry = stored.expires_at
assert initial_expiry is not None
# Update to terminal state (completed)
await store.update_task(task.taskId, status="completed")
# Expiry should be reset to a new time (from now + TTL)
new_expiry = stored.expires_at
assert new_expiry is not None
assert new_expiry >= initial_expiry
@pytest.mark.anyio
async def test_terminal_status_transition_rejected(store: InMemoryTaskStore) -> None:
"""Test that transitions from terminal states are rejected.
Per spec: Terminal states (completed, failed, cancelled) MUST NOT
transition to any other status.
"""
# Test each terminal status
for terminal_status in ("completed", "failed", "cancelled"):
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
# Move to terminal state
await store.update_task(task.taskId, status=terminal_status)
# Attempting to transition to any other status should raise
with pytest.raises(ValueError, match="Cannot transition from terminal status"):
await store.update_task(task.taskId, status="working")
# Also test transitioning to another terminal state
other_terminal = "failed" if terminal_status != "failed" else "completed"
with pytest.raises(ValueError, match="Cannot transition from terminal status"):
await store.update_task(task.taskId, status=other_terminal)
@pytest.mark.anyio
async def test_terminal_status_allows_same_status(store: InMemoryTaskStore) -> None:
"""Test that setting the same terminal status doesn't raise.
This is not a transition, so it should be allowed (no-op).
"""
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
await store.update_task(task.taskId, status="completed")
# Setting the same status should not raise
updated = await store.update_task(task.taskId, status="completed")
assert updated.status == "completed"
# Updating just the message should also work
updated = await store.update_task(task.taskId, status_message="Updated message")
assert updated.statusMessage == "Updated message"
@pytest.mark.anyio
async def test_wait_for_update_nonexistent_raises(store: InMemoryTaskStore) -> None:
"""Test that wait_for_update raises for nonexistent task."""
with pytest.raises(ValueError, match="not found"):
await store.wait_for_update("nonexistent-task-id")
@pytest.mark.anyio
async def test_cancel_task_succeeds_for_working_task(store: InMemoryTaskStore) -> None:
"""Test cancel_task helper succeeds for a working task."""
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
assert task.status == "working"
result = await cancel_task(store, task.taskId)
assert result.taskId == task.taskId
assert result.status == "cancelled"
# Verify store is updated
retrieved = await store.get_task(task.taskId)
assert retrieved is not None
assert retrieved.status == "cancelled"
@pytest.mark.anyio
async def test_cancel_task_rejects_nonexistent_task(store: InMemoryTaskStore) -> None:
"""Test cancel_task raises McpError with INVALID_PARAMS for nonexistent task."""
with pytest.raises(McpError) as exc_info:
await cancel_task(store, "nonexistent-task-id")
assert exc_info.value.error.code == INVALID_PARAMS
assert "not found" in exc_info.value.error.message
@pytest.mark.anyio
async def test_cancel_task_rejects_completed_task(store: InMemoryTaskStore) -> None:
"""Test cancel_task raises McpError with INVALID_PARAMS for completed task."""
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
await store.update_task(task.taskId, status="completed")
with pytest.raises(McpError) as exc_info:
await cancel_task(store, task.taskId)
assert exc_info.value.error.code == INVALID_PARAMS
assert "terminal state 'completed'" in exc_info.value.error.message
@pytest.mark.anyio
async def test_cancel_task_rejects_failed_task(store: InMemoryTaskStore) -> None:
"""Test cancel_task raises McpError with INVALID_PARAMS for failed task."""
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
await store.update_task(task.taskId, status="failed")
with pytest.raises(McpError) as exc_info:
await cancel_task(store, task.taskId)
assert exc_info.value.error.code == INVALID_PARAMS
assert "terminal state 'failed'" in exc_info.value.error.message
@pytest.mark.anyio
async def test_cancel_task_rejects_already_cancelled_task(store: InMemoryTaskStore) -> None:
"""Test cancel_task raises McpError with INVALID_PARAMS for already cancelled task."""
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
await store.update_task(task.taskId, status="cancelled")
with pytest.raises(McpError) as exc_info:
await cancel_task(store, task.taskId)
assert exc_info.value.error.code == INVALID_PARAMS
assert "terminal state 'cancelled'" in exc_info.value.error.message
@pytest.mark.anyio
async def test_cancel_task_succeeds_for_input_required_task(store: InMemoryTaskStore) -> None:
"""Test cancel_task helper succeeds for a task in input_required status."""
task = await store.create_task(metadata=TaskMetadata(ttl=60000))
await store.update_task(task.taskId, status="input_required")
result = await cancel_task(store, task.taskId)
assert result.taskId == task.taskId
assert result.status == "cancelled"
@@ -0,0 +1,354 @@
"""Tests for TaskResultHandler."""
from collections.abc import AsyncIterator
from typing import Any
from unittest.mock import AsyncMock, Mock
import anyio
import pytest
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, QueuedMessage
from mcp.shared.experimental.tasks.resolver import Resolver
from mcp.shared.message import SessionMessage
from mcp.types import (
INVALID_REQUEST,
CallToolResult,
ErrorData,
GetTaskPayloadRequest,
GetTaskPayloadRequestParams,
GetTaskPayloadResult,
JSONRPCRequest,
TaskMetadata,
TextContent,
)
@pytest.fixture
async def store() -> AsyncIterator[InMemoryTaskStore]:
"""Provide a clean store for each test."""
s = InMemoryTaskStore()
yield s
s.cleanup()
@pytest.fixture
def queue() -> InMemoryTaskMessageQueue:
"""Provide a clean queue for each test."""
return InMemoryTaskMessageQueue()
@pytest.fixture
def handler(store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue) -> TaskResultHandler:
"""Provide a handler for each test."""
return TaskResultHandler(store, queue)
@pytest.mark.anyio
async def test_handle_returns_result_for_completed_task(
store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler
) -> None:
"""Test that handle() returns the stored result for a completed task."""
task = await store.create_task(TaskMetadata(ttl=60000), task_id="test-task")
result = CallToolResult(content=[TextContent(type="text", text="Done!")])
await store.store_result(task.taskId, result)
await store.update_task(task.taskId, status="completed")
mock_session = Mock()
mock_session.send_message = AsyncMock()
request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=task.taskId))
response = await handler.handle(request, mock_session, "req-1")
assert response is not None
assert response.meta is not None
assert "io.modelcontextprotocol/related-task" in response.meta
@pytest.mark.anyio
async def test_handle_raises_for_nonexistent_task(
store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler
) -> None:
"""Test that handle() raises McpError for nonexistent task."""
mock_session = Mock()
request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId="nonexistent"))
with pytest.raises(McpError) as exc_info:
await handler.handle(request, mock_session, "req-1")
assert "not found" in exc_info.value.error.message
@pytest.mark.anyio
async def test_handle_returns_empty_result_when_no_result_stored(
store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler
) -> None:
"""Test that handle() returns minimal result when task completed without stored result."""
task = await store.create_task(TaskMetadata(ttl=60000), task_id="test-task")
await store.update_task(task.taskId, status="completed")
mock_session = Mock()
mock_session.send_message = AsyncMock()
request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=task.taskId))
response = await handler.handle(request, mock_session, "req-1")
assert response is not None
assert response.meta is not None
assert "io.modelcontextprotocol/related-task" in response.meta
@pytest.mark.anyio
async def test_handle_delivers_queued_messages(
store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler
) -> None:
"""Test that handle() delivers queued messages before returning."""
task = await store.create_task(TaskMetadata(ttl=60000), task_id="test-task")
queued_msg = QueuedMessage(
type="notification",
message=JSONRPCRequest(
jsonrpc="2.0",
id="notif-1",
method="test/notification",
params={},
),
)
await queue.enqueue(task.taskId, queued_msg)
await store.update_task(task.taskId, status="completed")
sent_messages: list[SessionMessage] = []
async def track_send(msg: SessionMessage) -> None:
sent_messages.append(msg)
mock_session = Mock()
mock_session.send_message = track_send
request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=task.taskId))
await handler.handle(request, mock_session, "req-1")
assert len(sent_messages) == 1
@pytest.mark.anyio
async def test_handle_waits_for_task_completion(
store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler
) -> None:
"""Test that handle() waits for task to complete before returning."""
task = await store.create_task(TaskMetadata(ttl=60000), task_id="test-task")
mock_session = Mock()
mock_session.send_message = AsyncMock()
request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=task.taskId))
result_holder: list[GetTaskPayloadResult | None] = [None]
async def run_handle() -> None:
result_holder[0] = await handler.handle(request, mock_session, "req-1")
async with anyio.create_task_group() as tg:
tg.start_soon(run_handle)
# Wait for handler to start waiting (event gets created when wait starts)
while task.taskId not in store._update_events:
await anyio.sleep(0)
await store.store_result(task.taskId, CallToolResult(content=[TextContent(type="text", text="Done")]))
await store.update_task(task.taskId, status="completed")
assert result_holder[0] is not None
@pytest.mark.anyio
async def test_route_response_resolves_pending_request(
store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler
) -> None:
"""Test that route_response() resolves a pending request."""
resolver: Resolver[dict[str, Any]] = Resolver()
handler._pending_requests["req-123"] = resolver
result = handler.route_response("req-123", {"status": "ok"})
assert result is True
assert resolver.done()
assert await resolver.wait() == {"status": "ok"}
@pytest.mark.anyio
async def test_route_response_returns_false_for_unknown_request(
store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler
) -> None:
"""Test that route_response() returns False for unknown request ID."""
result = handler.route_response("unknown-req", {"status": "ok"})
assert result is False
@pytest.mark.anyio
async def test_route_response_returns_false_for_already_done_resolver(
store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler
) -> None:
"""Test that route_response() returns False if resolver already completed."""
resolver: Resolver[dict[str, Any]] = Resolver()
resolver.set_result({"already": "done"})
handler._pending_requests["req-123"] = resolver
result = handler.route_response("req-123", {"new": "data"})
assert result is False
@pytest.mark.anyio
async def test_route_error_resolves_pending_request_with_exception(
store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler
) -> None:
"""Test that route_error() sets exception on pending request."""
resolver: Resolver[dict[str, Any]] = Resolver()
handler._pending_requests["req-123"] = resolver
error = ErrorData(code=INVALID_REQUEST, message="Something went wrong")
result = handler.route_error("req-123", error)
assert result is True
assert resolver.done()
with pytest.raises(McpError) as exc_info:
await resolver.wait()
assert exc_info.value.error.message == "Something went wrong"
@pytest.mark.anyio
async def test_route_error_returns_false_for_unknown_request(
store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler
) -> None:
"""Test that route_error() returns False for unknown request ID."""
error = ErrorData(code=INVALID_REQUEST, message="Error")
result = handler.route_error("unknown-req", error)
assert result is False
@pytest.mark.anyio
async def test_deliver_registers_resolver_for_request_messages(
store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler
) -> None:
"""Test that _deliver_queued_messages registers resolvers for request messages."""
task = await store.create_task(TaskMetadata(ttl=60000), task_id="test-task")
resolver: Resolver[dict[str, Any]] = Resolver()
queued_msg = QueuedMessage(
type="request",
message=JSONRPCRequest(
jsonrpc="2.0",
id="inner-req-1",
method="elicitation/create",
params={},
),
resolver=resolver,
original_request_id="inner-req-1",
)
await queue.enqueue(task.taskId, queued_msg)
mock_session = Mock()
mock_session.send_message = AsyncMock()
await handler._deliver_queued_messages(task.taskId, mock_session, "outer-req-1")
assert "inner-req-1" in handler._pending_requests
assert handler._pending_requests["inner-req-1"] is resolver
@pytest.mark.anyio
async def test_deliver_skips_resolver_registration_when_no_original_id(
store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler
) -> None:
"""Test that _deliver_queued_messages skips resolver registration when original_request_id is None."""
task = await store.create_task(TaskMetadata(ttl=60000), task_id="test-task")
resolver: Resolver[dict[str, Any]] = Resolver()
queued_msg = QueuedMessage(
type="request",
message=JSONRPCRequest(
jsonrpc="2.0",
id="inner-req-1",
method="elicitation/create",
params={},
),
resolver=resolver,
original_request_id=None, # No original request ID
)
await queue.enqueue(task.taskId, queued_msg)
mock_session = Mock()
mock_session.send_message = AsyncMock()
await handler._deliver_queued_messages(task.taskId, mock_session, "outer-req-1")
# Resolver should NOT be registered since original_request_id is None
assert len(handler._pending_requests) == 0
# But the message should still be sent
mock_session.send_message.assert_called_once()
@pytest.mark.anyio
async def test_wait_for_task_update_handles_store_exception(
store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler
) -> None:
"""Test that _wait_for_task_update handles store exception gracefully."""
task = await store.create_task(TaskMetadata(ttl=60000), task_id="test-task")
# Make wait_for_update raise an exception
async def failing_wait(task_id: str) -> None:
raise RuntimeError("Store error")
store.wait_for_update = failing_wait # type: ignore[method-assign]
# Queue a message to unblock the race via the queue path
async def enqueue_later() -> None:
# Wait for queue to start waiting (event gets created when wait starts)
while task.taskId not in queue._events:
await anyio.sleep(0)
await queue.enqueue(
task.taskId,
QueuedMessage(
type="notification",
message=JSONRPCRequest(
jsonrpc="2.0",
id="notif-1",
method="test/notification",
params={},
),
),
)
async with anyio.create_task_group() as tg:
tg.start_soon(enqueue_later)
# This should complete via the queue path even though store raises
await handler._wait_for_task_update(task.taskId)
@pytest.mark.anyio
async def test_wait_for_task_update_handles_queue_exception(
store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler
) -> None:
"""Test that _wait_for_task_update handles queue exception gracefully."""
task = await store.create_task(TaskMetadata(ttl=60000), task_id="test-task")
# Make wait_for_message raise an exception
async def failing_wait(task_id: str) -> None:
raise RuntimeError("Queue error")
queue.wait_for_message = failing_wait # type: ignore[method-assign]
# Update the store to unblock the race via the store path
async def update_later() -> None:
# Wait for store to start waiting (event gets created when wait starts)
while task.taskId not in store._update_events:
await anyio.sleep(0)
await store.update_task(task.taskId, status="completed")
async with anyio.create_task_group() as tg:
tg.start_soon(update_later)
# This should complete via the store path even though queue raises
await handler._wait_for_task_update(task.taskId)
@@ -0,0 +1,283 @@
"""Tests for tasks capability checking utilities."""
import pytest
from mcp.shared.exceptions import McpError
from mcp.shared.experimental.tasks.capabilities import (
check_tasks_capability,
has_task_augmented_elicitation,
has_task_augmented_sampling,
require_task_augmented_elicitation,
require_task_augmented_sampling,
)
from mcp.types import (
ClientCapabilities,
ClientTasksCapability,
ClientTasksRequestsCapability,
TasksCreateElicitationCapability,
TasksCreateMessageCapability,
TasksElicitationCapability,
TasksSamplingCapability,
)
class TestCheckTasksCapability:
"""Tests for check_tasks_capability function."""
def test_required_requests_none_returns_true(self) -> None:
"""When required.requests is None, should return True."""
required = ClientTasksCapability()
client = ClientTasksCapability()
assert check_tasks_capability(required, client) is True
def test_client_requests_none_returns_false(self) -> None:
"""When client.requests is None but required.requests is set, should return False."""
required = ClientTasksCapability(requests=ClientTasksRequestsCapability())
client = ClientTasksCapability()
assert check_tasks_capability(required, client) is False
def test_elicitation_required_but_client_missing(self) -> None:
"""When elicitation is required but client doesn't have it."""
required = ClientTasksCapability(
requests=ClientTasksRequestsCapability(elicitation=TasksElicitationCapability())
)
client = ClientTasksCapability(requests=ClientTasksRequestsCapability())
assert check_tasks_capability(required, client) is False
def test_elicitation_create_required_but_client_missing(self) -> None:
"""When elicitation.create is required but client doesn't have it."""
required = ClientTasksCapability(
requests=ClientTasksRequestsCapability(
elicitation=TasksElicitationCapability(create=TasksCreateElicitationCapability())
)
)
client = ClientTasksCapability(
requests=ClientTasksRequestsCapability(
elicitation=TasksElicitationCapability() # No create
)
)
assert check_tasks_capability(required, client) is False
def test_elicitation_create_present(self) -> None:
"""When elicitation.create is required and client has it."""
required = ClientTasksCapability(
requests=ClientTasksRequestsCapability(
elicitation=TasksElicitationCapability(create=TasksCreateElicitationCapability())
)
)
client = ClientTasksCapability(
requests=ClientTasksRequestsCapability(
elicitation=TasksElicitationCapability(create=TasksCreateElicitationCapability())
)
)
assert check_tasks_capability(required, client) is True
def test_sampling_required_but_client_missing(self) -> None:
"""When sampling is required but client doesn't have it."""
required = ClientTasksCapability(requests=ClientTasksRequestsCapability(sampling=TasksSamplingCapability()))
client = ClientTasksCapability(requests=ClientTasksRequestsCapability())
assert check_tasks_capability(required, client) is False
def test_sampling_create_message_required_but_client_missing(self) -> None:
"""When sampling.createMessage is required but client doesn't have it."""
required = ClientTasksCapability(
requests=ClientTasksRequestsCapability(
sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability())
)
)
client = ClientTasksCapability(
requests=ClientTasksRequestsCapability(
sampling=TasksSamplingCapability() # No createMessage
)
)
assert check_tasks_capability(required, client) is False
def test_sampling_create_message_present(self) -> None:
"""When sampling.createMessage is required and client has it."""
required = ClientTasksCapability(
requests=ClientTasksRequestsCapability(
sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability())
)
)
client = ClientTasksCapability(
requests=ClientTasksRequestsCapability(
sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability())
)
)
assert check_tasks_capability(required, client) is True
def test_both_elicitation_and_sampling_present(self) -> None:
"""When both elicitation.create and sampling.createMessage are required and client has both."""
required = ClientTasksCapability(
requests=ClientTasksRequestsCapability(
elicitation=TasksElicitationCapability(create=TasksCreateElicitationCapability()),
sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability()),
)
)
client = ClientTasksCapability(
requests=ClientTasksRequestsCapability(
elicitation=TasksElicitationCapability(create=TasksCreateElicitationCapability()),
sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability()),
)
)
assert check_tasks_capability(required, client) is True
def test_elicitation_without_create_required(self) -> None:
"""When elicitation is required but not create specifically."""
required = ClientTasksCapability(
requests=ClientTasksRequestsCapability(
elicitation=TasksElicitationCapability() # No create
)
)
client = ClientTasksCapability(
requests=ClientTasksRequestsCapability(
elicitation=TasksElicitationCapability(create=TasksCreateElicitationCapability())
)
)
assert check_tasks_capability(required, client) is True
def test_sampling_without_create_message_required(self) -> None:
"""When sampling is required but not createMessage specifically."""
required = ClientTasksCapability(
requests=ClientTasksRequestsCapability(
sampling=TasksSamplingCapability() # No createMessage
)
)
client = ClientTasksCapability(
requests=ClientTasksRequestsCapability(
sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability())
)
)
assert check_tasks_capability(required, client) is True
class TestHasTaskAugmentedElicitation:
"""Tests for has_task_augmented_elicitation function."""
def test_tasks_none(self) -> None:
"""Returns False when caps.tasks is None."""
caps = ClientCapabilities()
assert has_task_augmented_elicitation(caps) is False
def test_requests_none(self) -> None:
"""Returns False when caps.tasks.requests is None."""
caps = ClientCapabilities(tasks=ClientTasksCapability())
assert has_task_augmented_elicitation(caps) is False
def test_elicitation_none(self) -> None:
"""Returns False when caps.tasks.requests.elicitation is None."""
caps = ClientCapabilities(tasks=ClientTasksCapability(requests=ClientTasksRequestsCapability()))
assert has_task_augmented_elicitation(caps) is False
def test_create_none(self) -> None:
"""Returns False when caps.tasks.requests.elicitation.create is None."""
caps = ClientCapabilities(
tasks=ClientTasksCapability(
requests=ClientTasksRequestsCapability(elicitation=TasksElicitationCapability())
)
)
assert has_task_augmented_elicitation(caps) is False
def test_create_present(self) -> None:
"""Returns True when full capability path is present."""
caps = ClientCapabilities(
tasks=ClientTasksCapability(
requests=ClientTasksRequestsCapability(
elicitation=TasksElicitationCapability(create=TasksCreateElicitationCapability())
)
)
)
assert has_task_augmented_elicitation(caps) is True
class TestHasTaskAugmentedSampling:
"""Tests for has_task_augmented_sampling function."""
def test_tasks_none(self) -> None:
"""Returns False when caps.tasks is None."""
caps = ClientCapabilities()
assert has_task_augmented_sampling(caps) is False
def test_requests_none(self) -> None:
"""Returns False when caps.tasks.requests is None."""
caps = ClientCapabilities(tasks=ClientTasksCapability())
assert has_task_augmented_sampling(caps) is False
def test_sampling_none(self) -> None:
"""Returns False when caps.tasks.requests.sampling is None."""
caps = ClientCapabilities(tasks=ClientTasksCapability(requests=ClientTasksRequestsCapability()))
assert has_task_augmented_sampling(caps) is False
def test_create_message_none(self) -> None:
"""Returns False when caps.tasks.requests.sampling.createMessage is None."""
caps = ClientCapabilities(
tasks=ClientTasksCapability(requests=ClientTasksRequestsCapability(sampling=TasksSamplingCapability()))
)
assert has_task_augmented_sampling(caps) is False
def test_create_message_present(self) -> None:
"""Returns True when full capability path is present."""
caps = ClientCapabilities(
tasks=ClientTasksCapability(
requests=ClientTasksRequestsCapability(
sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability())
)
)
)
assert has_task_augmented_sampling(caps) is True
class TestRequireTaskAugmentedElicitation:
"""Tests for require_task_augmented_elicitation function."""
def test_raises_when_none(self) -> None:
"""Raises McpError when client_caps is None."""
with pytest.raises(McpError) as exc_info:
require_task_augmented_elicitation(None)
assert "task-augmented elicitation" in str(exc_info.value)
def test_raises_when_missing(self) -> None:
"""Raises McpError when capability is missing."""
caps = ClientCapabilities()
with pytest.raises(McpError) as exc_info:
require_task_augmented_elicitation(caps)
assert "task-augmented elicitation" in str(exc_info.value)
def test_passes_when_present(self) -> None:
"""Does not raise when capability is present."""
caps = ClientCapabilities(
tasks=ClientTasksCapability(
requests=ClientTasksRequestsCapability(
elicitation=TasksElicitationCapability(create=TasksCreateElicitationCapability())
)
)
)
require_task_augmented_elicitation(caps)
class TestRequireTaskAugmentedSampling:
"""Tests for require_task_augmented_sampling function."""
def test_raises_when_none(self) -> None:
"""Raises McpError when client_caps is None."""
with pytest.raises(McpError) as exc_info:
require_task_augmented_sampling(None)
assert "task-augmented sampling" in str(exc_info.value)
def test_raises_when_missing(self) -> None:
"""Raises McpError when capability is missing."""
caps = ClientCapabilities()
with pytest.raises(McpError) as exc_info:
require_task_augmented_sampling(caps)
assert "task-augmented sampling" in str(exc_info.value)
def test_passes_when_present(self) -> None:
"""Does not raise when capability is present."""
caps = ClientCapabilities(
tasks=ClientTasksCapability(
requests=ClientTasksRequestsCapability(
sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability())
)
)
)
require_task_augmented_sampling(caps)
@@ -0,0 +1,737 @@
"""
Tests for the four elicitation scenarios with tasks.
This tests all combinations of tool call types and elicitation types:
1. Normal tool call + Normal elicitation (session.elicit)
2. Normal tool call + Task-augmented elicitation (session.experimental.elicit_as_task)
3. Task-augmented tool call + Normal elicitation (task.elicit)
4. Task-augmented tool call + Task-augmented elicitation (task.elicit_as_task)
And the same for sampling (create_message).
"""
from typing import Any
import anyio
import pytest
from anyio import Event
from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers
from mcp.client.session import ClientSession
from mcp.server import Server
from mcp.server.experimental.task_context import ServerTaskContext
from mcp.server.lowlevel import NotificationOptions
from mcp.shared.context import RequestContext
from mcp.shared.experimental.tasks.helpers import is_terminal
from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore
from mcp.shared.message import SessionMessage
from mcp.types import (
TASK_REQUIRED,
CallToolResult,
CreateMessageRequestParams,
CreateMessageResult,
CreateTaskResult,
ElicitRequestParams,
ElicitResult,
ErrorData,
GetTaskPayloadResult,
GetTaskResult,
SamplingMessage,
TaskMetadata,
TextContent,
Tool,
ToolExecution,
)
def create_client_task_handlers(
client_task_store: InMemoryTaskStore,
elicit_received: Event,
) -> ExperimentalTaskHandlers:
"""Create task handlers for client to handle task-augmented elicitation from server."""
elicit_response = ElicitResult(action="accept", content={"confirm": True})
task_complete_events: dict[str, Event] = {}
async def handle_augmented_elicitation(
context: RequestContext[ClientSession, Any],
params: ElicitRequestParams,
task_metadata: TaskMetadata,
) -> CreateTaskResult:
"""Handle task-augmented elicitation by creating a client-side task."""
elicit_received.set()
task = await client_task_store.create_task(task_metadata)
task_complete_events[task.taskId] = Event()
async def complete_task() -> None:
# Store result before updating status to avoid race condition
await client_task_store.store_result(task.taskId, elicit_response)
await client_task_store.update_task(task.taskId, status="completed")
task_complete_events[task.taskId].set()
context.session._task_group.start_soon(complete_task) # pyright: ignore[reportPrivateUsage]
return CreateTaskResult(task=task)
async def handle_get_task(
context: RequestContext[ClientSession, Any],
params: Any,
) -> GetTaskResult:
"""Handle tasks/get from server."""
task = await client_task_store.get_task(params.taskId)
assert task is not None, f"Task not found: {params.taskId}"
return GetTaskResult(
taskId=task.taskId,
status=task.status,
statusMessage=task.statusMessage,
createdAt=task.createdAt,
lastUpdatedAt=task.lastUpdatedAt,
ttl=task.ttl,
pollInterval=100,
)
async def handle_get_task_result(
context: RequestContext[ClientSession, Any],
params: Any,
) -> GetTaskPayloadResult | ErrorData:
"""Handle tasks/result from server."""
event = task_complete_events.get(params.taskId)
assert event is not None, f"No completion event for task: {params.taskId}"
await event.wait()
result = await client_task_store.get_result(params.taskId)
assert result is not None, f"Result not found for task: {params.taskId}"
return GetTaskPayloadResult.model_validate(result.model_dump(by_alias=True))
return ExperimentalTaskHandlers(
augmented_elicitation=handle_augmented_elicitation,
get_task=handle_get_task,
get_task_result=handle_get_task_result,
)
def create_sampling_task_handlers(
client_task_store: InMemoryTaskStore,
sampling_received: Event,
) -> ExperimentalTaskHandlers:
"""Create task handlers for client to handle task-augmented sampling from server."""
sampling_response = CreateMessageResult(
role="assistant",
content=TextContent(type="text", text="Hello from the model!"),
model="test-model",
)
task_complete_events: dict[str, Event] = {}
async def handle_augmented_sampling(
context: RequestContext[ClientSession, Any],
params: CreateMessageRequestParams,
task_metadata: TaskMetadata,
) -> CreateTaskResult:
"""Handle task-augmented sampling by creating a client-side task."""
sampling_received.set()
task = await client_task_store.create_task(task_metadata)
task_complete_events[task.taskId] = Event()
async def complete_task() -> None:
# Store result before updating status to avoid race condition
await client_task_store.store_result(task.taskId, sampling_response)
await client_task_store.update_task(task.taskId, status="completed")
task_complete_events[task.taskId].set()
context.session._task_group.start_soon(complete_task) # pyright: ignore[reportPrivateUsage]
return CreateTaskResult(task=task)
async def handle_get_task(
context: RequestContext[ClientSession, Any],
params: Any,
) -> GetTaskResult:
"""Handle tasks/get from server."""
task = await client_task_store.get_task(params.taskId)
assert task is not None, f"Task not found: {params.taskId}"
return GetTaskResult(
taskId=task.taskId,
status=task.status,
statusMessage=task.statusMessage,
createdAt=task.createdAt,
lastUpdatedAt=task.lastUpdatedAt,
ttl=task.ttl,
pollInterval=100,
)
async def handle_get_task_result(
context: RequestContext[ClientSession, Any],
params: Any,
) -> GetTaskPayloadResult | ErrorData:
"""Handle tasks/result from server."""
event = task_complete_events.get(params.taskId)
assert event is not None, f"No completion event for task: {params.taskId}"
await event.wait()
result = await client_task_store.get_result(params.taskId)
assert result is not None, f"Result not found for task: {params.taskId}"
return GetTaskPayloadResult.model_validate(result.model_dump(by_alias=True))
return ExperimentalTaskHandlers(
augmented_sampling=handle_augmented_sampling,
get_task=handle_get_task,
get_task_result=handle_get_task_result,
)
@pytest.mark.anyio
async def test_scenario1_normal_tool_normal_elicitation() -> None:
"""
Scenario 1: Normal tool call with normal elicitation.
Server calls session.elicit() directly, client responds immediately.
"""
server = Server("test-scenario1")
elicit_received = Event()
tool_result: list[str] = []
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="confirm_action",
description="Confirm an action",
inputSchema={"type": "object"},
)
]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult:
ctx = server.request_context
# Normal elicitation - expects immediate response
result = await ctx.session.elicit(
message="Please confirm the action",
requestedSchema={"type": "object", "properties": {"confirm": {"type": "boolean"}}},
)
confirmed = result.content.get("confirm", False) if result.content else False
tool_result.append("confirmed" if confirmed else "cancelled")
return CallToolResult(content=[TextContent(type="text", text="confirmed" if confirmed else "cancelled")])
# Elicitation callback for client
async def elicitation_callback(
context: RequestContext[ClientSession, Any],
params: ElicitRequestParams,
) -> ElicitResult:
elicit_received.set()
return ElicitResult(action="accept", content={"confirm": True})
# 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 run_server() -> None:
await server.run(
client_to_server_receive,
server_to_client_send,
server.create_initialization_options(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
)
async def run_client() -> None:
async with ClientSession(
server_to_client_receive,
client_to_server_send,
elicitation_callback=elicitation_callback,
) as client_session:
await client_session.initialize()
# Call tool normally (not as task)
result = await client_session.call_tool("confirm_action", {})
# Verify elicitation was received and tool completed
assert elicit_received.is_set()
assert len(result.content) > 0
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "confirmed"
async with anyio.create_task_group() as tg:
tg.start_soon(run_server)
tg.start_soon(run_client)
assert tool_result[0] == "confirmed"
@pytest.mark.anyio
async def test_scenario2_normal_tool_task_augmented_elicitation() -> None:
"""
Scenario 2: Normal tool call with task-augmented elicitation.
Server calls session.experimental.elicit_as_task(), client creates a task
for the elicitation and returns CreateTaskResult. Server polls client.
"""
server = Server("test-scenario2")
elicit_received = Event()
tool_result: list[str] = []
# Client-side task store for handling task-augmented elicitation
client_task_store = InMemoryTaskStore()
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="confirm_action",
description="Confirm an action",
inputSchema={"type": "object"},
)
]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult:
ctx = server.request_context
# Task-augmented elicitation - server polls client
result = await ctx.session.experimental.elicit_as_task(
message="Please confirm the action",
requestedSchema={"type": "object", "properties": {"confirm": {"type": "boolean"}}},
ttl=60000,
)
confirmed = result.content.get("confirm", False) if result.content else False
tool_result.append("confirmed" if confirmed else "cancelled")
return CallToolResult(content=[TextContent(type="text", text="confirmed" if confirmed else "cancelled")])
task_handlers = create_client_task_handlers(client_task_store, elicit_received)
# 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 run_server() -> None:
await server.run(
client_to_server_receive,
server_to_client_send,
server.create_initialization_options(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
)
async def run_client() -> None:
async with ClientSession(
server_to_client_receive,
client_to_server_send,
experimental_task_handlers=task_handlers,
) as client_session:
await client_session.initialize()
# Call tool normally (not as task)
result = await client_session.call_tool("confirm_action", {})
# Verify elicitation was received and tool completed
assert elicit_received.is_set()
assert len(result.content) > 0
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "confirmed"
async with anyio.create_task_group() as tg:
tg.start_soon(run_server)
tg.start_soon(run_client)
assert tool_result[0] == "confirmed"
client_task_store.cleanup()
@pytest.mark.anyio
async def test_scenario3_task_augmented_tool_normal_elicitation() -> None:
"""
Scenario 3: Task-augmented tool call with normal elicitation.
Client calls tool as task. Inside the task, server uses task.elicit()
which queues the request and delivers via tasks/result.
"""
server = Server("test-scenario3")
server.experimental.enable_tasks()
elicit_received = Event()
work_completed = Event()
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="confirm_action",
description="Confirm an action",
inputSchema={"type": "object"},
execution=ToolExecution(taskSupport=TASK_REQUIRED),
)
]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CreateTaskResult:
ctx = server.request_context
ctx.experimental.validate_task_mode(TASK_REQUIRED)
async def work(task: ServerTaskContext) -> CallToolResult:
# Normal elicitation within task - queued and delivered via tasks/result
result = await task.elicit(
message="Please confirm the action",
requestedSchema={"type": "object", "properties": {"confirm": {"type": "boolean"}}},
)
confirmed = result.content.get("confirm", False) if result.content else False
work_completed.set()
return CallToolResult(content=[TextContent(type="text", text="confirmed" if confirmed else "cancelled")])
return await ctx.experimental.run_task(work)
# Elicitation callback for client
async def elicitation_callback(
context: RequestContext[ClientSession, Any],
params: ElicitRequestParams,
) -> ElicitResult:
elicit_received.set()
return ElicitResult(action="accept", content={"confirm": True})
# 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 run_server() -> None:
await server.run(
client_to_server_receive,
server_to_client_send,
server.create_initialization_options(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
)
async def run_client() -> None:
async with ClientSession(
server_to_client_receive,
client_to_server_send,
elicitation_callback=elicitation_callback,
) as client_session:
await client_session.initialize()
# Call tool as task
create_result = await client_session.experimental.call_tool_as_task("confirm_action", {})
task_id = create_result.task.taskId
assert create_result.task.status == "working"
# Poll until input_required, then call tasks/result
found_input_required = False
async for status in client_session.experimental.poll_task(task_id): # pragma: no branch
if status.status == "input_required": # pragma: no branch
found_input_required = True
break
assert found_input_required, "Expected to see input_required status"
# This will deliver the elicitation and get the response
final_result = await client_session.experimental.get_task_result(task_id, CallToolResult)
# Verify
assert elicit_received.is_set()
assert len(final_result.content) > 0
assert isinstance(final_result.content[0], TextContent)
assert final_result.content[0].text == "confirmed"
async with anyio.create_task_group() as tg:
tg.start_soon(run_server)
tg.start_soon(run_client)
assert work_completed.is_set()
@pytest.mark.anyio
async def test_scenario4_task_augmented_tool_task_augmented_elicitation() -> None:
"""
Scenario 4: Task-augmented tool call with task-augmented elicitation.
Client calls tool as task. Inside the task, server uses task.elicit_as_task()
which sends task-augmented elicitation. Client creates its own task for the
elicitation, and server polls the client.
This tests the full bidirectional flow where:
1. Client calls tasks/result on server (for tool task)
2. Server delivers task-augmented elicitation through that stream
3. Client creates its own task and returns CreateTaskResult
4. Server polls the client's task while the client's tasks/result is still open
5. Server gets the ElicitResult and completes the tool task
6. Client's tasks/result returns with the CallToolResult
"""
server = Server("test-scenario4")
server.experimental.enable_tasks()
elicit_received = Event()
work_completed = Event()
# Client-side task store for handling task-augmented elicitation
client_task_store = InMemoryTaskStore()
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="confirm_action",
description="Confirm an action",
inputSchema={"type": "object"},
execution=ToolExecution(taskSupport=TASK_REQUIRED),
)
]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CreateTaskResult:
ctx = server.request_context
ctx.experimental.validate_task_mode(TASK_REQUIRED)
async def work(task: ServerTaskContext) -> CallToolResult:
# Task-augmented elicitation within task - server polls client
result = await task.elicit_as_task(
message="Please confirm the action",
requestedSchema={"type": "object", "properties": {"confirm": {"type": "boolean"}}},
ttl=60000,
)
confirmed = result.content.get("confirm", False) if result.content else False
work_completed.set()
return CallToolResult(content=[TextContent(type="text", text="confirmed" if confirmed else "cancelled")])
return await ctx.experimental.run_task(work)
task_handlers = create_client_task_handlers(client_task_store, elicit_received)
# 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 run_server() -> None:
await server.run(
client_to_server_receive,
server_to_client_send,
server.create_initialization_options(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
)
async def run_client() -> None:
async with ClientSession(
server_to_client_receive,
client_to_server_send,
experimental_task_handlers=task_handlers,
) as client_session:
await client_session.initialize()
# Call tool as task
create_result = await client_session.experimental.call_tool_as_task("confirm_action", {})
task_id = create_result.task.taskId
assert create_result.task.status == "working"
# Poll until input_required or terminal, then call tasks/result
found_expected_status = False
async for status in client_session.experimental.poll_task(task_id): # pragma: no branch
if status.status == "input_required" or is_terminal(status.status): # pragma: no branch
found_expected_status = True
break
assert found_expected_status, "Expected to see input_required or terminal status"
# This will deliver the task-augmented elicitation,
# server will poll client, and eventually return the tool result
final_result = await client_session.experimental.get_task_result(task_id, CallToolResult)
# Verify
assert elicit_received.is_set()
assert len(final_result.content) > 0
assert isinstance(final_result.content[0], TextContent)
assert final_result.content[0].text == "confirmed"
async with anyio.create_task_group() as tg:
tg.start_soon(run_server)
tg.start_soon(run_client)
assert work_completed.is_set()
client_task_store.cleanup()
@pytest.mark.anyio
async def test_scenario2_sampling_normal_tool_task_augmented_sampling() -> None:
"""
Scenario 2 for sampling: Normal tool call with task-augmented sampling.
Server calls session.experimental.create_message_as_task(), client creates
a task for the sampling and returns CreateTaskResult. Server polls client.
"""
server = Server("test-scenario2-sampling")
sampling_received = Event()
tool_result: list[str] = []
# Client-side task store for handling task-augmented sampling
client_task_store = InMemoryTaskStore()
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="generate_text",
description="Generate text using sampling",
inputSchema={"type": "object"},
)
]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult:
ctx = server.request_context
# Task-augmented sampling - server polls client
result = await ctx.session.experimental.create_message_as_task(
messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))],
max_tokens=100,
ttl=60000,
)
assert isinstance(result.content, TextContent), "Expected TextContent response"
response_text = result.content.text
tool_result.append(response_text)
return CallToolResult(content=[TextContent(type="text", text=response_text)])
task_handlers = create_sampling_task_handlers(client_task_store, sampling_received)
# 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 run_server() -> None:
await server.run(
client_to_server_receive,
server_to_client_send,
server.create_initialization_options(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
)
async def run_client() -> None:
async with ClientSession(
server_to_client_receive,
client_to_server_send,
experimental_task_handlers=task_handlers,
) as client_session:
await client_session.initialize()
# Call tool normally (not as task)
result = await client_session.call_tool("generate_text", {})
# Verify sampling was received and tool completed
assert sampling_received.is_set()
assert len(result.content) > 0
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Hello from the model!"
async with anyio.create_task_group() as tg:
tg.start_soon(run_server)
tg.start_soon(run_client)
assert tool_result[0] == "Hello from the model!"
client_task_store.cleanup()
@pytest.mark.anyio
async def test_scenario4_sampling_task_augmented_tool_task_augmented_sampling() -> None:
"""
Scenario 4 for sampling: Task-augmented tool call with task-augmented sampling.
Client calls tool as task. Inside the task, server uses task.create_message_as_task()
which sends task-augmented sampling. Client creates its own task for the sampling,
and server polls the client.
"""
server = Server("test-scenario4-sampling")
server.experimental.enable_tasks()
sampling_received = Event()
work_completed = Event()
# Client-side task store for handling task-augmented sampling
client_task_store = InMemoryTaskStore()
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="generate_text",
description="Generate text using sampling",
inputSchema={"type": "object"},
execution=ToolExecution(taskSupport=TASK_REQUIRED),
)
]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CreateTaskResult:
ctx = server.request_context
ctx.experimental.validate_task_mode(TASK_REQUIRED)
async def work(task: ServerTaskContext) -> CallToolResult:
# Task-augmented sampling within task - server polls client
result = await task.create_message_as_task(
messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))],
max_tokens=100,
ttl=60000,
)
assert isinstance(result.content, TextContent), "Expected TextContent response"
response_text = result.content.text
work_completed.set()
return CallToolResult(content=[TextContent(type="text", text=response_text)])
return await ctx.experimental.run_task(work)
task_handlers = create_sampling_task_handlers(client_task_store, sampling_received)
# 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 run_server() -> None:
await server.run(
client_to_server_receive,
server_to_client_send,
server.create_initialization_options(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
)
async def run_client() -> None:
async with ClientSession(
server_to_client_receive,
client_to_server_send,
experimental_task_handlers=task_handlers,
) as client_session:
await client_session.initialize()
# Call tool as task
create_result = await client_session.experimental.call_tool_as_task("generate_text", {})
task_id = create_result.task.taskId
assert create_result.task.status == "working"
# Poll until input_required or terminal
found_expected_status = False
async for status in client_session.experimental.poll_task(task_id): # pragma: no branch
if status.status == "input_required" or is_terminal(status.status): # pragma: no branch
found_expected_status = True
break
assert found_expected_status, "Expected to see input_required or terminal status"
final_result = await client_session.experimental.get_task_result(task_id, CallToolResult)
# Verify
assert sampling_received.is_set()
assert len(final_result.content) > 0
assert isinstance(final_result.content[0], TextContent)
assert final_result.content[0].text == "Hello from the model!"
async with anyio.create_task_group() as tg:
tg.start_soon(run_server)
tg.start_soon(run_client)
assert work_completed.is_set()
client_task_store.cleanup()
@@ -0,0 +1,331 @@
"""
Tests for TaskMessageQueue and InMemoryTaskMessageQueue.
"""
from datetime import datetime, timezone
import anyio
import pytest
from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue, QueuedMessage
from mcp.shared.experimental.tasks.resolver import Resolver
from mcp.types import JSONRPCNotification, JSONRPCRequest
@pytest.fixture
def queue() -> InMemoryTaskMessageQueue:
return InMemoryTaskMessageQueue()
def make_request(id: int = 1, method: str = "test/method") -> JSONRPCRequest:
return JSONRPCRequest(jsonrpc="2.0", id=id, method=method)
def make_notification(method: str = "test/notify") -> JSONRPCNotification:
return JSONRPCNotification(jsonrpc="2.0", method=method)
class TestInMemoryTaskMessageQueue:
@pytest.mark.anyio
async def test_enqueue_and_dequeue(self, queue: InMemoryTaskMessageQueue) -> None:
"""Test basic enqueue and dequeue operations."""
task_id = "task-1"
msg = QueuedMessage(type="request", message=make_request())
await queue.enqueue(task_id, msg)
result = await queue.dequeue(task_id)
assert result is not None
assert result.type == "request"
assert result.message.method == "test/method"
@pytest.mark.anyio
async def test_dequeue_empty_returns_none(self, queue: InMemoryTaskMessageQueue) -> None:
"""Dequeue from empty queue returns None."""
result = await queue.dequeue("nonexistent-task")
assert result is None
@pytest.mark.anyio
async def test_fifo_ordering(self, queue: InMemoryTaskMessageQueue) -> None:
"""Messages are dequeued in FIFO order."""
task_id = "task-1"
await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request(1, "first")))
await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request(2, "second")))
await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request(3, "third")))
msg1 = await queue.dequeue(task_id)
msg2 = await queue.dequeue(task_id)
msg3 = await queue.dequeue(task_id)
assert msg1 is not None and msg1.message.method == "first"
assert msg2 is not None and msg2.message.method == "second"
assert msg3 is not None and msg3.message.method == "third"
@pytest.mark.anyio
async def test_separate_queues_per_task(self, queue: InMemoryTaskMessageQueue) -> None:
"""Each task has its own queue."""
await queue.enqueue("task-1", QueuedMessage(type="request", message=make_request(1, "task1-msg")))
await queue.enqueue("task-2", QueuedMessage(type="request", message=make_request(2, "task2-msg")))
msg1 = await queue.dequeue("task-1")
msg2 = await queue.dequeue("task-2")
assert msg1 is not None and msg1.message.method == "task1-msg"
assert msg2 is not None and msg2.message.method == "task2-msg"
@pytest.mark.anyio
async def test_peek_does_not_remove(self, queue: InMemoryTaskMessageQueue) -> None:
"""Peek returns message without removing it."""
task_id = "task-1"
await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request()))
peeked = await queue.peek(task_id)
dequeued = await queue.dequeue(task_id)
assert peeked is not None
assert dequeued is not None
assert isinstance(peeked.message, JSONRPCRequest)
assert isinstance(dequeued.message, JSONRPCRequest)
assert peeked.message.id == dequeued.message.id
@pytest.mark.anyio
async def test_is_empty(self, queue: InMemoryTaskMessageQueue) -> None:
"""Test is_empty method."""
task_id = "task-1"
assert await queue.is_empty(task_id) is True
await queue.enqueue(task_id, QueuedMessage(type="notification", message=make_notification()))
assert await queue.is_empty(task_id) is False
await queue.dequeue(task_id)
assert await queue.is_empty(task_id) is True
@pytest.mark.anyio
async def test_clear_returns_all_messages(self, queue: InMemoryTaskMessageQueue) -> None:
"""Clear removes and returns all messages."""
task_id = "task-1"
await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request(1)))
await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request(2)))
await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request(3)))
messages = await queue.clear(task_id)
assert len(messages) == 3
assert await queue.is_empty(task_id) is True
@pytest.mark.anyio
async def test_clear_empty_queue(self, queue: InMemoryTaskMessageQueue) -> None:
"""Clear on empty queue returns empty list."""
messages = await queue.clear("nonexistent")
assert messages == []
@pytest.mark.anyio
async def test_notification_messages(self, queue: InMemoryTaskMessageQueue) -> None:
"""Test queuing notification messages."""
task_id = "task-1"
msg = QueuedMessage(type="notification", message=make_notification("log/message"))
await queue.enqueue(task_id, msg)
result = await queue.dequeue(task_id)
assert result is not None
assert result.type == "notification"
assert result.message.method == "log/message"
@pytest.mark.anyio
async def test_message_timestamp(self, queue: InMemoryTaskMessageQueue) -> None:
"""Messages have timestamps."""
before = datetime.now(timezone.utc)
msg = QueuedMessage(type="request", message=make_request())
after = datetime.now(timezone.utc)
assert before <= msg.timestamp <= after
@pytest.mark.anyio
async def test_message_with_resolver(self, queue: InMemoryTaskMessageQueue) -> None:
"""Messages can have resolvers."""
task_id = "task-1"
resolver: Resolver[dict[str, str]] = Resolver()
msg = QueuedMessage(
type="request",
message=make_request(),
resolver=resolver,
original_request_id=42,
)
await queue.enqueue(task_id, msg)
result = await queue.dequeue(task_id)
assert result is not None
assert result.resolver is resolver
assert result.original_request_id == 42
@pytest.mark.anyio
async def test_cleanup_specific_task(self, queue: InMemoryTaskMessageQueue) -> None:
"""Cleanup removes specific task's data."""
await queue.enqueue("task-1", QueuedMessage(type="request", message=make_request(1)))
await queue.enqueue("task-2", QueuedMessage(type="request", message=make_request(2)))
queue.cleanup("task-1")
assert await queue.is_empty("task-1") is True
assert await queue.is_empty("task-2") is False
@pytest.mark.anyio
async def test_cleanup_all(self, queue: InMemoryTaskMessageQueue) -> None:
"""Cleanup without task_id removes all data."""
await queue.enqueue("task-1", QueuedMessage(type="request", message=make_request(1)))
await queue.enqueue("task-2", QueuedMessage(type="request", message=make_request(2)))
queue.cleanup()
assert await queue.is_empty("task-1") is True
assert await queue.is_empty("task-2") is True
@pytest.mark.anyio
async def test_wait_for_message_returns_immediately_if_message_exists(
self, queue: InMemoryTaskMessageQueue
) -> None:
"""wait_for_message returns immediately if queue not empty."""
task_id = "task-1"
await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request()))
# Should return immediately, not block
with anyio.fail_after(1):
await queue.wait_for_message(task_id)
@pytest.mark.anyio
async def test_wait_for_message_blocks_until_message(self, queue: InMemoryTaskMessageQueue) -> None:
"""wait_for_message blocks until a message is enqueued."""
task_id = "task-1"
received = False
waiter_started = anyio.Event()
async def enqueue_when_ready() -> None:
# Wait until the waiter has started before enqueueing
await waiter_started.wait()
await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request()))
async def wait_for_msg() -> None:
nonlocal received
# Signal that we're about to start waiting
waiter_started.set()
await queue.wait_for_message(task_id)
received = True
async with anyio.create_task_group() as tg:
tg.start_soon(wait_for_msg)
tg.start_soon(enqueue_when_ready)
assert received is True
@pytest.mark.anyio
async def test_notify_message_available_wakes_waiter(self, queue: InMemoryTaskMessageQueue) -> None:
"""notify_message_available wakes up waiting coroutines."""
task_id = "task-1"
notified = False
waiter_started = anyio.Event()
async def notify_when_ready() -> None:
# Wait until the waiter has started before notifying
await waiter_started.wait()
await queue.notify_message_available(task_id)
async def wait_for_notification() -> None:
nonlocal notified
# Signal that we're about to start waiting
waiter_started.set()
await queue.wait_for_message(task_id)
notified = True
async with anyio.create_task_group() as tg:
tg.start_soon(wait_for_notification)
tg.start_soon(notify_when_ready)
assert notified is True
@pytest.mark.anyio
async def test_peek_empty_queue_returns_none(self, queue: InMemoryTaskMessageQueue) -> None:
"""Peek on empty queue returns None."""
result = await queue.peek("nonexistent-task")
assert result is None
@pytest.mark.anyio
async def test_wait_for_message_double_check_race_condition(self, queue: InMemoryTaskMessageQueue) -> None:
"""wait_for_message returns early if message arrives after event creation but before wait."""
task_id = "task-1"
# To test the double-check path (lines 223-225), we need a message to arrive
# after the event is created (line 220) but before event.wait() (line 228).
# We simulate this by injecting a message before is_empty is called the second time.
original_is_empty = queue.is_empty
call_count = 0
async def is_empty_with_injection(tid: str) -> bool:
nonlocal call_count
call_count += 1
if call_count == 2 and tid == task_id:
# Before second check, inject a message - this simulates a message
# arriving between event creation and the double-check
queue._queues[task_id] = [QueuedMessage(type="request", message=make_request())]
return await original_is_empty(tid)
queue.is_empty = is_empty_with_injection # type: ignore[method-assign]
# Should return immediately due to double-check finding the message
with anyio.fail_after(1):
await queue.wait_for_message(task_id)
class TestResolver:
@pytest.mark.anyio
async def test_set_result_and_wait(self) -> None:
"""Test basic set_result and wait flow."""
resolver: Resolver[str] = Resolver()
resolver.set_result("hello")
result = await resolver.wait()
assert result == "hello"
assert resolver.done()
@pytest.mark.anyio
async def test_set_exception_and_wait(self) -> None:
"""Test set_exception raises on wait."""
resolver: Resolver[str] = Resolver()
resolver.set_exception(ValueError("test error"))
with pytest.raises(ValueError, match="test error"):
await resolver.wait()
assert resolver.done()
@pytest.mark.anyio
async def test_set_result_when_already_completed_raises(self) -> None:
"""Test that set_result raises if resolver already completed."""
resolver: Resolver[str] = Resolver()
resolver.set_result("first")
with pytest.raises(RuntimeError, match="already completed"):
resolver.set_result("second")
@pytest.mark.anyio
async def test_set_exception_when_already_completed_raises(self) -> None:
"""Test that set_exception raises if resolver already completed."""
resolver: Resolver[str] = Resolver()
resolver.set_result("done")
with pytest.raises(RuntimeError, match="already completed"):
resolver.set_exception(ValueError("too late"))
@pytest.mark.anyio
async def test_done_returns_false_before_completion(self) -> None:
"""Test done() returns False before any result is set."""
resolver: Resolver[str] = Resolver()
assert resolver.done() is False
@@ -0,0 +1,166 @@
"""Tests for the RequestContext.experimental (Experimental class) task validation helpers."""
import pytest
from mcp.server.experimental.request_context import Experimental
from mcp.shared.exceptions import McpError
from mcp.types import (
METHOD_NOT_FOUND,
TASK_FORBIDDEN,
TASK_OPTIONAL,
TASK_REQUIRED,
ClientCapabilities,
ClientTasksCapability,
TaskMetadata,
Tool,
ToolExecution,
)
def test_is_task_true_when_metadata_present() -> None:
exp = Experimental(task_metadata=TaskMetadata(ttl=60000))
assert exp.is_task is True
def test_is_task_false_when_no_metadata() -> None:
exp = Experimental(task_metadata=None)
assert exp.is_task is False
def test_client_supports_tasks_true() -> None:
exp = Experimental(_client_capabilities=ClientCapabilities(tasks=ClientTasksCapability()))
assert exp.client_supports_tasks is True
def test_client_supports_tasks_false_no_tasks() -> None:
exp = Experimental(_client_capabilities=ClientCapabilities())
assert exp.client_supports_tasks is False
def test_client_supports_tasks_false_no_capabilities() -> None:
exp = Experimental(_client_capabilities=None)
assert exp.client_supports_tasks is False
def test_validate_task_mode_required_with_task_is_valid() -> None:
exp = Experimental(task_metadata=TaskMetadata(ttl=60000))
error = exp.validate_task_mode(TASK_REQUIRED, raise_error=False)
assert error is None
def test_validate_task_mode_required_without_task_returns_error() -> None:
exp = Experimental(task_metadata=None)
error = exp.validate_task_mode(TASK_REQUIRED, raise_error=False)
assert error is not None
assert error.code == METHOD_NOT_FOUND
assert "requires task-augmented" in error.message
def test_validate_task_mode_required_without_task_raises_by_default() -> None:
exp = Experimental(task_metadata=None)
with pytest.raises(McpError) as exc_info:
exp.validate_task_mode(TASK_REQUIRED)
assert exc_info.value.error.code == METHOD_NOT_FOUND
def test_validate_task_mode_forbidden_without_task_is_valid() -> None:
exp = Experimental(task_metadata=None)
error = exp.validate_task_mode(TASK_FORBIDDEN, raise_error=False)
assert error is None
def test_validate_task_mode_forbidden_with_task_returns_error() -> None:
exp = Experimental(task_metadata=TaskMetadata(ttl=60000))
error = exp.validate_task_mode(TASK_FORBIDDEN, raise_error=False)
assert error is not None
assert error.code == METHOD_NOT_FOUND
assert "does not support task-augmented" in error.message
def test_validate_task_mode_forbidden_with_task_raises_by_default() -> None:
exp = Experimental(task_metadata=TaskMetadata(ttl=60000))
with pytest.raises(McpError) as exc_info:
exp.validate_task_mode(TASK_FORBIDDEN)
assert exc_info.value.error.code == METHOD_NOT_FOUND
def test_validate_task_mode_none_treated_as_forbidden() -> None:
exp = Experimental(task_metadata=TaskMetadata(ttl=60000))
error = exp.validate_task_mode(None, raise_error=False)
assert error is not None
assert "does not support task-augmented" in error.message
def test_validate_task_mode_optional_with_task_is_valid() -> None:
exp = Experimental(task_metadata=TaskMetadata(ttl=60000))
error = exp.validate_task_mode(TASK_OPTIONAL, raise_error=False)
assert error is None
def test_validate_task_mode_optional_without_task_is_valid() -> None:
exp = Experimental(task_metadata=None)
error = exp.validate_task_mode(TASK_OPTIONAL, raise_error=False)
assert error is None
def test_validate_for_tool_with_execution_required() -> None:
exp = Experimental(task_metadata=None)
tool = Tool(
name="test",
description="test",
inputSchema={"type": "object"},
execution=ToolExecution(taskSupport=TASK_REQUIRED),
)
error = exp.validate_for_tool(tool, raise_error=False)
assert error is not None
assert "requires task-augmented" in error.message
def test_validate_for_tool_without_execution() -> None:
exp = Experimental(task_metadata=TaskMetadata(ttl=60000))
tool = Tool(
name="test",
description="test",
inputSchema={"type": "object"},
execution=None,
)
error = exp.validate_for_tool(tool, raise_error=False)
assert error is not None
assert "does not support task-augmented" in error.message
def test_validate_for_tool_optional_with_task() -> None:
exp = Experimental(task_metadata=TaskMetadata(ttl=60000))
tool = Tool(
name="test",
description="test",
inputSchema={"type": "object"},
execution=ToolExecution(taskSupport=TASK_OPTIONAL),
)
error = exp.validate_for_tool(tool, raise_error=False)
assert error is None
def test_can_use_tool_required_with_task_support() -> None:
exp = Experimental(_client_capabilities=ClientCapabilities(tasks=ClientTasksCapability()))
assert exp.can_use_tool(TASK_REQUIRED) is True
def test_can_use_tool_required_without_task_support() -> None:
exp = Experimental(_client_capabilities=ClientCapabilities())
assert exp.can_use_tool(TASK_REQUIRED) is False
def test_can_use_tool_optional_without_task_support() -> None:
exp = Experimental(_client_capabilities=ClientCapabilities())
assert exp.can_use_tool(TASK_OPTIONAL) is True
def test_can_use_tool_forbidden_without_task_support() -> None:
exp = Experimental(_client_capabilities=ClientCapabilities())
assert exp.can_use_tool(TASK_FORBIDDEN) is True
def test_can_use_tool_none_without_task_support() -> None:
exp = Experimental(_client_capabilities=ClientCapabilities())
assert exp.can_use_tool(None) is True
@@ -0,0 +1,753 @@
"""
Tasks Spec Compliance Tests
===========================
Test structure mirrors: https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks.md
Each section contains tests for normative requirements (MUST/SHOULD/MAY).
"""
from datetime import datetime, timezone
import pytest
from mcp.server import Server
from mcp.server.lowlevel import NotificationOptions
from mcp.shared.experimental.tasks.helpers import MODEL_IMMEDIATE_RESPONSE_KEY
from mcp.types import (
CancelTaskRequest,
CancelTaskResult,
CreateTaskResult,
GetTaskRequest,
GetTaskResult,
ListTasksRequest,
ListTasksResult,
ServerCapabilities,
Task,
)
# Shared test datetime
TEST_DATETIME = datetime(2025, 1, 1, tzinfo=timezone.utc)
def _get_capabilities(server: Server) -> ServerCapabilities:
"""Helper to get capabilities from a server."""
return server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
)
def test_server_without_task_handlers_has_no_tasks_capability() -> None:
"""Server without any task handlers has no tasks capability."""
server: Server = Server("test")
caps = _get_capabilities(server)
assert caps.tasks is None
def test_server_with_list_tasks_handler_declares_list_capability() -> None:
"""Server with list_tasks handler declares tasks.list capability."""
server: Server = Server("test")
@server.experimental.list_tasks()
async def handle_list(req: ListTasksRequest) -> ListTasksResult:
raise NotImplementedError
caps = _get_capabilities(server)
assert caps.tasks is not None
assert caps.tasks.list is not None
def test_server_with_cancel_task_handler_declares_cancel_capability() -> None:
"""Server with cancel_task handler declares tasks.cancel capability."""
server: Server = Server("test")
@server.experimental.cancel_task()
async def handle_cancel(req: CancelTaskRequest) -> CancelTaskResult:
raise NotImplementedError
caps = _get_capabilities(server)
assert caps.tasks is not None
assert caps.tasks.cancel is not None
def test_server_with_get_task_handler_declares_requests_tools_call_capability() -> None:
"""
Server with get_task handler declares tasks.requests.tools.call capability.
(get_task is required for task-augmented tools/call support)
"""
server: Server = Server("test")
@server.experimental.get_task()
async def handle_get(req: GetTaskRequest) -> GetTaskResult:
raise NotImplementedError
caps = _get_capabilities(server)
assert caps.tasks is not None
assert caps.tasks.requests is not None
assert caps.tasks.requests.tools is not None
def test_server_without_list_handler_has_no_list_capability() -> None:
"""Server without list_tasks handler has no tasks.list capability."""
server: Server = Server("test")
# Register only get_task (not list_tasks)
@server.experimental.get_task()
async def handle_get(req: GetTaskRequest) -> GetTaskResult:
raise NotImplementedError
caps = _get_capabilities(server)
assert caps.tasks is not None
assert caps.tasks.list is None
def test_server_without_cancel_handler_has_no_cancel_capability() -> None:
"""Server without cancel_task handler has no tasks.cancel capability."""
server: Server = Server("test")
# Register only get_task (not cancel_task)
@server.experimental.get_task()
async def handle_get(req: GetTaskRequest) -> GetTaskResult:
raise NotImplementedError
caps = _get_capabilities(server)
assert caps.tasks is not None
assert caps.tasks.cancel is None
def test_server_with_all_task_handlers_has_full_capability() -> None:
"""Server with all task handlers declares complete tasks capability."""
server: Server = Server("test")
@server.experimental.list_tasks()
async def handle_list(req: ListTasksRequest) -> ListTasksResult:
raise NotImplementedError
@server.experimental.cancel_task()
async def handle_cancel(req: CancelTaskRequest) -> CancelTaskResult:
raise NotImplementedError
@server.experimental.get_task()
async def handle_get(req: GetTaskRequest) -> GetTaskResult:
raise NotImplementedError
caps = _get_capabilities(server)
assert caps.tasks is not None
assert caps.tasks.list is not None
assert caps.tasks.cancel is not None
assert caps.tasks.requests is not None
assert caps.tasks.requests.tools is not None
class TestClientCapabilities:
"""
Clients declare:
- tasks.list — supports listing operations
- tasks.cancel — supports cancellation
- tasks.requests.sampling.createMessage — task-augmented sampling
- tasks.requests.elicitation.create — task-augmented elicitation
"""
def test_client_declares_tasks_capability(self) -> None:
"""Client can declare tasks capability."""
pytest.skip("TODO")
class TestToolLevelNegotiation:
"""
Tools in tools/list responses include execution.taskSupport with values:
- Not present or "forbidden": No task augmentation allowed
- "optional": Task augmentation allowed at requestor discretion
- "required": Task augmentation is mandatory
"""
def test_tool_execution_task_forbidden_rejects_task_augmented_call(self) -> None:
"""Tool with execution.taskSupport="forbidden" MUST reject task-augmented calls (-32601)."""
pytest.skip("TODO")
def test_tool_execution_task_absent_rejects_task_augmented_call(self) -> None:
"""Tool without execution.taskSupport MUST reject task-augmented calls (-32601)."""
pytest.skip("TODO")
def test_tool_execution_task_optional_accepts_normal_call(self) -> None:
"""Tool with execution.taskSupport="optional" accepts normal calls."""
pytest.skip("TODO")
def test_tool_execution_task_optional_accepts_task_augmented_call(self) -> None:
"""Tool with execution.taskSupport="optional" accepts task-augmented calls."""
pytest.skip("TODO")
def test_tool_execution_task_required_rejects_normal_call(self) -> None:
"""Tool with execution.taskSupport="required" MUST reject non-task calls (-32601)."""
pytest.skip("TODO")
def test_tool_execution_task_required_accepts_task_augmented_call(self) -> None:
"""Tool with execution.taskSupport="required" accepts task-augmented calls."""
pytest.skip("TODO")
class TestCapabilityNegotiation:
"""
Requestors SHOULD only augment requests with a task if the corresponding
capability has been declared by the receiver.
Receivers that do not declare the task capability for a request type
MUST process requests of that type normally, ignoring any task-augmentation
metadata if present.
"""
def test_receiver_without_capability_ignores_task_metadata(self) -> None:
"""
Receiver without task capability MUST process request normally,
ignoring task-augmentation metadata.
"""
pytest.skip("TODO")
def test_receiver_with_capability_may_require_task_augmentation(self) -> None:
"""
Receivers that declare task capability MAY return error (-32600)
for non-task-augmented requests, requiring task augmentation.
"""
pytest.skip("TODO")
class TestTaskStatusLifecycle:
"""
Tasks begin in working status and follow valid transitions:
working → input_required → working → terminal
working → terminal (directly)
input_required → terminal (directly)
Terminal states (no further transitions allowed):
- completed
- failed
- cancelled
"""
def test_task_begins_in_working_status(self) -> None:
"""Tasks MUST begin in working status."""
pytest.skip("TODO")
def test_working_to_completed_transition(self) -> None:
"""working → completed is valid."""
pytest.skip("TODO")
def test_working_to_failed_transition(self) -> None:
"""working → failed is valid."""
pytest.skip("TODO")
def test_working_to_cancelled_transition(self) -> None:
"""working → cancelled is valid."""
pytest.skip("TODO")
def test_working_to_input_required_transition(self) -> None:
"""working → input_required is valid."""
pytest.skip("TODO")
def test_input_required_to_working_transition(self) -> None:
"""input_required → working is valid."""
pytest.skip("TODO")
def test_input_required_to_terminal_transition(self) -> None:
"""input_required → terminal is valid."""
pytest.skip("TODO")
def test_terminal_state_no_further_transitions(self) -> None:
"""Terminal states allow no further transitions."""
pytest.skip("TODO")
def test_completed_is_terminal(self) -> None:
"""completed is a terminal state."""
pytest.skip("TODO")
def test_failed_is_terminal(self) -> None:
"""failed is a terminal state."""
pytest.skip("TODO")
def test_cancelled_is_terminal(self) -> None:
"""cancelled is a terminal state."""
pytest.skip("TODO")
class TestInputRequiredStatus:
"""
When a receiver needs information to proceed, it moves the task to input_required.
The requestor should call tasks/result to retrieve input requests.
The task must include io.modelcontextprotocol/related-task metadata in associated requests.
"""
def test_input_required_status_retrievable_via_tasks_get(self) -> None:
"""Task in input_required status is retrievable via tasks/get."""
pytest.skip("TODO")
def test_input_required_related_task_metadata_in_requests(self) -> None:
"""
Task MUST include io.modelcontextprotocol/related-task metadata
in associated requests.
"""
pytest.skip("TODO")
class TestCreatingTask:
"""
Request structure:
{"method": "tools/call", "params": {"name": "...", "arguments": {...}, "task": {"ttl": 60000}}}
Response (CreateTaskResult):
{"result": {"task": {"taskId": "...", "status": "working", ...}}}
Receivers may include io.modelcontextprotocol/model-immediate-response in _meta.
"""
def test_task_augmented_request_returns_create_task_result(self) -> None:
"""Task-augmented request MUST return CreateTaskResult immediately."""
pytest.skip("TODO")
def test_create_task_result_contains_task_id(self) -> None:
"""CreateTaskResult MUST contain taskId."""
pytest.skip("TODO")
def test_create_task_result_contains_status_working(self) -> None:
"""CreateTaskResult MUST have status=working initially."""
pytest.skip("TODO")
def test_create_task_result_contains_created_at(self) -> None:
"""CreateTaskResult MUST contain createdAt timestamp."""
pytest.skip("TODO")
def test_create_task_result_created_at_is_iso8601(self) -> None:
"""createdAt MUST be ISO 8601 formatted."""
pytest.skip("TODO")
def test_create_task_result_may_contain_ttl(self) -> None:
"""CreateTaskResult MAY contain ttl."""
pytest.skip("TODO")
def test_create_task_result_may_contain_poll_interval(self) -> None:
"""CreateTaskResult MAY contain pollInterval."""
pytest.skip("TODO")
def test_create_task_result_may_contain_status_message(self) -> None:
"""CreateTaskResult MAY contain statusMessage."""
pytest.skip("TODO")
def test_receiver_may_override_requested_ttl(self) -> None:
"""Receiver MAY override requested ttl but MUST return actual value."""
pytest.skip("TODO")
def test_model_immediate_response_in_meta(self) -> None:
"""
Receiver MAY include io.modelcontextprotocol/model-immediate-response
in _meta to provide immediate response while task executes.
"""
# Verify the constant has the correct value per spec
assert MODEL_IMMEDIATE_RESPONSE_KEY == "io.modelcontextprotocol/model-immediate-response"
# CreateTaskResult can include model-immediate-response in _meta
task = Task(
taskId="test-123",
status="working",
createdAt=TEST_DATETIME,
lastUpdatedAt=TEST_DATETIME,
ttl=60000,
)
immediate_msg = "Task started, processing your request..."
# Note: Must use _meta= (alias) not meta= due to Pydantic alias handling
result = CreateTaskResult(
task=task,
**{"_meta": {MODEL_IMMEDIATE_RESPONSE_KEY: immediate_msg}},
)
# Verify the metadata is present and correct
assert result.meta is not None
assert MODEL_IMMEDIATE_RESPONSE_KEY in result.meta
assert result.meta[MODEL_IMMEDIATE_RESPONSE_KEY] == immediate_msg
# Verify it serializes correctly with _meta alias
serialized = result.model_dump(by_alias=True)
assert "_meta" in serialized
assert MODEL_IMMEDIATE_RESPONSE_KEY in serialized["_meta"]
assert serialized["_meta"][MODEL_IMMEDIATE_RESPONSE_KEY] == immediate_msg
class TestGettingTaskStatus:
"""
Request: {"method": "tasks/get", "params": {"taskId": "..."}}
Response: Returns full Task object with current status and pollInterval.
"""
def test_tasks_get_returns_task_object(self) -> None:
"""tasks/get MUST return full Task object."""
pytest.skip("TODO")
def test_tasks_get_returns_current_status(self) -> None:
"""tasks/get MUST return current status."""
pytest.skip("TODO")
def test_tasks_get_may_return_poll_interval(self) -> None:
"""tasks/get MAY return pollInterval."""
pytest.skip("TODO")
def test_tasks_get_invalid_task_id_returns_error(self) -> None:
"""tasks/get with invalid taskId MUST return -32602."""
pytest.skip("TODO")
def test_tasks_get_nonexistent_task_id_returns_error(self) -> None:
"""tasks/get with nonexistent taskId MUST return -32602."""
pytest.skip("TODO")
class TestRetrievingResults:
"""
Request: {"method": "tasks/result", "params": {"taskId": "..."}}
Response: The actual operation result structure (e.g., CallToolResult).
This call blocks until terminal status.
"""
def test_tasks_result_returns_underlying_result(self) -> None:
"""tasks/result MUST return exactly what underlying request would return."""
pytest.skip("TODO")
def test_tasks_result_blocks_until_terminal(self) -> None:
"""tasks/result MUST block for non-terminal tasks."""
pytest.skip("TODO")
def test_tasks_result_unblocks_on_terminal(self) -> None:
"""tasks/result MUST unblock upon reaching terminal status."""
pytest.skip("TODO")
def test_tasks_result_includes_related_task_metadata(self) -> None:
"""tasks/result MUST include io.modelcontextprotocol/related-task in _meta."""
pytest.skip("TODO")
def test_tasks_result_returns_error_for_failed_task(self) -> None:
"""
tasks/result returns the same error the underlying request
would have produced for failed tasks.
"""
pytest.skip("TODO")
def test_tasks_result_invalid_task_id_returns_error(self) -> None:
"""tasks/result with invalid taskId MUST return -32602."""
pytest.skip("TODO")
class TestListingTasks:
"""
Request: {"method": "tasks/list", "params": {"cursor": "optional"}}
Response: Array of tasks with pagination support via nextCursor.
"""
def test_tasks_list_returns_array_of_tasks(self) -> None:
"""tasks/list MUST return array of tasks."""
pytest.skip("TODO")
def test_tasks_list_pagination_with_cursor(self) -> None:
"""tasks/list supports pagination via cursor."""
pytest.skip("TODO")
def test_tasks_list_returns_next_cursor_when_more_results(self) -> None:
"""tasks/list MUST return nextCursor when more results available."""
pytest.skip("TODO")
def test_tasks_list_cursors_are_opaque(self) -> None:
"""Implementers MUST treat cursors as opaque tokens."""
pytest.skip("TODO")
def test_tasks_list_invalid_cursor_returns_error(self) -> None:
"""tasks/list with invalid cursor MUST return -32602."""
pytest.skip("TODO")
class TestCancellingTasks:
"""
Request: {"method": "tasks/cancel", "params": {"taskId": "..."}}
Response: Returns the task object with status: "cancelled".
"""
def test_tasks_cancel_returns_cancelled_task(self) -> None:
"""tasks/cancel MUST return task with status=cancelled."""
pytest.skip("TODO")
def test_tasks_cancel_terminal_task_returns_error(self) -> None:
"""Cancelling already-terminal task MUST return -32602."""
pytest.skip("TODO")
def test_tasks_cancel_completed_task_returns_error(self) -> None:
"""Cancelling completed task MUST return -32602."""
pytest.skip("TODO")
def test_tasks_cancel_failed_task_returns_error(self) -> None:
"""Cancelling failed task MUST return -32602."""
pytest.skip("TODO")
def test_tasks_cancel_already_cancelled_task_returns_error(self) -> None:
"""Cancelling already-cancelled task MUST return -32602."""
pytest.skip("TODO")
def test_tasks_cancel_invalid_task_id_returns_error(self) -> None:
"""tasks/cancel with invalid taskId MUST return -32602."""
pytest.skip("TODO")
class TestStatusNotifications:
"""
Receivers MAY send: {"method": "notifications/tasks/status", "params": {...}}
These are optional; requestors MUST NOT rely on them and SHOULD continue polling.
"""
def test_receiver_may_send_status_notification(self) -> None:
"""Receiver MAY send notifications/tasks/status."""
pytest.skip("TODO")
def test_status_notification_contains_task_id(self) -> None:
"""Status notification MUST contain taskId."""
pytest.skip("TODO")
def test_status_notification_contains_status(self) -> None:
"""Status notification MUST contain status."""
pytest.skip("TODO")
class TestTaskManagement:
"""
- Receivers generate unique task IDs as strings
- Tasks must begin in working status
- createdAt timestamps must be ISO 8601 formatted
- Receivers may override requested ttl but must return actual value
- Receivers may delete tasks after TTL expires
- All task-related messages must include io.modelcontextprotocol/related-task
in _meta except for tasks/get, tasks/list, tasks/cancel operations
"""
def test_task_ids_are_unique_strings(self) -> None:
"""Receivers MUST generate unique task IDs as strings."""
pytest.skip("TODO")
def test_multiple_tasks_have_unique_ids(self) -> None:
"""Multiple tasks MUST have unique IDs."""
pytest.skip("TODO")
def test_receiver_may_delete_tasks_after_ttl(self) -> None:
"""Receivers MAY delete tasks after TTL expires."""
pytest.skip("TODO")
def test_related_task_metadata_in_task_messages(self) -> None:
"""
All task-related messages MUST include io.modelcontextprotocol/related-task
in _meta.
"""
pytest.skip("TODO")
def test_tasks_get_does_not_require_related_task_metadata(self) -> None:
"""tasks/get does not require related-task metadata."""
pytest.skip("TODO")
def test_tasks_list_does_not_require_related_task_metadata(self) -> None:
"""tasks/list does not require related-task metadata."""
pytest.skip("TODO")
def test_tasks_cancel_does_not_require_related_task_metadata(self) -> None:
"""tasks/cancel does not require related-task metadata."""
pytest.skip("TODO")
class TestResultHandling:
"""
- Receivers must return CreateTaskResult immediately upon accepting task-augmented requests
- tasks/result must return exactly what the underlying request would return
- tasks/result blocks for non-terminal tasks; must unblock upon reaching terminal status
"""
def test_create_task_result_returned_immediately(self) -> None:
"""Receiver MUST return CreateTaskResult immediately (not after work completes)."""
pytest.skip("TODO")
def test_tasks_result_matches_underlying_result_structure(self) -> None:
"""tasks/result MUST return same structure as underlying request."""
pytest.skip("TODO")
def test_tasks_result_for_tool_call_returns_call_tool_result(self) -> None:
"""tasks/result for tools/call returns CallToolResult."""
pytest.skip("TODO")
class TestProgressTracking:
"""
Task-augmented requests support progress notifications using the progressToken
mechanism, which remains valid throughout the task lifetime.
"""
def test_progress_token_valid_throughout_task_lifetime(self) -> None:
"""progressToken remains valid throughout task lifetime."""
pytest.skip("TODO")
def test_progress_notifications_sent_during_task_execution(self) -> None:
"""Progress notifications can be sent during task execution."""
pytest.skip("TODO")
class TestProtocolErrors:
"""
Protocol Errors (JSON-RPC standard codes):
- -32600 (Invalid request): Non-task requests to endpoint requiring task augmentation
- -32602 (Invalid params): Invalid/nonexistent taskId, invalid cursor, cancel terminal task
- -32603 (Internal error): Server-side execution failures
"""
def test_invalid_request_for_required_task_augmentation(self) -> None:
"""Non-task request to task-required endpoint returns -32600."""
pytest.skip("TODO")
def test_invalid_params_for_invalid_task_id(self) -> None:
"""Invalid taskId returns -32602."""
pytest.skip("TODO")
def test_invalid_params_for_nonexistent_task_id(self) -> None:
"""Nonexistent taskId returns -32602."""
pytest.skip("TODO")
def test_invalid_params_for_invalid_cursor(self) -> None:
"""Invalid cursor in tasks/list returns -32602."""
pytest.skip("TODO")
def test_invalid_params_for_cancel_terminal_task(self) -> None:
"""Attempt to cancel terminal task returns -32602."""
pytest.skip("TODO")
def test_internal_error_for_server_failure(self) -> None:
"""Server-side execution failure returns -32603."""
pytest.skip("TODO")
class TestTaskExecutionErrors:
"""
When underlying requests fail, the task moves to failed status.
- tasks/get response should include statusMessage explaining failure
- tasks/result returns same error the underlying request would have produced
- For tool calls, isError: true moves task to failed status
"""
def test_underlying_failure_moves_task_to_failed(self) -> None:
"""Underlying request failure moves task to failed status."""
pytest.skip("TODO")
def test_failed_task_has_status_message(self) -> None:
"""Failed task SHOULD include statusMessage explaining failure."""
pytest.skip("TODO")
def test_tasks_result_returns_underlying_error(self) -> None:
"""tasks/result returns same error underlying request would produce."""
pytest.skip("TODO")
def test_tool_call_is_error_true_moves_to_failed(self) -> None:
"""Tool call with isError: true moves task to failed status."""
pytest.skip("TODO")
class TestTaskObject:
"""
Task Object fields:
- taskId: String identifier
- status: Current execution state
- statusMessage: Optional human-readable description
- createdAt: ISO 8601 timestamp of creation
- ttl: Milliseconds before potential deletion
- pollInterval: Suggested milliseconds between polls
"""
def test_task_has_task_id_string(self) -> None:
"""Task MUST have taskId as string."""
pytest.skip("TODO")
def test_task_has_status(self) -> None:
"""Task MUST have status."""
pytest.skip("TODO")
def test_task_status_message_is_optional(self) -> None:
"""Task statusMessage is optional."""
pytest.skip("TODO")
def test_task_has_created_at(self) -> None:
"""Task MUST have createdAt."""
pytest.skip("TODO")
def test_task_ttl_is_optional(self) -> None:
"""Task ttl is optional."""
pytest.skip("TODO")
def test_task_poll_interval_is_optional(self) -> None:
"""Task pollInterval is optional."""
pytest.skip("TODO")
class TestRelatedTaskMetadata:
"""
Related Task Metadata structure:
{"_meta": {"io.modelcontextprotocol/related-task": {"taskId": "..."}}}
"""
def test_related_task_metadata_structure(self) -> None:
"""Related task metadata has correct structure."""
pytest.skip("TODO")
def test_related_task_metadata_contains_task_id(self) -> None:
"""Related task metadata contains taskId."""
pytest.skip("TODO")
class TestAccessAndIsolation:
"""
- Task IDs enable access to sensitive results
- Authorization context binding is essential where available
- For non-authorized environments: strong entropy IDs, strict TTL limits
"""
def test_task_bound_to_authorization_context(self) -> None:
"""
Receivers receiving authorization context MUST bind tasks to that context.
"""
pytest.skip("TODO")
def test_reject_task_operations_outside_authorization_context(self) -> None:
"""
Receivers MUST reject task operations for tasks outside
requestor's authorization context.
"""
pytest.skip("TODO")
def test_non_authorized_environments_use_secure_ids(self) -> None:
"""
For non-authorized environments, receivers SHOULD use
cryptographically secure IDs.
"""
pytest.skip("TODO")
def test_non_authorized_environments_use_shorter_ttls(self) -> None:
"""
For non-authorized environments, receivers SHOULD use shorter TTLs.
"""
pytest.skip("TODO")
class TestResourceLimits:
"""
Receivers should:
- Enforce concurrent task limits per requestor
- Implement maximum TTL constraints
- Clean up expired tasks promptly
"""
def test_concurrent_task_limit_enforced(self) -> None:
"""Receiver SHOULD enforce concurrent task limits per requestor."""
pytest.skip("TODO")
def test_maximum_ttl_constraint_enforced(self) -> None:
"""Receiver SHOULD implement maximum TTL constraints."""
pytest.skip("TODO")
def test_expired_tasks_cleaned_up(self) -> None:
"""Receiver SHOULD clean up expired tasks promptly."""
pytest.skip("TODO")
+141
View File
@@ -0,0 +1,141 @@
"""Tests for server validation functions."""
import pytest
from mcp.server.validation import (
check_sampling_tools_capability,
validate_sampling_tools,
validate_tool_use_result_messages,
)
from mcp.shared.exceptions import McpError
from mcp.types import (
ClientCapabilities,
SamplingCapability,
SamplingMessage,
SamplingToolsCapability,
TextContent,
Tool,
ToolChoice,
ToolResultContent,
ToolUseContent,
)
class TestCheckSamplingToolsCapability:
"""Tests for check_sampling_tools_capability function."""
def test_returns_false_when_caps_none(self) -> None:
"""Returns False when client_caps is None."""
assert check_sampling_tools_capability(None) is False
def test_returns_false_when_sampling_none(self) -> None:
"""Returns False when client_caps.sampling is None."""
caps = ClientCapabilities()
assert check_sampling_tools_capability(caps) is False
def test_returns_false_when_tools_none(self) -> None:
"""Returns False when client_caps.sampling.tools is None."""
caps = ClientCapabilities(sampling=SamplingCapability())
assert check_sampling_tools_capability(caps) is False
def test_returns_true_when_tools_present(self) -> None:
"""Returns True when sampling.tools is present."""
caps = ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability()))
assert check_sampling_tools_capability(caps) is True
class TestValidateSamplingTools:
"""Tests for validate_sampling_tools function."""
def test_no_error_when_tools_none(self) -> None:
"""No error when tools and tool_choice are None."""
validate_sampling_tools(None, None, None) # Should not raise
def test_raises_when_tools_provided_but_no_capability(self) -> None:
"""Raises McpError when tools provided but client doesn't support."""
tool = Tool(name="test", inputSchema={"type": "object"})
with pytest.raises(McpError) as exc_info:
validate_sampling_tools(None, [tool], None)
assert "sampling tools capability" in str(exc_info.value)
def test_raises_when_tool_choice_provided_but_no_capability(self) -> None:
"""Raises McpError when tool_choice provided but client doesn't support."""
with pytest.raises(McpError) as exc_info:
validate_sampling_tools(None, None, ToolChoice(mode="auto"))
assert "sampling tools capability" in str(exc_info.value)
def test_no_error_when_capability_present(self) -> None:
"""No error when client has sampling.tools capability."""
caps = ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability()))
tool = Tool(name="test", inputSchema={"type": "object"})
validate_sampling_tools(caps, [tool], ToolChoice(mode="auto")) # Should not raise
class TestValidateToolUseResultMessages:
"""Tests for validate_tool_use_result_messages function."""
def test_no_error_for_empty_messages(self) -> None:
"""No error when messages list is empty."""
validate_tool_use_result_messages([]) # Should not raise
def test_no_error_for_simple_text_messages(self) -> None:
"""No error for simple text messages."""
messages = [
SamplingMessage(role="user", content=TextContent(type="text", text="Hello")),
SamplingMessage(role="assistant", content=TextContent(type="text", text="Hi")),
]
validate_tool_use_result_messages(messages) # Should not raise
def test_raises_when_tool_result_mixed_with_other_content(self) -> None:
"""Raises when tool_result is mixed with other content types."""
messages = [
SamplingMessage(
role="user",
content=[
ToolResultContent(type="tool_result", toolUseId="123"),
TextContent(type="text", text="also this"),
],
),
]
with pytest.raises(ValueError, match="only tool_result content"):
validate_tool_use_result_messages(messages)
def test_raises_when_tool_result_without_previous_tool_use(self) -> None:
"""Raises when tool_result appears without preceding tool_use."""
messages = [
SamplingMessage(
role="user",
content=ToolResultContent(type="tool_result", toolUseId="123"),
),
]
with pytest.raises(ValueError, match="previous message containing tool_use"):
validate_tool_use_result_messages(messages)
def test_raises_when_tool_result_ids_dont_match_tool_use(self) -> None:
"""Raises when tool_result IDs don't match tool_use IDs."""
messages = [
SamplingMessage(
role="assistant",
content=ToolUseContent(type="tool_use", id="tool-1", name="test", input={}),
),
SamplingMessage(
role="user",
content=ToolResultContent(type="tool_result", toolUseId="tool-2"),
),
]
with pytest.raises(ValueError, match="do not match"):
validate_tool_use_result_messages(messages)
def test_no_error_when_tool_result_matches_tool_use(self) -> None:
"""No error when tool_result IDs match tool_use IDs."""
messages = [
SamplingMessage(
role="assistant",
content=ToolUseContent(type="tool_use", id="tool-1", name="test", input={}),
),
SamplingMessage(
role="user",
content=ToolResultContent(type="tool_result", toolUseId="tool-1"),
),
]
validate_tool_use_result_messages(messages) # Should not raise
Generated
+124
View File
@@ -15,6 +15,10 @@ members = [
"mcp-simple-resource",
"mcp-simple-streamablehttp",
"mcp-simple-streamablehttp-stateless",
"mcp-simple-task",
"mcp-simple-task-client",
"mcp-simple-task-interactive",
"mcp-simple-task-interactive-client",
"mcp-simple-tool",
"mcp-snippets",
"mcp-structured-output-lowlevel",
@@ -1196,6 +1200,126 @@ dev = [
{ name = "ruff", specifier = ">=0.6.9" },
]
[[package]]
name = "mcp-simple-task"
version = "0.1.0"
source = { editable = "examples/servers/simple-task" }
dependencies = [
{ name = "anyio" },
{ name = "click" },
{ name = "mcp" },
{ name = "starlette" },
{ name = "uvicorn" },
]
[package.dev-dependencies]
dev = [
{ name = "pyright" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "anyio", specifier = ">=4.5" },
{ name = "click", specifier = ">=8.0" },
{ name = "mcp", editable = "." },
{ name = "starlette" },
{ name = "uvicorn" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pyright", specifier = ">=1.1.378" },
{ name = "ruff", specifier = ">=0.6.9" },
]
[[package]]
name = "mcp-simple-task-client"
version = "0.1.0"
source = { editable = "examples/clients/simple-task-client" }
dependencies = [
{ name = "click" },
{ name = "mcp" },
]
[package.dev-dependencies]
dev = [
{ name = "pyright" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.0" },
{ name = "mcp", editable = "." },
]
[package.metadata.requires-dev]
dev = [
{ name = "pyright", specifier = ">=1.1.378" },
{ name = "ruff", specifier = ">=0.6.9" },
]
[[package]]
name = "mcp-simple-task-interactive"
version = "0.1.0"
source = { editable = "examples/servers/simple-task-interactive" }
dependencies = [
{ name = "anyio" },
{ name = "click" },
{ name = "mcp" },
{ name = "starlette" },
{ name = "uvicorn" },
]
[package.dev-dependencies]
dev = [
{ name = "pyright" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "anyio", specifier = ">=4.5" },
{ name = "click", specifier = ">=8.0" },
{ name = "mcp", editable = "." },
{ name = "starlette" },
{ name = "uvicorn" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pyright", specifier = ">=1.1.378" },
{ name = "ruff", specifier = ">=0.6.9" },
]
[[package]]
name = "mcp-simple-task-interactive-client"
version = "0.1.0"
source = { editable = "examples/clients/simple-task-interactive-client" }
dependencies = [
{ name = "click" },
{ name = "mcp" },
]
[package.dev-dependencies]
dev = [
{ name = "pyright" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.0" },
{ name = "mcp", editable = "." },
]
[package.metadata.requires-dev]
dev = [
{ name = "pyright", specifier = ">=1.1.378" },
{ name = "ruff", specifier = ">=0.6.9" },
]
[[package]]
name = "mcp-simple-tool"
version = "0.1.0"