Merge main into sampling-tools-sep-1577

This commit is contained in:
Jeremiah Lowin 2025-12-04 22:23:54 -05:00
commit b544cddca7
81 changed files with 10424 additions and 567 deletions

16
examples/tasks/.envrc Normal file
View file

@ -0,0 +1,16 @@
# FastMCP Tasks Example Environment Configuration
# This file is loaded by direnv (https://direnv.net/) when you cd into this directory
# Run `direnv allow` to enable automatic environment loading
# Enable Docket support for background task execution
export FASTMCP_ENABLE_DOCKET=true
# Enable MCP SEP-1686 task protocol support
export FASTMCP_ENABLE_TASKS=true
# Configure Docket backend URL
# Use Redis backend (requires docker-compose up)
export FASTMCP_DOCKET_URL=redis://localhost:24242/0
# Or uncomment to use memory:// for single-process testing
# export FASTMCP_DOCKET_URL=memory://

62
examples/tasks/README.md Normal file
View file

@ -0,0 +1,62 @@
# FastMCP Tasks Example
Demonstrates background task execution with Docket, including progress tracking, distributed backends, and CLI worker management.
## Setup
```bash
# From the fastmcp root directory
uv sync
# Start Redis
cd examples/tasks
docker compose up -d
# Load environment (or source .envrc manually)
direnv allow
# Run the server
fastmcp run server.py
```
For single-process mode without Redis, set `FASTMCP_DOCKET_URL=memory://` (note: CLI workers won't work).
## Running the Client
```bash
# Background execution with progress callbacks
python examples/tasks/client.py --duration 10
# Immediate execution (blocks)
python examples/tasks/client.py immediate --duration 5
```
## Starting Additional Workers
With Redis, you can run additional workers to process tasks in parallel:
```bash
fastmcp tasks worker server.py
# Configure via environment:
export FASTMCP_DOCKET_CONCURRENCY=20
fastmcp tasks worker server.py
```
**Backend options:**
- `memory://` - Single-process only (default)
- `redis://` - Distributed, multi-process (Redis or Valkey)
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `FASTMCP_ENABLE_DOCKET` | `false` | Enable Docket task system |
| `FASTMCP_ENABLE_TASKS` | `false` | Enable MCP task protocol (SEP-1686) |
| `FASTMCP_DOCKET_URL` | `memory://` | Docket backend URL |
## Learn More
- [FastMCP Tasks Documentation](https://gofastmcp.com/docs/tasks)
- [Docket Documentation](https://github.com/PrefectHQ/docket)
- [MCP Task Protocol (SEP-1686)](https://spec.modelcontextprotocol.io/specification/architecture/tasks/)

160
examples/tasks/client.py Normal file
View file

@ -0,0 +1,160 @@
"""
FastMCP Tasks Example Client
Demonstrates calling tools both immediately and as background tasks,
with real-time progress updates via status callbacks.
Usage:
# Make sure environment is configured (source .envrc or use direnv)
source .envrc
# Background task execution with progress callbacks (default)
python client.py --duration 10
# Immediate execution (blocks until complete)
python client.py immediate --duration 5
"""
import asyncio
import sys
from pathlib import Path
from typing import Annotated
import cyclopts
from mcp.types import GetTaskResult, TextContent
from rich.console import Console
from fastmcp.client import Client
console = Console()
app = cyclopts.App(name="tasks-client", help="FastMCP Tasks Example Client")
def load_server():
"""Load the example server."""
examples_dir = Path(__file__).parent.parent.parent
if str(examples_dir) not in sys.path:
sys.path.insert(0, str(examples_dir))
import examples.tasks.server as server_module
return server_module.mcp
# Track last message to deduplicate consecutive identical notifications
# Note: Docket fires separate events for progress.increment() and progress.set_message(),
# but MCP's statusMessage field only carries the text message (no numerical progress).
# This means we often get duplicate notifications with identical messages.
_last_notification_message = None
def print_notification(status: GetTaskResult) -> None:
"""Callback function for push notifications from server.
This is called automatically when the server sends notifications/tasks/status.
Deduplicates identical consecutive messages to keep output clean.
"""
global _last_notification_message
# Skip if this is the same message we just printed
if status.statusMessage == _last_notification_message:
return
_last_notification_message = status.statusMessage
color = {
"working": "yellow",
"completed": "green",
"failed": "red",
}.get(status.status, "yellow")
icon = {
"working": "🚀",
"completed": "",
"failed": "",
}.get(status.status, "⚠️")
console.print(
f"[{color}]📢 Notification: {status.status} {icon} - {status.statusMessage}[/{color}]"
)
@app.default
async def task(
duration: Annotated[
int,
cyclopts.Parameter(help="Duration of computation in seconds (1-60)"),
] = 10,
):
"""Execute as background task with real-time progress callbacks."""
if duration < 1 or duration > 60:
console.print("[red]Error: Duration must be between 1 and 60 seconds[/red]")
sys.exit(1)
server = load_server()
console.print(f"\n[bold]Calling slow_computation(duration={duration})[/bold]")
console.print("Mode: [cyan]Background task[/cyan]\n")
async with Client(server) as client:
task_obj = await client.call_tool(
"slow_computation",
arguments={"duration": duration},
task=True,
)
console.print(f"Task started: [cyan]{task_obj.task_id}[/cyan]\n")
# Register callback for real-time push notifications
task_obj.on_status_change(print_notification)
console.print(
"[dim]Notifications will appear as the server sends them...[/dim]\n"
)
# Do other work while task runs in background
for i in range(3):
await asyncio.sleep(0.5)
console.print(f"[dim]Client doing other work... ({i + 1}/3)[/dim]")
console.print()
# Wait for task to complete
console.print("[dim]Waiting for final result...[/dim]")
result = await task_obj.result()
console.print("\n[bold]Result:[/bold]")
assert isinstance(result.content[0], TextContent)
console.print(f" {result.content[0].text}")
@app.command
async def immediate(
duration: Annotated[
int,
cyclopts.Parameter(help="Duration of computation in seconds (1-60)"),
] = 5,
):
"""Execute the tool immediately (blocks until complete)."""
if duration < 1 or duration > 60:
console.print("[red]Error: Duration must be between 1 and 60 seconds[/red]")
sys.exit(1)
server = load_server()
console.print(f"\n[bold]Calling slow_computation(duration={duration})[/bold]")
console.print("Mode: [cyan]Immediate execution[/cyan]\n")
async with Client(server) as client:
result = await client.call_tool(
"slow_computation",
arguments={"duration": duration},
)
console.print("\n[bold]Result:[/bold]")
assert isinstance(result.content[0], TextContent)
console.print(f" {result.content[0].text}")
if __name__ == "__main__":
app()

View file

@ -0,0 +1,10 @@
services:
redis:
image: redis:7-alpine
ports:
- "24242:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5

75
examples/tasks/server.py Normal file
View file

@ -0,0 +1,75 @@
"""
FastMCP Tasks Example Server
Demonstrates background task execution with progress tracking using Docket.
Setup:
1. Start Redis: docker compose up -d
2. Load environment: source .envrc
3. Run server: fastmcp run server.py
The example uses Redis by default to demonstrate distributed task execution
and the fastmcp tasks CLI commands.
"""
import asyncio
import logging
from typing import Annotated
from docket import Logged
from fastmcp import FastMCP
from fastmcp.dependencies import Progress
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Create server
mcp = FastMCP("Tasks Example")
@mcp.tool(task=True)
async def slow_computation(
duration: Annotated[int, Logged],
progress: Progress = Progress(),
) -> str:
"""
Perform a slow computation that takes `duration` seconds.
This tool demonstrates progress tracking with background tasks.
It logs progress every 1-2 seconds and reports progress via Docket.
Args:
duration: Number of seconds the computation should take (1-60)
Returns:
A completion message with the total duration
"""
if duration < 1 or duration > 60:
raise ValueError("Duration must be between 1 and 60 seconds")
logger.info(f"Starting slow computation for {duration} seconds")
# Set total progress units
await progress.set_total(duration)
# Process each second
for i in range(duration):
# Sleep for 1 second
await asyncio.sleep(1)
# Update progress
elapsed = i + 1
remaining = duration - elapsed
await progress.increment()
await progress.set_message(
f"Working... {elapsed}/{duration}s ({remaining}s remaining)"
)
# Log every 1-2 seconds
if elapsed % 2 == 0 or elapsed == duration:
logger.info(f"Progress: {elapsed}/{duration}s")
logger.info(f"Completed computation in {duration} seconds")
return f"Computation completed successfully in {duration} seconds!"