mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
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:
parent
d41ff5bcd8
commit
74e01d5e08
23 changed files with 1119 additions and 2247 deletions
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue