mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-13 00:59:10 +02:00
209 lines
6.6 KiB
Python
209 lines
6.6 KiB
Python
"""Shared helpers for driving SEP-2663 tasks in server-side tests.
|
|
|
|
There is no client task-submission API until Phase 4, so server-side tests drive
|
|
the task lifecycle in-process: the create decision runs through the real
|
|
`tools/call` interceptor (with a per-request tasks opt-in bound into the request
|
|
context), and `tasks/get` / `tasks/update` / `tasks/cancel` call the extension's
|
|
handler functions directly. Optional auth binding exercises the auth-scoped task
|
|
isolation.
|
|
|
|
Typical use::
|
|
|
|
async with running_task_server(mcp):
|
|
created = await submit_task(mcp, "square", {"n": 6})
|
|
final = await wait_for_task(mcp, created.task_id)
|
|
assert final.status == "completed"
|
|
|
|
or the one-shot::
|
|
|
|
async with running_task_server(mcp):
|
|
final = await run_task(mcp, "square", {"n": 6})
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
from types import SimpleNamespace
|
|
from typing import Any, cast
|
|
|
|
from fastmcp_tasks.handlers import tasks_cancel, tasks_get, tasks_update
|
|
from fastmcp_tasks.models import (
|
|
CancelTaskResult,
|
|
CreateTaskResult,
|
|
GetTaskResult,
|
|
UpdateTaskResult,
|
|
)
|
|
from mcp.server.auth.middleware.auth_context import auth_context_var
|
|
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
|
|
from mcp.server.context import ServerRequestContext
|
|
from mcp.server.session import ServerSession
|
|
from mcp_types import CLIENT_CAPABILITIES_META_KEY
|
|
|
|
from fastmcp.server.auth import AccessToken
|
|
from fastmcp.server.dependencies import bind_request_context
|
|
from fastmcp.server.server import FastMCP
|
|
from fastmcp.utilities.tasks import TASKS_EXTENSION_ID
|
|
|
|
TERMINAL_STATES = frozenset({"completed", "failed", "cancelled"})
|
|
|
|
|
|
def opt_in_meta(settings: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
"""The per-request `_meta` block that opts the tasks extension in."""
|
|
return {
|
|
CLIENT_CAPABILITIES_META_KEY: {
|
|
"extensions": {TASKS_EXTENSION_ID: settings or {}}
|
|
}
|
|
}
|
|
|
|
|
|
def make_access_token(client_id: str, sub: str | None = None) -> AccessToken:
|
|
"""A minimal FastMCP access token for auth-scoped task tests."""
|
|
claims: dict[str, Any] = {"sub": sub} if sub is not None else {}
|
|
return AccessToken(
|
|
token=f"token-{client_id}-{sub}",
|
|
client_id=client_id,
|
|
scopes=[],
|
|
claims=claims,
|
|
)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def auth_scope(access_token: AccessToken | None):
|
|
"""Bind (or clear) the auth context so `get_task_scope` sees a caller."""
|
|
if access_token is None:
|
|
yield
|
|
return
|
|
token = auth_context_var.set(AuthenticatedUser(access_token))
|
|
try:
|
|
yield
|
|
finally:
|
|
auth_context_var.reset(token)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _opted_in_request(
|
|
name: str, arguments: dict[str, Any] | None, settings: dict[str, Any] | None
|
|
):
|
|
"""Bind a request context carrying the tasks opt-in for a `tools/call`."""
|
|
params: dict[str, Any] = {
|
|
"name": name,
|
|
"arguments": arguments or {},
|
|
"_meta": opt_in_meta(settings),
|
|
}
|
|
srctx = ServerRequestContext(
|
|
session=cast(ServerSession, SimpleNamespace()),
|
|
lifespan_context={},
|
|
protocol_version="2026-07-28",
|
|
method="tools/call",
|
|
params=params,
|
|
)
|
|
with bind_request_context(srctx):
|
|
yield
|
|
|
|
|
|
def running_task_server(server: FastMCP):
|
|
"""Enter the server lifespan (Docket backend + worker) for the block."""
|
|
return server._lifespan_manager()
|
|
|
|
|
|
async def submit_task(
|
|
server: FastMCP,
|
|
name: str,
|
|
arguments: dict[str, Any] | None = None,
|
|
*,
|
|
access_token: AccessToken | None = None,
|
|
settings: dict[str, Any] | None = None,
|
|
) -> CreateTaskResult:
|
|
"""Run an opted-in `tools/call` through the interceptor and return its task."""
|
|
with auth_scope(access_token), _opted_in_request(name, arguments, settings):
|
|
result = await server.call_tool(name, arguments or {})
|
|
if not isinstance(result, CreateTaskResult):
|
|
raise AssertionError(
|
|
f"Expected the call to be tasked, got {type(result).__name__}: {result!r}"
|
|
)
|
|
return result
|
|
|
|
|
|
async def call_tool_without_optin(
|
|
server: FastMCP,
|
|
name: str,
|
|
arguments: dict[str, Any] | None = None,
|
|
*,
|
|
access_token: AccessToken | None = None,
|
|
):
|
|
"""Run a `tools/call` with no tasks opt-in (synchronous unless mode=required)."""
|
|
with auth_scope(access_token):
|
|
return await server.call_tool(name, arguments or {})
|
|
|
|
|
|
async def get_task(
|
|
server: FastMCP,
|
|
task_id: str,
|
|
*,
|
|
access_token: AccessToken | None = None,
|
|
) -> GetTaskResult:
|
|
"""Call the `tasks/get` handler within the given auth scope."""
|
|
with auth_scope(access_token):
|
|
return await tasks_get(server, task_id)
|
|
|
|
|
|
async def update_task(
|
|
server: FastMCP,
|
|
task_id: str,
|
|
input_responses: dict[str, Any],
|
|
*,
|
|
access_token: AccessToken | None = None,
|
|
) -> UpdateTaskResult:
|
|
"""Call the `tasks/update` handler within the given auth scope."""
|
|
with auth_scope(access_token):
|
|
return await tasks_update(server, task_id, input_responses)
|
|
|
|
|
|
async def cancel_task(
|
|
server: FastMCP,
|
|
task_id: str,
|
|
*,
|
|
access_token: AccessToken | None = None,
|
|
) -> CancelTaskResult:
|
|
"""Call the `tasks/cancel` handler within the given auth scope."""
|
|
with auth_scope(access_token):
|
|
return await tasks_cancel(server, task_id)
|
|
|
|
|
|
async def wait_for_task(
|
|
server: FastMCP,
|
|
task_id: str,
|
|
*,
|
|
access_token: AccessToken | None = None,
|
|
target_states: frozenset[str] = TERMINAL_STATES,
|
|
timeout: float = 5.0,
|
|
poll: float = 0.02,
|
|
) -> GetTaskResult:
|
|
"""Poll `tasks/get` until the task reaches one of `target_states`."""
|
|
deadline = asyncio.get_event_loop().time() + timeout
|
|
result = await get_task(server, task_id, access_token=access_token)
|
|
while result.status not in target_states:
|
|
if asyncio.get_event_loop().time() >= deadline:
|
|
raise TimeoutError(
|
|
f"Task {task_id} still {result.status!r} after {timeout}s "
|
|
f"(waiting for {sorted(target_states)})"
|
|
)
|
|
await asyncio.sleep(poll)
|
|
result = await get_task(server, task_id, access_token=access_token)
|
|
return result
|
|
|
|
|
|
async def run_task(
|
|
server: FastMCP,
|
|
name: str,
|
|
arguments: dict[str, Any] | None = None,
|
|
*,
|
|
access_token: AccessToken | None = None,
|
|
timeout: float = 5.0,
|
|
) -> GetTaskResult:
|
|
"""Submit a task and wait for it to reach a terminal state."""
|
|
created = await submit_task(server, name, arguments, access_token=access_token)
|
|
return await wait_for_task(
|
|
server, created.task_id, access_token=access_token, timeout=timeout
|
|
)
|