Add SEP-2663 client half: transparent call_tool, ResultClaim, Task handle

A FastMCP client now transparently completes tasked tools/call: the tasks
ClientExtension advertises the capability and claims the CreateTaskResult, and
the resolver drives the tasks/get poll loop to completion, answering in-task
input through the client's elicitation handler and returning the tool's real
result. call_tool is transparent, call_tool_mcp exposes the raw result, and
call_tool_task yields a Task handle. The client half moves to fastmcp-tasks;
the [tasks] client extension auto-wires into Client (ProxyClient opts out).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeremiah Lowin 2026-07-22 15:21:10 -04:00
commit 74e01d5e08
No known key found for this signature in database
23 changed files with 1119 additions and 2247 deletions

View file

@ -1,8 +1,16 @@
"""
Background task elicitation demo.
Background task input demo (SEP-2663 guard pattern).
A background task (Docket) that pauses mid-execution to ask the user a
question, waits for the answer, then resumes and finishes.
A background task that pauses to ask the user a question, waits for the answer,
then resumes and finishes. Under SEP-2663 a task gathers input by the *guard
pattern*: instead of awaiting `ctx.elicit()` (which would block a worker), the
tool *returns* an `InputRequiredResult`. That ends the leg; the client answers
via the tasks protocol; the framework re-runs the tool with the answer on
`ctx.input_responses`. No worker is ever blocked.
The client side is transparent: `client.call_tool(...)` drives the whole
round-trip poll, answer via the `elicitation_handler`, poll again and returns
the finished result.
Works with both in-memory and Redis backends:
@ -22,13 +30,15 @@ Requires the `docket` extra (included in dev dependencies).
import asyncio
from dataclasses import dataclass
import mcp_types
from mcp_types import TextContent
from fastmcp import Context, FastMCP
from fastmcp.client import Client
from fastmcp.server.elicitation import AcceptedElicitation
from fastmcp_tasks import TasksExtension
mcp = FastMCP("Task Elicitation Demo")
mcp.add_extension(TasksExtension())
@dataclass
@ -37,44 +47,60 @@ class DinnerPrefs:
vegetarian: bool
@mcp.tool(task=True)
async def plan_dinner(ctx: Context) -> str:
"""Plan a dinner menu, asking the user what they're in the mood for."""
await ctx.report_progress(0, 2, "Asking what you'd like...")
result = await ctx.elicit(
"What kind of dinner are you in the mood for?",
response_type=DinnerPrefs,
def _ask_dinner_prefs() -> mcp_types.InputRequiredResult:
"""Return the input request that pauses the task until the client answers."""
request = mcp_types.ElicitRequest(
params=mcp_types.ElicitRequestFormParams(
message="What kind of dinner are you in the mood for?",
requested_schema={
"type": "object",
"properties": {
"cuisine": {"type": "string"},
"vegetarian": {"type": "boolean"},
},
"required": ["cuisine", "vegetarian"],
},
)
)
return mcp_types.InputRequiredResult(
result_type="input_required",
input_requests={"prefs": request},
)
if not isinstance(result, AcceptedElicitation):
@mcp.tool(task=True)
async def plan_dinner(ctx: Context) -> str | mcp_types.InputRequiredResult:
"""Plan a dinner menu, asking the user what they're in the mood for."""
responses = ctx.input_responses
if responses is None:
# First leg: ask for preferences and end the leg.
return _ask_dinner_prefs()
# Re-entered leg: the client's answer is on ctx.input_responses.
answer = responses["prefs"]
assert isinstance(answer, mcp_types.ElicitResult)
if answer.action != "accept" or answer.content is None:
return "Dinner cancelled!"
prefs = result.data
assert isinstance(prefs, DinnerPrefs)
await ctx.report_progress(1, 2, "Planning your menu...")
await asyncio.sleep(1)
await ctx.report_progress(2, 2, "Done!")
veg = "vegetarian " if prefs.vegetarian else ""
return f"Tonight's menu: a lovely {veg}{prefs.cuisine} dinner!"
await asyncio.sleep(1) # "planning the menu"
veg = "vegetarian " if answer.content["vegetarian"] else ""
return f"Tonight's menu: a lovely {veg}{answer.content['cuisine']} dinner!"
async def handle_elicitation(message, response_type, params, context):
"""Handle elicitation requests from background tasks."""
"""Answer elicitation requests raised by the background task."""
print(f" Server asks: {message}")
print(" Responding with: cuisine=Thai, vegetarian=True")
return DinnerPrefs(cuisine="Thai", vegetarian=True)
async def main():
async with Client(mcp, elicitation_handler=handle_elicitation) as client:
print("Starting background task...")
task = await client.call_tool("plan_dinner", {}, task=True)
print(f" task_id = {task.task_id}\n")
result = await task.result()
client = Client(mcp, mode="auto", elicitation_handler=handle_elicitation)
async with client:
print("Calling plan_dinner (runs as a background task)...")
# call_tool drives the whole round-trip transparently: it polls, answers
# the task's input request via handle_elicitation, and returns the result.
result = await client.call_tool("plan_dinner", {})
assert isinstance(result.content[0], TextContent)
print(f"\nResult: {result.content[0].text}")

View file

@ -1,18 +1,23 @@
"""
FastMCP Tasks Example Client
FastMCP Tasks Example Client (SEP-2663)
Demonstrates calling tools both immediately and as background tasks,
with real-time progress updates via status callbacks.
Demonstrates the two client task surfaces:
- Transparent: `client.call_tool(...)` drives the background task to completion
under the hood and returns the tool's real result. The caller writes ordinary
tool-call code and never sees that the server ran the call as a task.
- Explicit handle: `call_tool_task(...)` returns a `ToolTask` immediately, so the
client can do other work and poll the task itself before collecting the result.
Usage:
# Make sure environment is configured (source .envrc or use direnv)
source .envrc
# Background task execution with progress callbacks (default)
# Transparent background task (default)
python client.py --duration 10
# Immediate execution (blocks until complete)
python client.py immediate --duration 5
# Return-quickly handle, driven by the client
python client.py handle --duration 5
"""
import asyncio
@ -21,10 +26,11 @@ from pathlib import Path
from typing import Annotated
import cyclopts
from mcp_types import GetTaskResult, TextContent
from mcp_types import TextContent
from rich.console import Console
from fastmcp.client import Client
from fastmcp_tasks import call_tool_task
console = Console()
app = cyclopts.App(name="tasks-client", help="FastMCP Tasks Example Client")
@ -41,52 +47,14 @@ def load_server():
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 status_message 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.status_message == _last_notification_message:
return
_last_notification_message = status.status_message
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.status_message}[/{color}]"
)
@app.default
async def task(
async def transparent(
duration: Annotated[
int,
cyclopts.Parameter(help="Duration of computation in seconds (1-60)"),
] = 10,
):
"""Execute as background task with real-time progress callbacks."""
"""Call the tool transparently: the client drives the task to completion."""
if duration < 1 or duration > 60:
console.print("[red]Error: Duration must be between 1 and 60 seconds[/red]")
sys.exit(1)
@ -94,58 +62,11 @@ async def task(
server = load_server()
console.print(f"\n[bold]Calling slow_computation(duration={duration})[/bold]")
console.print("Mode: [cyan]Background task[/cyan]\n")
console.print("Mode: [cyan]Transparent (server may run it as a 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:
# mode="auto" negotiates the modern protocol, so the server may run the call
# as a background task; the client resolves it transparently.
async with Client(server, mode="auto") as client:
result = await client.call_tool(
"slow_computation",
arguments={"duration": duration},
@ -156,5 +77,48 @@ async def immediate(
console.print(f" {result.content[0].text}")
@app.command
async def handle(
duration: Annotated[
int,
cyclopts.Parameter(help="Duration of computation in seconds (1-60)"),
] = 5,
):
"""Use the explicit handle: return immediately, then drive the task."""
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]Explicit ToolTask handle[/cyan]\n")
async with Client(server, mode="auto") as client:
task = await call_tool_task(
client,
"slow_computation",
arguments={"duration": duration},
)
console.print(f"Task started: [cyan]{task.task_id}[/cyan]\n")
# Do other work while the task runs in the background.
for i in range(3):
await asyncio.sleep(0.5)
status = await task.status()
console.print(
f"[dim]Client doing other work... ({i + 1}/3) "
f"— task is {status.status}[/dim]"
)
console.print("\n[dim]Waiting for the final result...[/dim]")
result = await task.result()
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

@ -20,13 +20,17 @@ from docket import Logged
from fastmcp import FastMCP
from fastmcp.dependencies import Progress
from fastmcp_tasks import TasksExtension
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Create server
# Create server and enable background tasks (SEP-2663). The extension reads the
# FASTMCP_DOCKET_* environment for its backend (memory:// by default, Redis for
# distributed execution).
mcp = FastMCP("Tasks Example")
mcp.add_extension(TasksExtension())
@mcp.tool(task=True)