Remove Tasks (SEP-1686) from the SDK (#2714)
This commit is contained in:
parent
a5b2ebb660
commit
8cc187fac0
5 changed files with 63 additions and 12963 deletions
|
|
@ -1,42 +0,0 @@
|
|||
# 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: enable task support (auto-registers default handlers)
|
||||
server = Server(name="my-server")
|
||||
server.experimental.enable_tasks()
|
||||
|
||||
# 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).
|
||||
|
|
@ -1,361 +0,0 @@
|
|||
# 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.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
|
||||
|
|
@ -1,577 +0,0 @@
|
|||
# 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
|
||||
import uvicorn
|
||||
|
||||
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("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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(server.streamable_http_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
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -595,7 +595,7 @@ The `RequestContext` class has been split to separate shared fields from server-
|
|||
**`RequestContext` changes:**
|
||||
|
||||
- Type parameters reduced from `RequestContext[SessionT, LifespanContextT, RequestT]` to `RequestContext[SessionT]`
|
||||
- Server-specific fields (`lifespan_context`, `experimental`, `request`, `close_sse_stream`, `close_standalone_sse_stream`) moved to new `ServerRequestContext` class in `mcp.server.context`
|
||||
- Server-specific fields (`lifespan_context`, `request`, `close_sse_stream`, `close_standalone_sse_stream`) moved to new `ServerRequestContext` class in `mcp.server.context`
|
||||
|
||||
**Before (v1):**
|
||||
|
||||
|
|
@ -861,7 +861,7 @@ server = Server("my-server", on_list_tools=handle_list_tools, on_call_tool=handl
|
|||
|
||||
**Key differences:**
|
||||
|
||||
- Handlers receive `(ctx, params)` instead of the full request object or unpacked arguments. `ctx` is a `ServerRequestContext` with `session`, `lifespan_context`, and `experimental` fields (plus `request_id`, `meta`, etc. for request handlers). `params` is the typed request params object.
|
||||
- Handlers receive `(ctx, params)` instead of the full request object or unpacked arguments. `ctx` is a `ServerRequestContext` with `session` and `lifespan_context` fields (plus `request_id`, `meta`, etc. for request handlers). `params` is the typed request params object.
|
||||
- Handlers return the full result type (e.g. `ListToolsResult`) rather than unwrapped values (e.g. `list[Tool]`).
|
||||
- The automatic `jsonschema` input/output validation that the old `call_tool()` decorator performed has been removed. There is no built-in replacement — if you relied on schema validation in the lowlevel server, you will need to validate inputs yourself in your handler.
|
||||
|
||||
|
|
@ -872,7 +872,7 @@ All handlers receive `ctx: ServerRequestContext` as the first argument. The seco
|
|||
| v1 decorator | v2 constructor kwarg | `params` type | return type |
|
||||
|---|---|---|---|
|
||||
| `@server.list_tools()` | `on_list_tools` | `PaginatedRequestParams \| None` | `ListToolsResult` |
|
||||
| `@server.call_tool()` | `on_call_tool` | `CallToolRequestParams` | `CallToolResult \| CreateTaskResult` |
|
||||
| `@server.call_tool()` | `on_call_tool` | `CallToolRequestParams` | `CallToolResult` |
|
||||
| `@server.list_resources()` | `on_list_resources` | `PaginatedRequestParams \| None` | `ListResourcesResult` |
|
||||
| `@server.list_resource_templates()` | `on_list_resource_templates` | `PaginatedRequestParams \| None` | `ListResourceTemplatesResult` |
|
||||
| `@server.read_resource()` | `on_read_resource` | `ReadResourceRequestParams` | `ReadResourceResult` |
|
||||
|
|
@ -1039,37 +1039,11 @@ from mcp.server import ServerRequestContext
|
|||
# but None in notification handlers
|
||||
```
|
||||
|
||||
### Experimental: task handler decorators removed
|
||||
### Experimental Tasks support removed
|
||||
|
||||
The experimental decorator methods on `ExperimentalHandlers` (`@server.experimental.list_tasks()`, `@server.experimental.get_task()`, etc.) have been removed.
|
||||
Tasks (SEP-1686) have been removed from the MCP specification and are no longer part of this SDK. The `mcp.client.experimental`, `mcp.server.experimental`, `mcp.shared.experimental`, and `mcp.server.lowlevel.experimental` modules have been removed, along with all `Task*` types, the `tasks` capability fields, `Tool.execution`, and the `experimental` properties on `ClientSession`, `ServerSession`, `Server`, and `ServerRequestContext`.
|
||||
|
||||
Default task handlers are still registered automatically via `server.experimental.enable_tasks()`. Custom handlers can be passed as `on_*` kwargs to override specific defaults.
|
||||
|
||||
**Before (v1):**
|
||||
|
||||
```python
|
||||
server = Server("my-server")
|
||||
server.experimental.enable_tasks()
|
||||
|
||||
@server.experimental.get_task()
|
||||
async def custom_get_task(request: GetTaskRequest) -> GetTaskResult:
|
||||
...
|
||||
```
|
||||
|
||||
**After (v2):**
|
||||
|
||||
```python
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.types import GetTaskRequestParams, GetTaskResult
|
||||
|
||||
|
||||
async def custom_get_task(ctx: ServerRequestContext, params: GetTaskRequestParams) -> GetTaskResult:
|
||||
...
|
||||
|
||||
|
||||
server = Server("my-server")
|
||||
server.experimental.enable_tasks(on_get_task=custom_get_task)
|
||||
```
|
||||
Tasks are expected to return as a separate MCP extension in a future release.
|
||||
|
||||
## Deprecations
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue