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)

View file

@ -41,7 +41,11 @@ from mcp.client.extension import (
NotificationBinding,
ResultClaim,
)
from mcp.client.session import ClientRequestContext, MessageHandlerFnT
from mcp.client.session import (
ClientRequestContext,
ElicitationFnT,
MessageHandlerFnT,
)
from mcp_types.methods import validate_server_result
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
from pydantic import AnyUrl, ValidationError
@ -52,6 +56,7 @@ from fastmcp.client.elicitation import (
ElicitationHandler,
create_elicitation_callback,
)
from fastmcp.client.extension_hooks import build_internal_client_extensions
from fastmcp.client.logging import (
LogHandler,
create_log_callback,
@ -334,6 +339,13 @@ class Client(
```
"""
#: Whether FastMCP-internal client extensions (e.g. the tasks extension) are
#: folded in automatically at construction. `ProxyClient` overrides this to
#: `False`: a proxy forwards calls and must not advertise task support to its
#: backend, since proxied tools run synchronously (forbidden mode) and the
#: proxy has no path to drive a backend task on the front connection's behalf.
_auto_internal_extensions: bool = True
@overload
def __init__(self: Client[T], transport: T, *args: Any, **kwargs: Any) -> None: ...
@ -504,6 +516,16 @@ class Client(
# `_build_extension_kwargs`.
self._claim_by_model: dict[type[mcp_types.Result], ResultClaim[Any]] = {}
# Build the elicitation callback up front: it is threaded both into the
# session (to answer server-initiated elicitation) and into the internal
# client extensions (so a task resolver can answer in-task input), and
# `_build_extension_kwargs` — called below — needs it.
self._elicitation_callback: ElicitationFnT | None = (
create_elicitation_callback(elicitation_handler)
if elicitation_handler is not None
else None
)
self._session_kwargs: SessionKwargs = {
"sampling_callback": None,
"list_roots_callback": None,
@ -527,10 +549,8 @@ class Client(
else mcp_types.SamplingCapability()
)
if elicitation_handler is not None:
self._session_kwargs["elicitation_callback"] = create_elicitation_callback(
elicitation_handler
)
if self._elicitation_callback is not None:
self._session_kwargs["elicitation_callback"] = self._elicitation_callback
# Maximum time to wait for a clean disconnect before giving up.
# Normally disconnects complete in <100ms; this is a safety net for
@ -1191,8 +1211,31 @@ class Client(
Also rebuilds `self._claim_by_model`, the modelclaim index the resolution
path uses to finish a claimed `tools/call` result, covering both the folded
extension claims and the explicit `result_claims` extras.
FastMCP-internal extensions (e.g. the tasks extension from `fastmcp-tasks`,
registered via `register_internal_client_extension_factory`) are folded in
automatically so an ordinary `Client` transparently drives a server's
background tasks. They lead the fold order; a user extension declaring the
same identifier wins, so the internal one is dropped rather than colliding.
"""
folded = _fold_extensions(self._extensions_arg)
user_extensions = list(self._extensions_arg or ())
user_identifiers = {
identifier
for extension in user_extensions
if (identifier := getattr(extension, "identifier", None)) is not None
}
internal_extensions = (
[
extension
for extension in build_internal_client_extensions(
self._elicitation_callback
)
if extension.identifier not in user_identifiers
]
if self._auto_internal_extensions
else []
)
folded = _fold_extensions([*internal_extensions, *user_extensions])
claims: dict[str, tuple[ResultClaim[Any], ...]] = dict(folded.claims or {})
by_model: dict[type[mcp_types.Result], ResultClaim[Any]] = dict(folded.by_model)

View file

@ -0,0 +1,65 @@
"""Registry for FastMCP-internal client extensions (SEP-2133).
Core ships the client wiring for opt-in extensions but no extension of its own.
A companion package (``fastmcp-tasks``) provides an extension the ``Client``
should register *automatically* so an ordinary ``Client(url)`` transparently
drives a server's background tasks without the caller passing anything. The
package cannot reach into core's ``Client`` constructor, so core exposes this
hook instead: the package registers a factory on import, and ``Client`` folds
the factory's extension in alongside the user's own.
This mirrors the server-side ``set_background_context_factory`` hook: core
declares the extension point, the tasks package fills it. With no package
imported, the registry is empty and ``Client`` behaves exactly as before.
A factory receives the client's elicitation callback (so a task resolver can
answer in-task input prompts) and returns a ``ClientExtension`` to register, or
``None`` to contribute nothing for this client.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from mcp.client.extension import ClientExtension
from mcp.client.session import ElicitationFnT
#: A factory that builds a FastMCP-internal client extension for one ``Client``,
#: given that client's elicitation callback (``None`` when the client has no
#: elicitation handler).
InternalClientExtensionFactory = Callable[
["ElicitationFnT | None"], "ClientExtension | None"
]
_internal_client_extension_factories: list[InternalClientExtensionFactory] = []
def register_internal_client_extension_factory(
factory: InternalClientExtensionFactory,
) -> None:
"""Register a factory whose extension every ``Client`` folds in automatically.
Idempotent: registering the same factory object twice is a no-op, so a
package importing more than once does not double-register.
"""
if factory not in _internal_client_extension_factories:
_internal_client_extension_factories.append(factory)
def build_internal_client_extensions(
elicitation_callback: ElicitationFnT | None,
) -> list[ClientExtension]:
"""Build the internal extensions to fold into a ``Client`` under construction.
Each registered factory is invoked with the client's elicitation callback;
factories that return ``None`` contribute nothing. Empty when no package has
registered a factory (plain core).
"""
extensions: list[ClientExtension] = []
for factory in _internal_client_extension_factories:
extension = factory(elicitation_callback)
if extension is not None:
extensions.append(extension)
return extensions

View file

@ -1353,6 +1353,12 @@ class ProxyClient(Client[ClientTransportT]):
_proxy_rc_ref: list[Any]
_proxy_restoring_handler_keys: set[str]
# A proxy forwards calls; it must not advertise task support to its backend.
# Proxied tools run synchronously (forbidden mode), and the proxy has no path
# to drive a backend task on the front connection's behalf, so the internal
# tasks client extension is not folded into a proxy's backend client.
_auto_internal_extensions: bool = False
def __init__(
self,
transport: ClientTransportT

View file

@ -181,26 +181,6 @@ class Settings(BaseSettings):
),
] = 5
# May move to the fastmcp-tasks package alongside the client task senders
# when client task support is rebuilt on the SEP-2663 extension.
client_task_poll_interval: Annotated[
float,
Field(
description=inspect.cleandoc(
"""
Ceiling, in seconds, for the fallback poll backoff while waiting on a
background task (SEP-1686). Applies only when the server does not
advertise its own pollInterval: in that case Task.wait() starts polling
fast (~20ms) and doubles up to this ceiling, so quick tasks resolve
promptly while long-running tasks don't hammer the server. When the
server does advertise a pollInterval, that interval is honored exactly
and this setting is ignored. Must be positive.
"""
),
gt=0,
),
] = 0.5
# Transport settings
transport: Literal["stdio", "http", "sse", "streamable-http"] = "stdio"

View file

@ -2,6 +2,8 @@
from importlib.metadata import PackageNotFoundError, version
from fastmcp.client.extension_hooks import register_internal_client_extension_factory
from fastmcp_tasks.client import ToolTask, _build_tasks_client_extension, call_tool_task
from fastmcp_tasks.extension import TasksExtension
try:
@ -9,4 +11,10 @@ try:
except PackageNotFoundError:
__version__ = "0.0.0"
__all__ = ["TasksExtension", "__version__"]
# Register the client half so every FastMCP `Client` transparently drives a
# task-serving backend's background tasks (see `fastmcp_tasks.client`). Importing
# this package — which any task deployment does, server or client side — is what
# turns on client task support.
register_internal_client_extension_factory(_build_tasks_client_extension)
__all__ = ["TasksExtension", "ToolTask", "call_tool_task", "__version__"]

View file

@ -1,232 +0,0 @@
"""Task management methods for FastMCP Client."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
import mcp_types
from mcp import MCPError
from mcp_types import Result
from pydantic import ConfigDict
if TYPE_CHECKING:
from fastmcp.client.client import Client
from mcp_types import (
CancelTaskRequest,
CancelTaskRequestParams,
GetTaskPayloadRequest,
GetTaskPayloadRequestParams,
GetTaskRequest,
GetTaskRequestParams,
GetTaskResult,
ListTasksRequest,
PaginatedRequestParams,
)
from fastmcp.client.telemetry import client_span
from fastmcp.telemetry import inject_trace_context
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
class _RawTaskPayloadResult(Result):
"""Permissive result type for `tasks/result` responses.
Per the v2 spec, a `tasks/result` payload arrives as extra wire fields whose
shape matches the original request's result type (CallToolResult,
GetPromptResult, ReadResourceResult, ...). `GetTaskPayloadResult` is a bare
`Result` that drops those fields on validation, so this subclass retains them
with `extra="allow"`; callers re-parse the resulting dict into the concrete
result type.
"""
model_config = ConfigDict(
alias_generator=Result.model_config.get("alias_generator"),
populate_by_name=True,
extra="allow",
)
class ClientTaskManagementMixin:
"""Mixin providing task management methods for Client."""
async def get_task_status(self: Client, task_id: str) -> GetTaskResult:
"""Query the status of a background task.
Sends a 'tasks/get' MCP protocol request over the existing transport.
Args:
task_id: The task ID returned from call_tool_as_task
Returns:
GetTaskResult: Status information including taskId, status, pollInterval, etc.
Raises:
RuntimeError: If client not connected
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
with client_span(
"tasks/get",
"tasks/get",
task_id,
session_id=self.transport.get_session_id(),
):
request_meta = cast(
"mcp_types.RequestParamsMeta | None", inject_trace_context()
)
request = GetTaskRequest(
params=GetTaskRequestParams(
task_id=task_id,
_meta=request_meta, # type: ignore[unknown-argument]
)
)
return await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[arg-type]
result_type=GetTaskResult,
)
)
async def get_task_result(self: Client, task_id: str) -> Any:
"""Retrieve the raw result of a completed background task.
Sends a 'tasks/result' MCP protocol request over the existing transport.
Returns the raw result - callers should parse it appropriately.
Args:
task_id: The task ID returned from call_tool_as_task
Returns:
Any: The raw result (could be tool, prompt, or resource result)
Raises:
RuntimeError: If client not connected, task not found, or task failed
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
with client_span(
"tasks/result",
"tasks/result",
task_id,
session_id=self.transport.get_session_id(),
):
request_meta = cast(
"mcp_types.RequestParamsMeta | None", inject_trace_context()
)
request = GetTaskPayloadRequest(
params=GetTaskPayloadRequestParams(
task_id=task_id,
_meta=request_meta, # type: ignore[unknown-argument]
)
)
# Return raw result - Task classes handle type-specific parsing
result = await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[arg-type]
result_type=_RawTaskPayloadResult,
)
)
# Return as dict for compatibility with Task class parsing. The payload
# fields (content, structuredContent, messages, contents, ...) survive
# via the permissive result type's extra="allow".
return result.model_dump(exclude_none=True, by_alias=True)
async def list_tasks(
self: Client,
cursor: str | None = None,
limit: int = 50,
) -> dict[str, Any]:
"""List background tasks.
Sends a 'tasks/list' MCP protocol request to the server. If the server
returns an empty list (indicating client-side tracking), falls back to
querying status for locally tracked task IDs.
Args:
cursor: Optional pagination cursor
limit: Maximum number of tasks to return (default 50)
Returns:
dict: Response with structure:
- tasks: List of task status dicts with taskId, status, etc.
- nextCursor: Optional cursor for next page
Raises:
RuntimeError: If client not connected
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
with client_span(
"tasks/list",
"tasks/list",
"",
session_id=self.transport.get_session_id(),
):
request_meta = cast(
"mcp_types.RequestParamsMeta | None", inject_trace_context()
)
# Send protocol request
params = PaginatedRequestParams.model_validate(
{"cursor": cursor, "limit": limit, "_meta": request_meta}
)
request = ListTasksRequest(params=params)
server_response = await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[invalid-argument-type]
result_type=mcp_types.ListTasksResult,
)
)
# If server returned tasks, use those
if server_response.tasks:
return server_response.model_dump(by_alias=True)
# Server returned empty - fall back to client-side tracking
tasks = []
for task_id in list(self._submitted_task_ids)[:limit]: # ty: ignore[unresolved-attribute]
try:
status = await self.get_task_status(task_id) # ty: ignore[unresolved-attribute]
tasks.append(status.model_dump(by_alias=True))
except MCPError:
# Task may have expired or been deleted, skip it
continue
return {"tasks": tasks, "nextCursor": None}
async def cancel_task(self: Client, task_id: str) -> mcp_types.CancelTaskResult:
"""Cancel a task, transitioning it to cancelled state.
Sends a 'tasks/cancel' MCP protocol request. Task will halt execution
and transition to cancelled state.
Args:
task_id: The task ID to cancel
Returns:
CancelTaskResult: The task status showing cancelled state
Raises:
RuntimeError: If task doesn't exist
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
with client_span(
"tasks/cancel",
"tasks/cancel",
task_id,
session_id=self.transport.get_session_id(),
):
request_meta = cast(
"mcp_types.RequestParamsMeta | None", inject_trace_context()
)
request = CancelTaskRequest(
params=CancelTaskRequestParams(
task_id=task_id,
_meta=request_meta, # type: ignore[unknown-argument]
)
)
return await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[invalid-argument-type]
result_type=mcp_types.CancelTaskResult,
)
)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,130 @@
"""Client-side wire models for the SEP-2663 tasks extension.
These mirror the server models in ``models.py`` but flip the alias direction:
the server *produces* the wire (``serialization_alias`` -> camelCase dump), while
the client *consumes* it. The SDK validates both a claimed ``tools/call`` result
and a ``tasks/get`` response with ``model_validate(raw, by_name=False)``, so these
models declare **validation** aliases (``Field(alias="taskId")``) to read the
camelCase wire keys.
``ClientCreateTaskResult`` is the claim shape the tasks ``ResultClaim`` resolves.
It must subclass ``mcp_types.Result`` (not ``CallToolResult`` /
``InputRequiredResult``) and pin ``result_type`` to ``Literal["task"]`` the
SDK's ``ResultClaim.__post_init__`` enforces exactly this. ``ClientGetTaskResult``
is the typed ``tasks/get`` response: the flat task fields plus exactly one of
``result`` (completed), ``error`` (failed), or ``inputRequests`` (input_required).
"""
from __future__ import annotations
from typing import Any, Literal
import mcp_types
from mcp_types import RequestParams, Result
from pydantic import ConfigDict, Field
__all__ = [
"TaskStatus",
"ClientCreateTaskResult",
"ClientGetTaskResult",
"GetTaskRequest",
"GetTaskRequestParams",
"UpdateTaskRequest",
"UpdateTaskRequestParams",
"CancelTaskRequest",
"CancelTaskRequestParams",
]
TaskStatus = Literal["working", "input_required", "completed", "failed", "cancelled"]
class _ClientTaskFields(Result):
"""The flat task fields shared by every SEP-2663 task result, read from the wire.
Validation aliases (camelCase) because the SDK validates the server's
``model_dump(by_alias=True)`` output with ``by_name=False``.
"""
model_config = ConfigDict(populate_by_name=True)
task_id: str = Field(alias="taskId")
status: TaskStatus
created_at: str = Field(alias="createdAt")
last_updated_at: str = Field(alias="lastUpdatedAt")
ttl_ms: float | None = Field(default=None, alias="ttlMs")
status_message: str | None = Field(default=None, alias="statusMessage")
poll_interval_ms: float | None = Field(default=None, alias="pollIntervalMs")
class ClientCreateTaskResult(_ClientTaskFields):
"""The claimed ``tools/call`` result the server returns when it runs a call as a task.
Pinned to ``resultType: "task"`` so the tasks ``ResultClaim`` can key on it.
The resolver polls ``tasks/get`` from here to the finished result.
"""
result_type: Literal["task"] = Field(alias="resultType")
class ClientGetTaskResult(_ClientTaskFields):
"""The typed ``tasks/get`` response: task fields plus the inlined outcome.
Exactly one of ``result`` / ``error`` / ``input_requests`` is set, matching
the task's status. ``result_type`` is ``"complete"`` because ``tasks/get``
itself always completes normally, whatever the task's own status.
"""
result_type: Literal["complete"] = Field(alias="resultType")
result: dict[str, Any] | None = None
error: dict[str, Any] | None = None
input_requests: dict[str, Any] | None = Field(default=None, alias="inputRequests")
class GetTaskRequestParams(RequestParams):
"""Params for ``tasks/get`` / ``tasks/cancel``: the target task id.
These are outbound (client -> server), so they carry *serialization* aliases:
the client constructs them by field name and `send_request` dumps them to the
camelCase wire shape with `by_alias=True`.
"""
model_config = ConfigDict(populate_by_name=True)
task_id: str = Field(serialization_alias="taskId")
CancelTaskRequestParams = GetTaskRequestParams
class UpdateTaskRequestParams(RequestParams):
"""Params for ``tasks/update``: task id plus the caller's input responses."""
model_config = ConfigDict(populate_by_name=True)
task_id: str = Field(serialization_alias="taskId")
input_responses: dict[str, Any] = Field(serialization_alias="inputResponses")
class GetTaskRequest(mcp_types.Request[GetTaskRequestParams, Literal["tasks/get"]]):
"""``tasks/get`` request envelope for ``ClientSession.send_request``."""
method: Literal["tasks/get"] = "tasks/get"
params: GetTaskRequestParams
class UpdateTaskRequest(
mcp_types.Request[UpdateTaskRequestParams, Literal["tasks/update"]]
):
"""``tasks/update`` request envelope for ``ClientSession.send_request``."""
method: Literal["tasks/update"] = "tasks/update"
params: UpdateTaskRequestParams
class CancelTaskRequest(
mcp_types.Request[CancelTaskRequestParams, Literal["tasks/cancel"]]
):
"""``tasks/cancel`` request envelope for ``ClientSession.send_request``."""
method: Literal["tasks/cancel"] = "tasks/cancel"
params: CancelTaskRequestParams

View file

@ -120,3 +120,38 @@ class DocketSettings(BaseSettings):
docket_settings = DocketSettings()
class TasksClientSettings(BaseSettings):
"""Client-side settings for driving background tasks.
Moved here from core ``fastmcp.settings`` during the SEP-1686 -> SEP-2663
migration: the entire client task-driving path now lives in
``fastmcp-tasks``, so its one tunable does too.
"""
model_config = SettingsConfigDict(
env_prefix="FASTMCP_TASKS_CLIENT_",
extra="ignore",
)
poll_interval: Annotated[
float,
Field(
description=inspect.cleandoc(
"""
Ceiling, in seconds, for the fallback poll backoff while the client
waits on a background task. Applies only when the server does not
advertise its own pollIntervalMs: in that case the client starts
polling fast (~20ms) and doubles up to this ceiling, so quick tasks
resolve promptly while long-running tasks don't hammer the server.
When the server advertises a pollIntervalMs, that interval is honored
exactly and this setting is ignored. Must be positive.
"""
),
gt=0,
),
] = 0.5
client_settings = TasksClientSettings()

View file

@ -155,9 +155,6 @@ exclude = [
"examples/providers/sqlite", # needs aiosqlite
"examples/memory.py", # needs asyncpg, numpy, pydantic_ai, pgvector
"examples/get_file.py", # needs aiohttp
# Skipped pending client task support; rewritten in the client-task follow-up.
"tests/tasks/client/test_task_context_validation.py",
"tests/tasks/client/test_task_result_caching.py",
]
[tool.ty.environment]

View file

@ -7,7 +7,6 @@ from typing import Any, cast
import anyio
import pytest
from fastmcp_tasks.client import TaskNotificationHandler
from mcp import ClientSession, MCPError
from mcp_types import TextContent
from pydantic import AnyUrl
@ -886,34 +885,19 @@ async def test_client_list_dict_return_type():
assert result.data == [{"city": "NYC", "temp": 72}, {"city": "LA", "temp": 85}]
@pytest.mark.skip(reason="Phase 4: requires client task support (SEP-2663)")
def test_client_new_resets_mutable_task_state(fastmcp_server):
"""Client.new() should not share mutable task tracking structures."""
client = Client(transport=FastMCPTransport(fastmcp_server))
def test_client_new_preserves_internal_task_extension(fastmcp_server):
"""Client.new() rebuilds the clone with the auto-registered tasks claim.
client._task_registry["task-1"] = lambda: None # type: ignore[assignment] # ty: ignore
client._submitted_task_ids.add("task-1") # ty: ignore
The tasks client extension (from fastmcp-tasks, imported above) is folded into
every Client automatically; a clone must carry it too so tasked calls still
resolve transparently on the clone.
"""
from fastmcp_tasks.client_models import ClientCreateTaskResult
client = Client(transport=FastMCPTransport(fastmcp_server))
assert ClientCreateTaskResult in client._claim_by_model
clone = client.new()
assert clone is not client
assert clone._task_registry == {} # ty: ignore
assert clone._submitted_task_ids == set() # ty: ignore
assert clone._task_registry is not client._task_registry # ty: ignore
assert clone._submitted_task_ids is not client._submitted_task_ids # ty: ignore
@pytest.mark.skip(reason="Phase 4: requires client task support (SEP-2663)")
def test_client_new_rebinds_default_task_notification_handler(fastmcp_server):
"""Client.new() should bind the default task handler to the cloned client."""
client = Client(transport=FastMCPTransport(fastmcp_server))
handler = client._session_kwargs.get("message_handler")
assert isinstance(handler, TaskNotificationHandler)
clone = client.new()
clone_handler = clone._session_kwargs.get("message_handler")
assert isinstance(clone_handler, TaskNotificationHandler)
assert clone_handler is not handler
assert clone_handler._client_ref() is clone
assert ClientCreateTaskResult in clone._claim_by_model
assert clone._claim_by_model is not client._claim_by_model

View file

@ -1,98 +0,0 @@
"""Tests for client OpenTelemetry tracing on task operations."""
import asyncio
import pytest
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
InMemorySpanExporter,
)
from opentelemetry.trace import SpanKind
from fastmcp import Client, FastMCP
pytestmark = pytest.mark.skip(reason="Phase 4: requires client task support (SEP-2663)")
def assert_propagating_client_span(
trace_exporter: InMemorySpanExporter,
method: str,
component_key: str,
) -> None:
all_spans = trace_exporter.get_finished_spans()
spans = [span for span in all_spans if span.name == method]
client_span = next(
span
for span in spans
if span.attributes is not None and "fastmcp.server.name" not in span.attributes
)
server_span = next(
span
for span in spans
if span.attributes is not None and "fastmcp.server.name" in span.attributes
)
assert client_span.kind == SpanKind.CLIENT
assert client_span.attributes is not None
assert client_span.attributes["mcp.method.name"] == method
assert client_span.attributes["fastmcp.component.key"] == component_key
assert server_span.parent is not None
assert server_span.context.trace_id == client_span.context.trace_id
spans_by_id = {span.context.span_id: span for span in all_spans}
current = server_span
while current.parent is not None:
parent = spans_by_id.get(current.parent.span_id)
assert parent is not None
if parent.context.span_id == client_span.context.span_id:
break
current = parent
else:
raise AssertionError("Server span should descend from the client span")
async def test_list_tasks_creates_propagating_client_span(
trace_exporter: InMemorySpanExporter,
):
server = FastMCP("test-server")
async with Client(server, mode="legacy") as client:
await client.list_tasks()
assert_propagating_client_span(trace_exporter, "tasks/list", "")
async def test_task_id_operations_create_propagating_client_spans(
trace_exporter: InMemorySpanExporter,
):
started = asyncio.Event()
server = FastMCP("test-server")
@server.tool(task=True)
async def quick_tool() -> str:
return "done"
@server.tool(task=True)
async def slow_tool() -> str:
started.set()
# Never completes on its own - the test cancels this task well
# before any real-time completion would matter.
await asyncio.Event().wait()
return "done"
async with Client(server, mode="legacy") as client:
completed_task = await client.call_tool("quick_tool", task=True)
await completed_task.wait(timeout=2)
trace_exporter.clear()
await client.get_task_status(completed_task.task_id)
await client.get_task_result(completed_task.task_id)
running_task = await client.call_tool("slow_tool", task=True)
await asyncio.wait_for(started.wait(), timeout=2)
await client.cancel_task(running_task.task_id)
assert_propagating_client_span(trace_exporter, "tasks/get", completed_task.task_id)
assert_propagating_client_span(
trace_exporter, "tasks/result", completed_task.task_id
)
assert_propagating_client_span(trace_exporter, "tasks/cancel", running_task.task_id)

View file

@ -1,17 +1,21 @@
"""Tests for surfacing SEP-2133 client extensions on ``fastmcp.Client``.
Covers that ``extensions=`` / ``result_claims=`` are folded into the underlying
``ClientSession`` kwargs on construction, that user-supplied notification
bindings *compose* with FastMCP's internal task-status binding rather than
clobbering it, that both bindings actually fire against a live server, and that
a claimed ``tools/call`` result is resolved end-to-end through the owning
extension's resolver.
``ClientSession`` kwargs on construction, that a claimed ``tools/call`` result is
resolved end-to-end through the owning extension's resolver, and that FastMCP's
internal tasks extension (from ``fastmcp-tasks``, imported below) is folded in
automatically and *composes* with a user's own extensions rather than being
clobbered by them.
Importing ``fastmcp_tasks`` registers the internal client extension factory
process-wide, so every ``Client`` built here carries the tasks capability ad and
its ``resultType: "task"`` claim. These tests assert that composition explicitly.
"""
import asyncio
from typing import Any, Literal
import pytest
from fastmcp_tasks.client_models import ClientCreateTaskResult
from mcp.client.extension import (
ClaimContext,
ClientExtension,
@ -26,12 +30,15 @@ from mcp_types import CallToolRequestParams, CallToolResult, Result, TextContent
from mcp_types.version import LATEST_MODERN_VERSION
from pydantic import BaseModel
# Importing the package registers the internal tasks client extension factory, so
# every Client below folds the tasks extension in. Kept as an explicit import so
# the composition assertions are deterministic regardless of test import order.
import fastmcp_tasks # noqa: F401
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.server.dependencies import get_context
from fastmcp.utilities.tasks import TASKS_EXTENSION_ID
CUSTOM_METHOD = "notifications/x-test/ping"
TASK_STATUS_METHOD = "notifications/tasks/status"
EXTENSION_ID = "test.example.com/demo"
CLAIMED_TYPE = "x-test/claimed"
@ -120,19 +127,19 @@ def _claiming_server() -> SDKServer:
return server
def _binding_methods(client: Client) -> list[str]:
bindings = client._session_kwargs.get("notification_bindings") or []
return [b.method for b in bindings]
def test_extension_folds_into_session_kwargs():
"""A ClientExtension's ad, claim, and binding reach the session kwargs."""
"""A ClientExtension's ad and claim reach the session kwargs, alongside tasks."""
client = Client(FastMCP("srv"), extensions=[_DemoExtension()])
assert client._session_kwargs.get("extensions") == {EXTENSION_ID: {"enabled": True}}
# The tasks extension is auto-folded in beside the user's own.
assert client._session_kwargs.get("extensions") == {
TASKS_EXTENSION_ID: {},
EXTENSION_ID: {"enabled": True},
}
result_claims = client._session_kwargs.get("result_claims")
assert result_claims is not None
assert [c.result_type for c in result_claims[EXTENSION_ID]] == [CLAIMED_TYPE]
assert [c.result_type for c in result_claims[TASKS_EXTENSION_ID]] == ["task"]
def test_extension_populates_claim_by_model_index():
@ -140,42 +147,62 @@ def test_extension_populates_claim_by_model_index():
client = Client(FastMCP("srv"), extensions=[_DemoExtension()])
assert client._claim_by_model[ClaimedResult].result_type == CLAIMED_TYPE
# The auto-folded tasks claim is indexed too.
assert client._claim_by_model[ClientCreateTaskResult].result_type == "task"
@pytest.mark.skip(reason="Phase 4: requires client task support (SEP-2663)")
def test_binding_composes_with_internal_task_binding():
"""User binding is appended to (not replacing) the task-status binding."""
client = Client(FastMCP("srv"), extensions=[_DemoExtension()])
methods = _binding_methods(client)
assert TASK_STATUS_METHOD in methods
assert CUSTOM_METHOD in methods
# The internal task binding must lead so user bindings extend it.
assert methods[0] == TASK_STATUS_METHOD
@pytest.mark.skip(reason="Phase 4: requires client task support (SEP-2663)")
def test_no_extensions_leaves_only_task_binding():
"""Without extensions, only the internal task-status binding is registered."""
def test_internal_tasks_extension_present_without_user_extensions():
"""Even with no user extensions, the tasks claim is auto-registered."""
client = Client(FastMCP("srv"))
assert _binding_methods(client) == [TASK_STATUS_METHOD]
assert "extensions" not in client._session_kwargs
assert "result_claims" not in client._session_kwargs
assert client._session_kwargs.get("extensions") == {TASKS_EXTENSION_ID: {}}
assert client._claim_by_model[ClientCreateTaskResult].result_type == "task"
def test_user_extension_composes_with_internal_tasks_extension():
"""A user extension is folded in beside the internal tasks extension."""
client = Client(FastMCP("srv"), extensions=[_DemoExtension()])
ad = client._session_kwargs.get("extensions") or {}
assert TASKS_EXTENSION_ID in ad
assert EXTENSION_ID in ad
# Both claims are resolvable.
assert set(client._claim_by_model) == {ClaimedResult, ClientCreateTaskResult}
def test_user_extension_may_override_internal_tasks_extension():
"""A user extension declaring the tasks identifier wins; the internal one drops.
Composition prefers the user's extension: rather than colliding on the shared
identifier (which the fold rejects), the internal tasks extension is dropped so
a power user can supply their own task-handling extension.
"""
class CustomTasks(ClientExtension):
identifier = TASKS_EXTENSION_ID
def settings(self) -> dict[str, Any]:
return {"custom": True}
client = Client(FastMCP("srv"), extensions=[CustomTasks()])
assert client._session_kwargs.get("extensions") == {
TASKS_EXTENSION_ID: {"custom": True}
}
# The user extension declares no claim, so no task claim is registered.
assert client._claim_by_model == {}
@pytest.mark.skip(reason="Phase 4: requires client task support (SEP-2663)")
def test_new_preserves_extension_composition():
"""new() rebuilds the clone with both the task binding and user bindings."""
"""new() rebuilds the clone with both the tasks extension and user extensions."""
client = Client(FastMCP("srv"), extensions=[_DemoExtension()])
clone = client.new()
methods = _binding_methods(clone)
assert methods[0] == TASK_STATUS_METHOD
assert CUSTOM_METHOD in methods
assert clone._session_kwargs.get("extensions") == {EXTENSION_ID: {"enabled": True}}
ad = clone._session_kwargs.get("extensions") or {}
assert TASKS_EXTENSION_ID in ad
assert EXTENSION_ID in ad
assert clone._claim_by_model[ClaimedResult].result_type == CLAIMED_TYPE
assert clone._claim_by_model[ClientCreateTaskResult].result_type == "task"
def test_result_claims_merge_with_extension_claims():
@ -203,80 +230,12 @@ def test_result_claims_merge_with_extension_claims():
assert result_claims is not None
tags = {c.result_type for c in result_claims[EXTENSION_ID]}
assert tags == {CLAIMED_TYPE, "x-test/extra"}
# Both the extension claim and the explicit extra claim are resolvable.
assert set(client._claim_by_model) == {ClaimedResult, ExtraClaimed}
@pytest.mark.skip(reason="Phase 4: requires client task support (SEP-2663)")
async def test_user_binding_clobbering_task_method_is_rejected():
"""A user extension binding the task-status method cannot silently replace it.
Composition means the internal task binding always leads; a user extension
that binds the same method collides with it, and the SDK session rejects the
duplicate at connect time rather than letting one silently win.
"""
class TaskClobberExtension(ClientExtension):
identifier = "test.example.com/clobber"
def notifications(self):
async def _handler(params: PingParams) -> None:
return None
return (
NotificationBinding(
method=TASK_STATUS_METHOD,
params_type=PingParams,
handler=_handler,
),
)
client = Client(FastMCP("srv"), extensions=[TaskClobberExtension()])
with pytest.raises(RuntimeError, match="duplicate notification binding"):
async with client:
pass
@pytest.mark.skip(reason="Phase 4: requires client task support (SEP-2663)")
async def test_both_bindings_fire_against_live_server():
"""The internal task binding and a user extension binding both fire.
A ``task=True`` tool drives ``notifications/tasks/status`` (the internal
binding) while a second tool emits a custom notification the user extension
observes, proving the two coexist on one live connection. Pinned to
``mode="legacy"`` because FastMCP task submission is a legacy-era feature.
"""
received: list[PingParams] = []
mcp = FastMCP("compose-server")
@mcp.tool
async def emit(value: int) -> int:
ctx = get_context()
# Emit a custom (non-core) notification straight onto the outbound
# channel; unknown methods route to the client's notification bindings.
await ctx.session._connection.notify(CUSTOM_METHOD, {"value": value})
return value
@mcp.tool(task=True)
async def background(value: int) -> int:
await asyncio.sleep(0.02)
return value * 2
client = Client(mcp, extensions=[_DemoExtension(received)], mode="legacy")
async with client:
# The user extension binding fires on the custom notification.
await client.call_tool("emit", {"value": 21})
# The internal task binding fires on the task-status notification.
task = await client.call_tool("background", {"value": 5}, task=True) # ty: ignore
status = await task.wait(timeout=2.0) # ty: ignore
# Give the custom-notification queue a moment to drain.
await asyncio.sleep(0.1)
# Internal task binding fired: the task completed via a status notification.
assert status.status == "completed"
# User extension binding fired: it observed the custom notification.
assert [p.value for p in received] == [21]
# The extension claim, the explicit extra claim, and the tasks claim resolve.
assert set(client._claim_by_model) == {
ClaimedResult,
ExtraClaimed,
ClientCreateTaskResult,
}
class TestClaimedResultResolution:

View file

@ -1,283 +0,0 @@
"""
Tests for client-side handling of notifications/tasks/status (SEP-1686 lines 436-444).
Verifies that Task objects receive notifications, update their cache, wake up wait() calls,
and invoke user callbacks.
"""
import asyncio
import time
from collections.abc import Callable
from datetime import datetime, timezone
import pytest
from mcp_types import GetTaskResult
from fastmcp import FastMCP
from fastmcp.client import Client
pytestmark = pytest.mark.skip(reason="Phase 4: requires client task support (SEP-2663)")
async def _wait_until(condition: Callable[[], bool], timeout: float = 5.0) -> None:
"""Poll until condition() is true or timeout elapses.
Used in place of a fixed sleep when waiting for an async callback or
notification to be delivered/dispatched after the awaited call returns.
"""
deadline = time.monotonic() + timeout
while not condition() and time.monotonic() < deadline:
await asyncio.sleep(0.005)
@pytest.fixture
async def task_notification_server():
"""Server that sends task status notifications."""
mcp = FastMCP("task-notification-test")
@mcp.tool(task=True)
async def quick_task(value: int) -> int:
"""Quick background task with a brief, measurable delay (contrast with instant_task)."""
await asyncio.sleep(0.01)
return value * 2
@mcp.tool(task=True)
async def instant_task(value: int) -> int:
"""Background task that completes with no delay."""
return value * 2
@mcp.tool(task=True)
async def failing_task() -> str:
"""Task that fails."""
raise ValueError("Intentional failure")
return mcp
async def test_task_receives_status_notification(task_notification_server):
"""Task object receives and processes status notifications."""
async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 5}, task=True)
# Wait for task to complete (notification should arrive)
status = await task.wait(timeout=2.0)
# Verify task completed
assert status.status == "completed"
async def test_status_cache_updated_by_notification(task_notification_server):
"""Cached status is updated when notification arrives."""
async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 10}, task=True)
# Wait for completion (notification should update cache)
await task.wait(timeout=2.0)
# Status should be cached (no server call needed)
# Call status() twice - should return same cached object
status1 = await task.status()
status2 = await task.status()
# Should be the exact same object (from cache)
assert status1 is status2
assert status1.status == "completed"
async def test_callback_invoked_on_notification(task_notification_server):
"""User callback is invoked when notification arrives."""
callback_invocations = []
def status_callback(status: GetTaskResult):
"""Sync callback."""
callback_invocations.append(status)
async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 7}, task=True)
# Register callback
task.on_status_change(status_callback)
# Wait for completion
await task.wait(timeout=2.0)
# Wait for the status this test actually asserts on. Waiting merely for
# "some callback fired" would be satisfied by the earlier `working`
# notification and race the `completed` one.
await _wait_until(
lambda: any(s.status == "completed" for s in callback_invocations)
)
# Callback should have been invoked at least once
assert len(callback_invocations) > 0
# Should have received completed status
completed_statuses = [s for s in callback_invocations if s.status == "completed"]
assert len(completed_statuses) > 0
async def test_async_callback_invoked(task_notification_server):
"""Async callback is invoked when notification arrives."""
callback_invocations = []
async def async_status_callback(status: GetTaskResult):
"""Async callback."""
await asyncio.sleep(0.01) # Simulate async work
callback_invocations.append(status)
async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 3}, task=True)
# Register async callback
task.on_status_change(async_status_callback)
# Wait for completion
await task.wait(timeout=2.0)
# Give async callbacks time to complete
await _wait_until(lambda: len(callback_invocations) > 0)
# Async callback should have been invoked
assert len(callback_invocations) > 0
async def test_multiple_callbacks_all_invoked(task_notification_server):
"""Multiple callbacks are all invoked."""
callback1_calls = []
callback2_calls = []
def callback1(status: GetTaskResult):
callback1_calls.append(status.status)
def callback2(status: GetTaskResult):
callback2_calls.append(status.status)
async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 8}, task=True)
task.on_status_change(callback1)
task.on_status_change(callback2)
await task.wait(timeout=2.0)
await _wait_until(lambda: bool(callback1_calls) and bool(callback2_calls))
# Both callbacks should have been invoked
assert len(callback1_calls) > 0
assert len(callback2_calls) > 0
async def test_callback_error_doesnt_break_notification(task_notification_server):
"""Callback errors don't prevent other callbacks from running."""
callback1_calls = []
callback2_calls = []
def failing_callback(status: GetTaskResult):
callback1_calls.append("called")
raise ValueError("Callback intentionally fails")
def working_callback(status: GetTaskResult):
callback2_calls.append(status.status)
async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 12}, task=True)
task.on_status_change(failing_callback)
task.on_status_change(working_callback)
await task.wait(timeout=2.0)
await _wait_until(lambda: bool(callback1_calls) and bool(callback2_calls))
# Failing callback was called (and errored)
assert len(callback1_calls) > 0
# Working callback should still have been invoked
assert len(callback2_calls) > 0
async def test_wait_wakes_early_on_notification(task_notification_server):
"""wait() wakes up immediately when notification arrives, not after poll interval."""
async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 15}, task=True)
# Record timing
start = time.time()
status = await task.wait(timeout=5.0)
elapsed = time.time() - start
# Should complete much faster than the fallback poll interval (500ms)
# With notifications, should be < 200ms for quick task
# Without notifications, would take 500ms+ due to polling
assert elapsed < 1.0 # Very generous bound
assert status.status == "completed"
async def test_notification_with_failed_task(task_notification_server):
"""Notifications work for failed tasks too."""
async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("failing_task", {}, task=True)
with pytest.raises(Exception):
await task
# Should have cached the failed status from notification
status = await task.status()
assert status.status == "failed"
assert (
status.status_message is not None
) # Error details in statusMessage per spec
async def test_fast_task_completion_delivered_via_notification(
task_notification_server,
):
"""A near-instant task still delivers its completion via a status notification.
Regression test for the Docket subscribe() setup-window race: a task that
finishes before the pub/sub subscription goes live had its terminal state
publish lost, so no completion notification ever reached the client and
wait() fell back to a full poll interval. The server now reconciles the
execution against Redis to close that gap.
Callbacks fire only for received notifications client-side polling updates
the status cache directly without invoking them so a "completed" callback
proves the notification path (not the poll fallback) was exercised.
"""
received: list[str] = []
async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("instant_task", {"value": 21}, task=True)
task.on_status_change(lambda status: received.append(status.status))
result = await task
assert result.data == 42
# Allow the completion notification to arrive and dispatch.
await _wait_until(lambda: "completed" in received)
assert "completed" in received
async def test_wait_returns_on_input_required(task_notification_server):
"""wait() should return immediately when task enters input_required, not hang."""
async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 1}, task=True)
# Directly inject an input_required status into the cache and signal the event.
# SDK v2 types the Task timestamps as ISO 8601 strings.
now = datetime.now(timezone.utc).isoformat()
input_required_status = GetTaskResult(
task_id=task._task_id,
status="input_required",
status_message="Waiting for user input",
created_at=now,
last_updated_at=now,
ttl=None,
)
task._status_cache = input_required_status
if task._status_event is None:
task._status_event = asyncio.Event()
task._status_event.set()
# Should return immediately with input_required, not hang for 300s
status = await task.wait(timeout=2.0)
assert status.status == "input_required"

View file

@ -1,89 +0,0 @@
"""
Tests for client-side task protocol.
Generic protocol tests that use tools as test fixtures.
"""
import asyncio
import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
pytestmark = pytest.mark.skip(reason="Phase 4: requires client task support (SEP-2663)")
async def test_end_to_end_task_flow():
"""Complete end-to-end flow: submit, poll, retrieve."""
start_signal = asyncio.Event()
complete_signal = asyncio.Event()
mcp = FastMCP("protocol-test")
@mcp.tool(task=True)
async def controlled_tool(message: str) -> str:
"""Tool with controlled execution."""
start_signal.set()
await complete_signal.wait()
return f"Processed: {message}"
async with Client(mcp, mode="legacy") as client:
# Submit task
task = await client.call_tool(
"controlled_tool", {"message": "integration test"}, task=True
)
# Wait for execution to start
await asyncio.wait_for(start_signal.wait(), timeout=2.0)
# Check status while running
status = await task.status()
assert status.status in ["working"]
# Signal completion
complete_signal.set()
# Wait for task to finish and retrieve result
result = await task.result()
assert result.data == "Processed: integration test"
async def test_multiple_concurrent_tasks():
"""Multiple tasks can run concurrently."""
mcp = FastMCP("concurrent-test")
@mcp.tool(task=True)
async def multiply(a: int, b: int) -> int:
return a * b
async with Client(mcp, mode="legacy") as client:
# Submit multiple tasks
tasks = []
for i in range(5):
task = await client.call_tool("multiply", {"a": i, "b": 2}, task=True)
tasks.append((task, i * 2))
# Wait for all to complete and verify results
for task, expected in tasks:
result = await task.result()
assert result.data == expected
async def test_task_id_auto_generation():
"""Task IDs are auto-generated if not provided."""
mcp = FastMCP("id-test")
@mcp.tool(task=True)
async def echo(message: str) -> str:
return f"Echo: {message}"
async with Client(mcp, mode="legacy") as client:
# Submit without custom task ID
task_1 = await client.call_tool("echo", {"message": "first"}, task=True)
task_2 = await client.call_tool("echo", {"message": "second"}, task=True)
# Should generate different IDs
assert task_1.task_id != task_2.task_id
assert len(task_1.task_id) > 0
assert len(task_2.task_id) > 0

View file

@ -1,158 +1,152 @@
"""
Tests for client-side tool task methods.
"""The explicit `ToolTask` handle (the return-quickly surface, SEP-2663).
Tests the client's tool-specific task functionality, parallel to
test_client_prompt_tasks.py and test_client_resource_tasks.py.
`call_tool_task` returns a `ToolTask` as soon as the server accepts the task, so
the caller can do other work and drive it: `status`, `wait`, `result`, `cancel`,
or `await`. This contrasts with `client.call_tool`, which polls to completion
transparently. All tests use a real `Client(mode="auto")` over the in-memory
transport, since tasks are modern-only.
"""
from __future__ import annotations
import asyncio
import pytest
from fastmcp_tasks.client import ToolTask
from fastmcp_tasks.models import MISSING_REQUIRED_CLIENT_CAPABILITY
from mcp.shared.exceptions import MCPError
from fastmcp import FastMCP
from fastmcp import Context, FastMCP
from fastmcp.client import Client
from fastmcp.exceptions import ToolError
pytestmark = pytest.mark.skip(reason="Phase 4: requires client task support (SEP-2663)")
from fastmcp.utilities.tasks import TaskConfig
from fastmcp_tasks import TasksExtension, ToolTask, call_tool_task
@pytest.fixture
async def tool_task_server():
"""Create a test server with task-enabled tools."""
def tool_task_server() -> FastMCP:
mcp = FastMCP("tool-task-test")
mcp.add_extension(TasksExtension())
@mcp.tool(task=True)
async def echo(message: str) -> str:
"""Echo back the message."""
return f"Echo: {message}"
@mcp.tool(task=True)
async def multiply(a: int, b: int) -> int:
"""Multiply two numbers."""
return a * b
@mcp.tool(task=True)
async def boom() -> str:
raise ValueError("background task failure")
return mcp
async def test_call_tool_as_task_returns_tool_task(tool_task_server):
"""call_tool with task=True returns a ToolTask object."""
async with Client(tool_task_server, mode="legacy") as client:
task = await client.call_tool("echo", {"message": "hello"}, task=True)
async def test_call_tool_task_returns_tool_task(tool_task_server: FastMCP):
async with Client(tool_task_server, mode="auto") as client:
task = await call_tool_task(client, "echo", {"message": "hello"})
assert isinstance(task, ToolTask)
assert isinstance(task.task_id, str)
assert len(task.task_id) > 0
assert task.task_id
async def test_tool_task_server_generated_id(tool_task_server):
"""call_tool with task=True gets server-generated task ID."""
async with Client(tool_task_server, mode="legacy") as client:
task = await client.call_tool("echo", {"message": "test"}, task=True)
# Server should generate a UUID task ID
assert task.task_id is not None
assert isinstance(task.task_id, str)
# UUIDs have hyphens
assert "-" in task.task_id
async def test_tool_task_result_returns_call_tool_result(tool_task_server):
"""ToolTask.result() returns CallToolResult with tool data."""
async with Client(tool_task_server, mode="legacy") as client:
task = await client.call_tool("multiply", {"a": 6, "b": 7}, task=True)
assert not task.returned_immediately
async def test_tool_task_result_returns_parsed_result(tool_task_server: FastMCP):
async with Client(tool_task_server, mode="auto") as client:
task = await call_tool_task(client, "multiply", {"a": 6, "b": 7})
result = await task.result()
assert result.data == 42
async def test_tool_task_await_syntax(tool_task_server):
"""Tool tasks can be awaited directly to get result."""
async with Client(tool_task_server, mode="legacy") as client:
task = await client.call_tool("multiply", {"a": 7, "b": 6}, task=True)
# Can await task directly (syntactic sugar for task.result())
async def test_tool_task_await_syntax(tool_task_server: FastMCP):
async with Client(tool_task_server, mode="auto") as client:
task = await call_tool_task(client, "multiply", {"a": 7, "b": 6})
result = await task
assert result.data == 42
async def test_tool_task_status_and_wait(tool_task_server):
"""ToolTask.status() returns GetTaskResult."""
async with Client(tool_task_server, mode="legacy") as client:
task = await client.call_tool("echo", {"message": "test"}, task=True)
async def test_tool_task_status_and_wait(tool_task_server: FastMCP):
async with Client(tool_task_server, mode="auto") as client:
task = await call_tool_task(client, "echo", {"message": "test"})
status = await task.status()
assert status.task_id == task.task_id
assert status.status in ["working", "completed"]
assert status.status in {"working", "completed"}
# Wait for completion
await task.wait(timeout=2.0)
final_status = await task.status()
assert final_status.status == "completed"
final = await task.wait(timeout=2.0)
assert final.status == "completed"
async def test_immediate_tool_task_respects_raise_on_error_true():
"""Immediate task fallback should still raise ToolError when requested."""
mcp = FastMCP("immediate-tool-task-error")
async def test_tool_task_result_is_cached(tool_task_server: FastMCP):
"""Repeated result() calls return the same cached object without re-polling."""
async with Client(tool_task_server, mode="auto") as client:
task = await call_tool_task(client, "multiply", {"a": 2, "b": 5})
@mcp.tool
def failing_tool() -> str:
raise ValueError("immediate task failure")
async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("failing_tool", task=True, raise_on_error=True)
assert task.returned_immediately
with pytest.raises(
ToolError, match="does not support task-augmented execution"
):
await task.result()
result1 = await task.result()
result2 = await task.result()
result3 = await task
assert result1 is result2 is result3
assert result1.data == 10
async def test_immediate_tool_task_respects_raise_on_error_false():
"""Immediate task fallback should return error results when requested."""
mcp = FastMCP("immediate-tool-task-no-raise")
@mcp.tool
def failing_tool() -> str:
raise ValueError("immediate task failure")
async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("failing_tool", task=True, raise_on_error=False)
assert task.returned_immediately
result = await task.result()
assert result.is_error is True
assert "does not support task-augmented execution" in str(result)
async def test_background_tool_task_respects_raise_on_error_true():
"""Background tasks should still raise ToolError by default on errors."""
mcp = FastMCP("background-tool-task-error")
@mcp.tool(task=True)
async def failing_tool() -> str:
raise ValueError("background task failure")
async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("failing_tool", task=True, raise_on_error=True)
assert not task.returned_immediately
async def test_background_task_raises_on_error_by_default(tool_task_server: FastMCP):
async with Client(tool_task_server, mode="auto") as client:
task = await call_tool_task(client, "boom", {})
with pytest.raises(ToolError, match="background task failure"):
await task.result()
async def test_background_tool_task_respects_raise_on_error_false():
"""Background tasks should return error results when raise_on_error is disabled."""
mcp = FastMCP("background-tool-task-no-raise")
async def test_background_task_returns_error_when_not_raising(
tool_task_server: FastMCP,
):
async with Client(tool_task_server, mode="auto") as client:
task = await call_tool_task(client, "boom", {}, raise_on_error=False)
result = await task.result()
assert result.is_error
assert "background task failure" in str(result)
async def test_multiple_concurrent_tool_tasks(tool_task_server: FastMCP):
async with Client(tool_task_server, mode="auto") as client:
tasks = [
(await call_tool_task(client, "multiply", {"a": i, "b": 2}), i * 2)
for i in range(5)
]
for task, expected in tasks:
result = await task.result()
assert result.data == expected
async def test_tool_task_cancel():
"""A long-running task can be cancelled through the handle."""
mcp = FastMCP("cancel-test")
mcp.add_extension(TasksExtension())
@mcp.tool(task=True)
async def failing_tool() -> str:
raise ValueError("background task failure")
async def forever(ctx: Context) -> str:
await asyncio.Event().wait()
return "never"
async with Client(mcp, mode="auto") as client:
task = await call_tool_task(client, "forever", {})
await task.wait(state="working", timeout=2.0)
await task.cancel()
final = await task.wait(timeout=2.0)
assert final.status == "cancelled"
async def test_required_mode_without_optin_raises_32003():
"""A legacy client never negotiates the tasks capability, so a required-mode
tool call is rejected with the -32003 missing-capability error."""
mcp = FastMCP("required-test")
mcp.add_extension(TasksExtension())
@mcp.tool(task=TaskConfig(mode="required"))
async def must_task(x: int) -> int:
return x
async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("failing_tool", task=True, raise_on_error=False)
with pytest.raises(MCPError) as excinfo:
await client.call_tool("must_task", {"x": 1})
assert not task.returned_immediately
result = await task.result()
assert result.is_error is True
assert "background task failure" in str(result)
assert excinfo.value.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY

View file

@ -1,92 +1,61 @@
"""Fallback poll cadence for client-side task waiting.
"""Fallback poll cadence for client-side task waiting (SEP-2663).
Two modes: a server-advertised pollInterval is honored exactly, while an
unadvertised one falls back to an exponential ramp up to the client setting.
The modern protocol has no task status notifications, so the client polls. The
backoff ramps from a fast floor, doubling up to a ceiling: the server-advertised
``pollIntervalMs`` when present (a statement about server load), else the client
``poll_interval`` setting. A quick task resolves in ~20ms; a long one settles to
the advertised cadence.
"""
from __future__ import annotations
import pytest
from fastmcp_tasks.client import MIN_POLL_INTERVAL, ToolTask
from mcp_types import GetTaskResult
from fastmcp_tasks.client import MIN_POLL_INTERVAL, _next_poll_delay, _poll_ceiling
from fastmcp_tasks.settings import TasksClientSettings, client_settings
from pydantic import ValidationError
from fastmcp import Client, FastMCP
from fastmcp.settings import Settings
from fastmcp.utilities.tests import temporary_settings
pytestmark = pytest.mark.skip(reason="Phase 4: requires client task support (SEP-2663)")
@pytest.mark.parametrize("value", [0, -0.5, -1])
def test_non_positive_poll_interval_setting_is_rejected(value: float):
with pytest.raises(ValidationError):
Settings(client_task_poll_interval=value)
TasksClientSettings(poll_interval=value)
def test_positive_poll_interval_setting_is_accepted():
settings = Settings(client_task_poll_interval=0.25)
assert settings.client_task_poll_interval == 0.25
settings = TasksClientSettings(poll_interval=0.25)
assert settings.poll_interval == 0.25
@pytest.fixture
def task() -> ToolTask:
client = Client(FastMCP())
return ToolTask(client=client, task_id="t1", tool_name="echo")
@pytest.mark.parametrize("poll_interval_ms", [2000, 30_000])
def test_advertised_interval_caps_the_ramp(poll_interval_ms: int):
"""An advertised interval is the ceiling the ramp tops out at."""
assert _poll_ceiling(poll_interval_ms) == poll_interval_ms / 1000
def _status(poll_interval: int | None) -> GetTaskResult:
return GetTaskResult(
task_id="t1",
status="working",
created_at="2026-01-01T00:00:00+00:00",
last_updated_at="2026-01-01T00:00:00+00:00",
ttl=None,
poll_interval=poll_interval,
)
def test_large_advertised_interval_is_honored():
day_ms = 24 * 60 * 60 * 1000
assert _poll_ceiling(day_ms) == 24 * 60 * 60
@pytest.mark.parametrize("poll_interval", [2000, 30_000])
def test_advertised_interval_is_used_verbatim_without_backoff(
task: ToolTask, poll_interval: int
):
"""An advertised interval is the delay itself, not a ceiling to ramp toward."""
task._status_cache = _status(poll_interval)
expected = poll_interval / 1000
@pytest.mark.parametrize("poll_interval_ms", [None, 0, -1, -5000])
def test_absent_or_hostile_interval_falls_back_to_setting(poll_interval_ms):
"""An absent, zero, or negative server value cannot spin the client: use the setting."""
assert _poll_ceiling(poll_interval_ms) == client_settings.poll_interval
def test_ramp_doubles_from_floor_up_to_advertised_ceiling():
"""Even with an advertised interval, the poll ramps fast then caps at it."""
ceiling_ms = 500 # 0.5s ceiling
delays = []
backoff = MIN_POLL_INTERVAL
for _ in range(5):
delay, backoff = task._next_poll_delay(backoff)
assert delay == expected
def test_large_advertised_interval_is_honored(task: ToolTask):
task._status_cache = _status(24 * 60 * 60 * 1000)
delay, _ = task._next_poll_delay(MIN_POLL_INTERVAL)
assert delay == 24 * 60 * 60
@pytest.mark.parametrize("poll_interval", [0, -1, -5000])
def test_non_positive_advertised_interval_is_floored(
task: ToolTask, poll_interval: int
):
"""A buggy or hostile server must not be able to spin the client."""
task._status_cache = _status(poll_interval)
delay, _ = task._next_poll_delay(MIN_POLL_INTERVAL)
assert delay == MIN_POLL_INTERVAL
def test_unadvertised_interval_ramps_up_to_setting(task: ToolTask):
task._status_cache = _status(None)
with temporary_settings(client_task_poll_interval=0.5):
delays = []
backoff = MIN_POLL_INTERVAL
for _ in range(7):
delay, backoff = task._next_poll_delay(backoff)
delays.append(delay)
for _ in range(7):
delay, backoff = _next_poll_delay(ceiling_ms, backoff)
delays.append(delay)
assert delays == [0.02, 0.04, 0.08, 0.16, 0.32, 0.5, 0.5]
def test_missing_status_cache_ramps_from_floor(task: ToolTask):
delay, backoff = task._next_poll_delay(MIN_POLL_INTERVAL)
def test_first_delay_is_the_floor():
delay, backoff = _next_poll_delay(30_000, MIN_POLL_INTERVAL)
assert delay == MIN_POLL_INTERVAL
assert backoff == MIN_POLL_INTERVAL * 2

View file

@ -1,224 +0,0 @@
"""
Tests for Task client context validation.
Verifies that Task methods properly validate client context and that
cached results remain accessible outside context.
"""
import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
pytestmark = pytest.mark.skip(reason="Phase 4: requires client task support (SEP-2663)")
@pytest.fixture
async def task_server():
"""Create a test server with background tasks."""
mcp = FastMCP("context-test-server")
@mcp.tool(task=True)
async def background_tool(value: str) -> str:
"""Tool that runs in background."""
return f"Result: {value}"
@mcp.prompt(task=True)
async def background_prompt(topic: str) -> str:
"""Prompt that runs in background."""
return f"Prompt about {topic}"
@mcp.resource("file://background.txt", task=True)
async def background_resource() -> str:
"""Resource that runs in background."""
return "Background resource content"
return mcp
async def test_task_status_outside_context_raises(task_server):
"""Calling task.status() outside client context raises error."""
task = None
async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
assert not task.returned_immediately
# Now outside context
with pytest.raises(RuntimeError, match="outside client context"):
await task.status()
async def test_task_result_outside_context_raises(task_server):
"""Calling task.result() outside context raises error."""
task = None
async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
assert not task.returned_immediately
# Now outside context
with pytest.raises(RuntimeError, match="outside client context"):
await task.result()
async def test_task_wait_outside_context_raises(task_server):
"""Calling task.wait() outside context raises error."""
task = None
async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
assert not task.returned_immediately
# Now outside context
with pytest.raises(RuntimeError, match="outside client context"):
await task.wait()
async def test_task_cancel_outside_context_raises(task_server):
"""Calling task.cancel() outside context raises error."""
task = None
async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
assert not task.returned_immediately
# Now outside context
with pytest.raises(RuntimeError, match="outside client context"):
await task.cancel()
async def test_cached_tool_task_accessible_outside_context(task_server):
"""Tool tasks with cached results work outside context."""
task = None
async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
assert not task.returned_immediately
# Get result once to cache it
result1 = await task.result()
assert result1.data == "Result: test"
# Now outside context
# Should work because result is cached
result2 = await task.result()
assert result2 is result1 # Same object
assert result2.data == "Result: test"
@pytest.mark.xfail(
reason="SDK v2 has no `task` field on GetPromptRequestParams / "
"ReadResourceRequestParams; prompt/resource task submission is not "
"wire-expressible and always graceful-degrades (sdk-feedback #3).",
strict=True,
)
async def test_cached_prompt_task_accessible_outside_context(task_server):
"""Prompt tasks with cached results work outside context."""
task = None
async with Client(task_server, mode="legacy") as client:
task = await client.get_prompt(
"background_prompt", {"topic": "test"}, task=True
)
assert not task.returned_immediately
# Get result once to cache it
result1 = await task.result()
assert result1.description == "Prompt that runs in background."
# Now outside context
# Should work because result is cached
result2 = await task.result()
assert result2 is result1 # Same object
assert result2.description == "Prompt that runs in background."
@pytest.mark.xfail(
reason="SDK v2 has no `task` field on GetPromptRequestParams / "
"ReadResourceRequestParams; prompt/resource task submission is not "
"wire-expressible and always graceful-degrades (sdk-feedback #3).",
strict=True,
)
async def test_cached_resource_task_accessible_outside_context(task_server):
"""Resource tasks with cached results work outside context."""
task = None
async with Client(task_server, mode="legacy") as client:
task = await client.read_resource("file://background.txt", task=True)
assert not task.returned_immediately
# Get result once to cache it
result1 = await task.result()
assert len(result1) > 0
# Now outside context
# Should work because result is cached
result2 = await task.result()
assert result2 is result1 # Same object
async def test_uncached_status_outside_context_raises(task_server):
"""Even after caching result, status() still requires client context."""
task = None
async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
assert not task.returned_immediately
# Cache the result
await task.result()
# Now outside context
# result() works (cached)
result = await task.result()
assert result.data == "Result: test"
# But status() still needs client connection
with pytest.raises(RuntimeError, match="outside client context"):
await task.status()
async def test_task_await_syntax_outside_context_raises(task_server):
"""Using await task syntax outside context raises error for background tasks."""
task = None
async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
assert not task.returned_immediately
# Now outside context
with pytest.raises(RuntimeError, match="outside client context"):
await task # Same as await task.result()
async def test_task_await_syntax_works_for_cached_results(task_server):
"""Using await task syntax works outside context when result is cached."""
task = None
async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
result1 = await task # Cache it
# Now outside context
result2 = await task # Should work (cached)
assert result2 is result1
assert result2.data == "Result: test"
async def test_multiple_result_calls_return_same_cached_object(task_server):
"""Multiple result() calls return the same cached object."""
async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
result1 = await task.result()
result2 = await task.result()
result3 = await task.result()
# Should all be the same object (cached)
assert result1 is result2
assert result2 is result3
async def test_background_task_properties_accessible_outside_context(task_server):
"""Background task properties like task_id accessible outside context."""
task = None
async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
task_id_inside = task.task_id
assert not task.returned_immediately
# Now outside context
# Properties should still be accessible (they don't need client connection)
assert task.task_id == task_id_inside
assert task.returned_immediately is False

View file

@ -1,341 +0,0 @@
"""
Tests for Task result caching behavior.
Verifies that Task.result() and await task cache results properly to avoid
redundant server calls and ensure consistent object identity.
"""
import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
pytestmark = pytest.mark.skip(reason="Phase 4: requires client task support (SEP-2663)")
async def test_tool_task_result_cached_on_first_call():
"""First call caches result, subsequent calls return cached value."""
call_count = 0
mcp = FastMCP("test")
@mcp.tool(task=True)
async def counting_tool() -> int:
nonlocal call_count
call_count += 1
return call_count
async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("counting_tool", task=True)
result1 = await task.result()
result2 = await task.result()
result3 = await task.result()
# All should return 1 (first execution value)
assert result1.data == 1
assert result2.data == 1
assert result3.data == 1
# Verify they're the same object (cached)
assert result1 is result2 is result3
async def test_prompt_task_result_cached():
"""PromptTask caches results on first call."""
call_count = 0
mcp = FastMCP("test")
@mcp.prompt(task=True)
async def counting_prompt() -> str:
nonlocal call_count
call_count += 1
return f"Call number: {call_count}"
async with Client(mcp, mode="legacy") as client:
task = await client.get_prompt("counting_prompt", task=True)
result1 = await task.result()
result2 = await task.result()
result3 = await task.result()
# All should return same content
assert result1.messages[0].content.text == "Call number: 1"
assert result2.messages[0].content.text == "Call number: 1"
assert result3.messages[0].content.text == "Call number: 1"
# Verify they're the same object (cached)
assert result1 is result2 is result3
async def test_resource_task_result_cached():
"""ResourceTask caches results on first call."""
call_count = 0
mcp = FastMCP("test")
@mcp.resource("file://counter.txt", task=True)
async def counting_resource() -> str:
nonlocal call_count
call_count += 1
return f"Count: {call_count}"
async with Client(mcp, mode="legacy") as client:
task = await client.read_resource("file://counter.txt", task=True)
result1 = await task.result()
result2 = await task.result()
result3 = await task.result()
# All should return same content
assert result1[0].text == "Count: 1"
assert result2[0].text == "Count: 1"
assert result3[0].text == "Count: 1"
# Verify they're the same object (cached)
assert result1 is result2 is result3
async def test_multiple_await_returns_same_object():
"""Multiple await task calls return identical object."""
mcp = FastMCP("test")
@mcp.tool(task=True)
async def sample_tool() -> str:
return "result"
async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("sample_tool", task=True)
result1 = await task
result2 = await task
result3 = await task
# Should be exact same object in memory
assert result1 is result2 is result3
assert id(result1) == id(result2) == id(result3)
async def test_result_and_await_share_cache():
"""task.result() and await task share the same cache."""
mcp = FastMCP("test")
@mcp.tool(task=True)
async def sample_tool() -> str:
return "cached"
async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("sample_tool", task=True)
# Call result() first
result_via_method = await task.result()
# Then await directly
result_via_await = await task
# Should be the same cached object
assert result_via_method is result_via_await
assert id(result_via_method) == id(result_via_await)
async def test_forbidden_mode_tool_caches_error_result():
"""Tools with task=False (mode=forbidden) cache error results."""
mcp = FastMCP("test")
@mcp.tool(task=False)
async def non_task_tool() -> int:
return 1
async with Client(mcp, mode="legacy") as client:
# Request as task, but mode="forbidden" will reject with error
task = await client.call_tool("non_task_tool", task=True, raise_on_error=False)
# Should be immediate (error returned immediately)
assert task.returned_immediately
result1 = await task.result()
result2 = await task.result()
result3 = await task.result()
# All should return cached error
assert result1.is_error
assert "does not support task-augmented execution" in str(result1)
# Verify they're the same object (cached)
assert result1 is result2 is result3
@pytest.mark.xfail(
reason="SDK v2 has no `task` field on GetPromptRequestParams / "
"ReadResourceRequestParams; prompt/resource task submission is not "
"wire-expressible and always graceful-degrades (sdk-feedback #3).",
strict=True,
)
async def test_forbidden_mode_prompt_raises_error():
"""Prompts with task=False (mode=forbidden) raise error."""
import pytest
from mcp.shared.exceptions import MCPError
mcp = FastMCP("test")
@mcp.prompt(task=False)
async def non_task_prompt() -> str:
return "Immediate"
async with Client(mcp, mode="legacy") as client:
# Prompts with mode="forbidden" raise MCPError when called with task=True
with pytest.raises(MCPError):
await client.get_prompt("non_task_prompt", task=True)
@pytest.mark.xfail(
reason="SDK v2 has no `task` field on GetPromptRequestParams / "
"ReadResourceRequestParams; prompt/resource task submission is not "
"wire-expressible and always graceful-degrades (sdk-feedback #3).",
strict=True,
)
async def test_forbidden_mode_resource_raises_error():
"""Resources with task=False (mode=forbidden) raise error."""
import pytest
from mcp.shared.exceptions import MCPError
mcp = FastMCP("test")
@mcp.resource("file://immediate.txt", task=False)
async def non_task_resource() -> str:
return "Immediate"
async with Client(mcp, mode="legacy") as client:
# Resources with mode="forbidden" raise MCPError when called with task=True
with pytest.raises(MCPError):
await client.read_resource("file://immediate.txt", task=True)
async def test_immediate_task_caches_result():
"""Immediate tasks (optional mode called without background) cache results."""
call_count = 0
mcp = FastMCP("test", tasks=True)
# Tool with task=True (optional mode) - but without docket will execute immediately
@mcp.tool(task=True)
async def task_tool() -> int:
nonlocal call_count
call_count += 1
return call_count
async with Client(mcp, mode="legacy") as client:
# Call with task=True
task = await client.call_tool("task_tool", task=True)
# Get result multiple times
result1 = await task.result()
result2 = await task.result()
result3 = await task.result()
# All should return cached value
assert result1.data == 1
assert result2.data == 1
assert result3.data == 1
# Verify they're the same object (cached)
assert result1 is result2 is result3
async def test_cache_persists_across_mixed_access_patterns():
"""Cache works correctly when mixing result() and await."""
mcp = FastMCP("test")
@mcp.tool(task=True)
async def mixed_tool() -> str:
return "mixed"
async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("mixed_tool", task=True)
# Access in various orders
result1 = await task
result2 = await task.result()
result3 = await task
result4 = await task.result()
# All should be the same cached object
assert result1 is result2 is result3 is result4
async def test_different_tasks_have_separate_caches():
"""Different task instances maintain separate caches."""
mcp = FastMCP("test")
@mcp.tool(task=True)
async def separate_tool(value: str) -> str:
return f"Result: {value}"
async with Client(mcp, mode="legacy") as client:
task1 = await client.call_tool("separate_tool", {"value": "A"}, task=True)
task2 = await client.call_tool("separate_tool", {"value": "B"}, task=True)
result1 = await task1.result()
result2 = await task2.result()
# Different results
assert result1.data == "Result: A"
assert result2.data == "Result: B"
# Not the same object
assert result1 is not result2
# But each task's cache works independently
result1_again = await task1.result()
result2_again = await task2.result()
assert result1 is result1_again
assert result2 is result2_again
async def test_cache_survives_status_checks():
"""Calling status() doesn't affect result caching."""
mcp = FastMCP("test")
@mcp.tool(task=True)
async def status_check_tool() -> str:
return "status"
async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("status_check_tool", task=True)
# Check status multiple times
await task.status()
await task.status()
result1 = await task.result()
# Check status again
await task.status()
result2 = await task.result()
# Cache should still work
assert result1 is result2
async def test_cache_survives_wait_calls():
"""Calling wait() doesn't affect result caching."""
mcp = FastMCP("test")
@mcp.tool(task=True)
async def wait_test_tool() -> str:
return "waited"
async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("wait_test_tool", task=True)
# Wait for completion
await task.wait()
result1 = await task.result()
# Wait again (no-op since completed)
await task.wait()
result2 = await task.result()
# Cache should still work
assert result1 is result2

View file

@ -0,0 +1,158 @@
"""The transparent client task flow over a real in-memory connection.
A real `Client(server, mode="auto")` calls a `task=True` tool; the server runs it
as a task and answers `tools/call` with a `CreateTaskResult`; the client's
auto-registered tasks extension resolves it by polling `tasks/get` to completion.
The caller of `call_tool` sees only the tool's real result — never that the call
was tasked. This is the whole point of the client half.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
import mcp_types
import pytest
from fastmcp import Context, FastMCP
from fastmcp.client import Client
from fastmcp.exceptions import ToolError
from fastmcp_tasks import TasksExtension, call_tool_task
@pytest.fixture
def task_server() -> FastMCP:
mcp = FastMCP("transparent-tasks")
mcp.add_extension(TasksExtension())
@mcp.tool(task=True)
async def multiply(a: int, b: int) -> int:
await asyncio.sleep(0.01)
return a * b
@mcp.tool(task=True)
async def boom() -> str:
raise ValueError("kaboom")
return mcp
async def test_call_tool_transparently_completes_a_task(task_server: FastMCP):
"""call_tool returns the tool's real result; the caller never sees a task."""
async with Client(task_server, mode="auto") as client:
result = await client.call_tool("multiply", {"a": 6, "b": 7})
assert result.data == 42
async def test_call_tool_mcp_returns_completed_result(task_server: FastMCP):
"""call_tool_mcp resolves the tasked call into an ordinary CallToolResult."""
async with Client(task_server, mode="auto") as client:
result = await client.call_tool_mcp("multiply", {"a": 3, "b": 4})
assert result.structured_content == {"result": 12}
assert not result.is_error
async def test_failed_task_raises_tool_error(task_server: FastMCP):
"""A task whose tool raises surfaces as a ToolError through call_tool."""
async with Client(task_server, mode="auto") as client:
with pytest.raises(ToolError, match="kaboom"):
await client.call_tool("boom", {})
async def test_raw_create_task_result_is_exposed(task_server: FastMCP):
"""The raw claimed CreateTaskResult is reachable via the session/handle path."""
async with Client(task_server, mode="auto") as client:
task = await call_tool_task(client, "multiply", {"a": 2, "b": 5})
# The raw claimed shape is exposed on the handle.
assert task.create_result.result_type == "task"
assert task.create_result.status == "working"
assert isinstance(task.task_id, str) and task.task_id
result = await task.result()
assert result.data == 10
async def test_legacy_client_never_tasks(task_server: FastMCP):
"""A legacy-era client never negotiates the capability, so nothing is tasked.
The optional-mode tool simply runs synchronously and returns its result
directly (no CreateTaskResult on the wire).
"""
async with Client(task_server, mode="legacy") as client:
result = await client.call_tool("multiply", {"a": 8, "b": 9})
assert result.data == 72
# --- In-task input over the wire -------------------------------------------
@dataclass
class DinnerPrefs:
cuisine: str
vegetarian: bool
def _elicit_request(message: str) -> mcp_types.ElicitRequest:
return mcp_types.ElicitRequest(
params=mcp_types.ElicitRequestFormParams(
message=message,
requested_schema={
"type": "object",
"properties": {
"cuisine": {"type": "string"},
"vegetarian": {"type": "boolean"},
},
"required": ["cuisine", "vegetarian"],
},
)
)
@pytest.fixture
def guard_server() -> FastMCP:
mcp = FastMCP("guard-tasks")
mcp.add_extension(TasksExtension())
@mcp.tool(task=True)
async def plan_dinner(
ctx: Context,
) -> str | mcp_types.InputRequiredResult:
responses = ctx.input_responses
if responses is None:
return mcp_types.InputRequiredResult(
result_type="input_required",
input_requests={"prefs": _elicit_request("What's for dinner?")},
)
answer = responses["prefs"]
assert isinstance(answer, mcp_types.ElicitResult)
assert answer.content is not None
veg = "vegetarian " if answer.content["vegetarian"] else ""
return f"Tonight: a {veg}{answer.content['cuisine']} dinner!"
return mcp
async def test_in_task_input_answered_transparently(guard_server: FastMCP):
"""A guard task that asks for input is answered via the elicitation handler."""
async def handle_elicitation(message, response_type, params, context):
return DinnerPrefs(cuisine="Thai", vegetarian=True)
client = Client(
guard_server, mode="auto", elicitation_handler=handle_elicitation
)
async with client:
result = await client.call_tool("plan_dinner", {})
assert result.data == "Tonight: a vegetarian Thai dinner!"
async def test_in_task_input_without_handler_errors(guard_server: FastMCP):
"""A guard task with no elicitation handler surfaces a clear error."""
async with Client(guard_server, mode="auto") as client:
with pytest.raises(ToolError, match="no elicitation handler"):
await client.call_tool("plan_dinner", {})