mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-26 15:34:18 +02:00
Merge pull request #4603 from PrefectHQ/feat/tasks-sep2663
Add background tasks via the io.modelcontextprotocol/tasks extension (SEP-2663)
This commit is contained in:
commit
39148870af
181 changed files with 10998 additions and 14322 deletions
87
.github/workflows/publish-fastmcp-tasks.yml
vendored
Normal file
87
.github/workflows/publish-fastmcp-tasks.yml
vendored
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
name: Publish fastmcp-tasks to PyPI
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Publish fastmcp-slim to PyPI"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
pypi-publish:
|
||||
name: Upload fastmcp-tasks to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'release')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Build fastmcp-tasks
|
||||
run: uv build --package fastmcp-tasks
|
||||
|
||||
- name: Verify matching fastmcp-slim is published
|
||||
run: |
|
||||
SLIM_VERSION=$(python - <<'PY'
|
||||
import email.parser
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
wheel = next(Path("dist").glob("fastmcp_tasks-*.whl"))
|
||||
metadata_name = next(
|
||||
name for name in zipfile.ZipFile(wheel).namelist()
|
||||
if name.endswith(".dist-info/METADATA")
|
||||
)
|
||||
metadata = email.parser.Parser().parsestr(
|
||||
zipfile.ZipFile(wheel).read(metadata_name).decode()
|
||||
)
|
||||
for value in metadata.get_all("Requires-Dist", []):
|
||||
requirement, _, marker = value.partition(";")
|
||||
if marker.strip():
|
||||
continue
|
||||
match = re.fullmatch(
|
||||
r"fastmcp-slim(?:\[[^\]]+\])?==([^;\s]+)",
|
||||
requirement.strip(),
|
||||
)
|
||||
if match:
|
||||
print(match.group(1))
|
||||
break
|
||||
else:
|
||||
raise RuntimeError("Could not find the base fastmcp-slim dependency")
|
||||
PY
|
||||
)
|
||||
|
||||
for attempt in {1..12}; do
|
||||
if python - "$SLIM_VERSION" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
version = sys.argv[1]
|
||||
url = f"https://pypi.org/pypi/fastmcp-slim/{version}/json"
|
||||
with urllib.request.urlopen(url, timeout=30) as response:
|
||||
json.load(response)
|
||||
PY
|
||||
then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI yet; retrying (${attempt}/12)."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI; refusing to publish fastmcp-tasks." >&2
|
||||
exit 1
|
||||
|
||||
- name: Publish fastmcp-tasks to PyPI
|
||||
run: uv publish -v dist/fastmcp_tasks-*.tar.gz dist/fastmcp_tasks-*.whl
|
||||
52
.github/workflows/publish-fastmcp.yml
vendored
52
.github/workflows/publish-fastmcp.yml
vendored
|
|
@ -115,6 +115,58 @@ jobs:
|
|||
echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI; refusing to publish fastmcp." >&2
|
||||
exit 1
|
||||
|
||||
- name: Verify matching fastmcp-tasks is published
|
||||
run: |
|
||||
TASKS_VERSION=$(python - <<'PY'
|
||||
import email.parser
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
wheel = next(Path("dist").glob("fastmcp-*.whl"))
|
||||
metadata_name = next(
|
||||
name for name in zipfile.ZipFile(wheel).namelist()
|
||||
if name.endswith(".dist-info/METADATA")
|
||||
)
|
||||
metadata = email.parser.Parser().parsestr(
|
||||
zipfile.ZipFile(wheel).read(metadata_name).decode()
|
||||
)
|
||||
# fastmcp-tasks is pinned via the optional `tasks` extra, so its
|
||||
# Requires-Dist entry carries an `extra == "tasks"` marker — unlike the
|
||||
# base slim dependency, do not skip marked entries here.
|
||||
for value in metadata.get_all("Requires-Dist", []):
|
||||
requirement, _, _marker = value.partition(";")
|
||||
match = re.fullmatch(r"fastmcp-tasks==([^;\s]+)", requirement.strip())
|
||||
if match:
|
||||
print(match.group(1))
|
||||
break
|
||||
else:
|
||||
raise RuntimeError("Could not find the fastmcp-tasks extra dependency")
|
||||
PY
|
||||
)
|
||||
|
||||
for attempt in {1..12}; do
|
||||
if python - "$TASKS_VERSION" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
version = sys.argv[1]
|
||||
url = f"https://pypi.org/pypi/fastmcp-tasks/{version}/json"
|
||||
with urllib.request.urlopen(url, timeout=30) as response:
|
||||
json.load(response)
|
||||
PY
|
||||
then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "fastmcp-tasks ${TASKS_VERSION} is not available on PyPI yet; retrying (${attempt}/12)."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo "fastmcp-tasks ${TASKS_VERSION} is not available on PyPI; refusing to publish fastmcp (the [tasks] extra would be uninstallable)." >&2
|
||||
exit 1
|
||||
|
||||
- name: Publish fastmcp to PyPI
|
||||
run: uv publish -v dist/fastmcp-*.tar.gz dist/fastmcp-*.whl
|
||||
|
||||
|
|
|
|||
|
|
@ -190,9 +190,10 @@ Legacy mode is also what you need for the capabilities that depend on a live ses
|
|||
- **[Sampling](/clients/sampling)** — server-initiated LLM completion requests
|
||||
- **[Roots](/clients/roots)** — server-initiated requests for the client's roots
|
||||
- **[Elicitation](/clients/elicitation)** — server-initiated requests for user input, which modern connections replace with [input-required rounds](/clients/elicitation#input-required-rounds)
|
||||
- **[Background tasks](/clients/tasks)** — submitting an operation with `task=True`
|
||||
- `client.ping()` and `transport.get_session_id()`
|
||||
|
||||
Conversely, [background tasks](/clients/tasks) are **modern-only**: the tasks capability is negotiated over `2026-07-28` connections, so `mode="legacy"` never triggers one and a task-enabled tool just runs synchronously.
|
||||
|
||||
A FastMCP server serves both eras, so a default client negotiates the modern one and these raise an era-specific error. Pinning the handshake restores them.
|
||||
|
||||
You can also pin a specific modern protocol version to adopt it directly, without a discovery probe:
|
||||
|
|
@ -288,7 +289,7 @@ from myproject.extensions import AppsExtension
|
|||
client = Client("https://example.com/mcp", extensions=[AppsExtension()])
|
||||
```
|
||||
|
||||
Each extension's contributions are threaded into the underlying session. Notification bindings compose with FastMCP's own internal task-status binding rather than replacing it, so an extension that observes a custom notification and FastMCP's task tracking both work on the same connection. When a tool returns a shape an extension claims, `client.call_tool()` resolves it transparently through the owning claim's resolver and hands you back an ordinary result. Result claims and their advertisements are honored only on modern-era connections, so they are inert on a legacy handshake.
|
||||
Each extension's contributions are threaded into the underlying session. FastMCP folds in its own internal extension for [background tasks](/clients/tasks) automatically, and your own extensions *compose* with it rather than replacing it — pass your own tasks extension with the same identifier if you need to override it. When a tool returns a shape an extension claims, `client.call_tool()` resolves it transparently through the owning claim's resolver and hands you back an ordinary result. Result claims and their advertisements are honored only on modern-era connections, so they are inert on a legacy handshake.
|
||||
|
||||
For the rare case where you need to register additional result claims against an extension that is already advertised, pass them through `result_claims=`, keyed by the extension's identifier. Prefer declaring claims on the extension itself; this parameter merges extra claims with an extension's own.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,184 +1,136 @@
|
|||
---
|
||||
title: Background Tasks
|
||||
sidebarTitle: Tasks
|
||||
description: Execute operations asynchronously and track their progress.
|
||||
description: Call long-running tools without blocking, and answer questions they ask mid-run.
|
||||
icon: clock
|
||||
tag: "NEW"
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
<VersionBadge version="2.14.0" />
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
Use this when you need to run long operations asynchronously while doing other work.
|
||||
|
||||
The MCP task protocol lets you request operations to run in the background. The call returns a Task object immediately, letting you track progress, cancel operations, or await results.
|
||||
Some tool calls take a while. The MCP background tasks extension lets a server run one in the background instead of holding the request open, and FastMCP's client drives the whole thing for you — most of the time you don't need to know a call was tasked at all.
|
||||
|
||||
<Note>
|
||||
**Background tasks require the older MCP protocol.** FastMCP submits a task over the session that the `initialize` handshake opens, and protocol version `2026-07-28` has no equivalent. Clients default to `mode="auto"`, which negotiates the newest version both sides support, so the examples on this page pass `mode="legacy"`. See [protocol negotiation](/clients/client#protocol-negotiation).
|
||||
**Client task support is opt-in.** Install the `fastmcp-tasks` package (`pip install "fastmcp[tasks]"`) and import it — importing `fastmcp_tasks` anywhere (which you do to use `call_tool_task`) enables task support for every `Client` in the process. Without it, a `Client` never advertises the tasks capability, so the server runs its calls synchronously and background tasks simply don't happen.
|
||||
|
||||
**Tasks also require the modern protocol.** The capability is negotiated over `2026-07-28` connections. `mode="auto"` (the client default) negotiates it automatically; `mode="legacy"` never does. See [protocol negotiation](/clients/client#protocol-negotiation).
|
||||
</Note>
|
||||
|
||||
## Requesting Background Execution
|
||||
## Transparent Calls
|
||||
|
||||
Pass `task=True` to run an operation as a background task:
|
||||
With task support enabled, just call the tool. If the server runs it as a background task, `call_tool` polls it to completion under the hood and returns the same result you'd get from a synchronous call — the task is invisible.
|
||||
|
||||
```python
|
||||
import fastmcp_tasks # enables client task support
|
||||
from fastmcp import Client
|
||||
|
||||
async with Client(server, mode="auto") as client:
|
||||
result = await client.call_tool("slow_computation", {"duration": 10})
|
||||
print(result.data)
|
||||
```
|
||||
|
||||
This is the right default for most code: it works whether or not the server actually tasks the call, so you can write ordinary tool-calling code without checking server capabilities.
|
||||
|
||||
## Driving a Task Explicitly
|
||||
|
||||
When you want to do other work while a task runs — or check on it, or cancel it — use `call_tool_task` instead. It returns a `ToolTask` handle immediately rather than waiting for completion.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from fastmcp_tasks import call_tool_task
|
||||
|
||||
async with Client(server, mode="legacy") as client:
|
||||
# Start a background task
|
||||
task = await client.call_tool("slow_computation", {"duration": 10}, task=True)
|
||||
|
||||
async with Client(server, mode="auto") as client:
|
||||
task = await call_tool_task(client, "slow_computation", {"duration": 10})
|
||||
print(f"Task started: {task.task_id}")
|
||||
|
||||
# Do other work while it runs...
|
||||
|
||||
# Get the result when ready
|
||||
result = await task.result()
|
||||
```
|
||||
|
||||
This works with tools, resources, and prompts:
|
||||
|
||||
```python
|
||||
tool_task = await client.call_tool("my_tool", args, task=True)
|
||||
resource_task = await client.read_resource("file://large.txt", task=True)
|
||||
prompt_task = await client.get_prompt("my_prompt", args, task=True)
|
||||
```
|
||||
|
||||
## Task API
|
||||
|
||||
All task types share a common interface.
|
||||
|
||||
### Getting Results
|
||||
|
||||
Call `await task.result()` or simply `await task` to block until the task completes:
|
||||
|
||||
```python
|
||||
task = await client.call_tool("analyze", {"text": "hello"}, task=True)
|
||||
|
||||
# Wait for result (blocking)
|
||||
result = await task.result()
|
||||
# or: result = await task
|
||||
```
|
||||
`call_tool_task` requires the server to actually run the call as a task — if the tool isn't `task=True`, or the server doesn't have the tasks extension registered, it raises `ToolError`. Use it when you specifically need the handle; use `call_tool` when you just want the result.
|
||||
|
||||
### Checking Status
|
||||
|
||||
Check the current status without blocking:
|
||||
|
||||
```python
|
||||
status = await task.status()
|
||||
print(f"{status.status}: {status.statusMessage}")
|
||||
print(f"{status.status}: {status.status_message}")
|
||||
# status.status is "working", "input_required", "completed", "failed", or "cancelled"
|
||||
```
|
||||
|
||||
### Waiting with Control
|
||||
|
||||
Use `task.wait()` for more control over waiting. With no `state`, it returns when the task leaves `working`; pass a specific state when you need to wait for a particular transition:
|
||||
`task.wait()` polls until a terminal state (or a specific one you name), without answering any input the task asks for — use it when you want to observe an `input_required` pause yourself rather than have it answered automatically.
|
||||
|
||||
```python
|
||||
# Wait up to 30 seconds for completion
|
||||
status = await task.wait(timeout=30.0)
|
||||
|
||||
# Wait for a specific state
|
||||
status = await task.wait(state="completed", timeout=30.0)
|
||||
status = await task.wait(state="input_required", timeout=30.0)
|
||||
```
|
||||
|
||||
### Cancellation
|
||||
### Getting the Result
|
||||
|
||||
Cancel a running task:
|
||||
`task.result()` drives the task the rest of the way — including answering any input it asks for — and returns the finished result, same as `client.call_tool` would. Awaiting the task directly is shorthand for this.
|
||||
|
||||
```python
|
||||
result = await task.result()
|
||||
# or: result = await task
|
||||
```
|
||||
|
||||
By default a failed or cancelled task raises `ToolError`. Pass `raise_on_error=False` to `call_tool_task` to get an error result back instead.
|
||||
|
||||
### Cancellation
|
||||
|
||||
```python
|
||||
await task.cancel()
|
||||
```
|
||||
|
||||
## Status Updates
|
||||
Cancellation is cooperative — the task may still finish before the server notices the request.
|
||||
|
||||
Register callbacks to receive real-time status updates as the server reports progress:
|
||||
## Answering Questions Mid-Task
|
||||
|
||||
```python
|
||||
def on_status_change(status):
|
||||
print(f"Task {status.taskId}: {status.status} - {status.statusMessage}")
|
||||
|
||||
task.on_status_change(on_status_change)
|
||||
|
||||
# Async callbacks work too
|
||||
async def on_status_async(status):
|
||||
await log_status(status)
|
||||
|
||||
task.on_status_change(on_status_async)
|
||||
```
|
||||
|
||||
### Handler Template
|
||||
A task can pause partway through to ask a question, the same way a foreground [multi-round-trip](/clients/elicitation#input-required-rounds) tool does. Pass an `elicitation_handler` and both `call_tool` and `task.result()` answer it automatically as part of driving the task to completion:
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
def status_handler(status):
|
||||
"""
|
||||
Handle task status updates.
|
||||
async def handle_elicitation(message, response_type, params, context):
|
||||
return {"cuisine": "Thai", "vegetarian": True}
|
||||
|
||||
Args:
|
||||
status: Task status object with:
|
||||
- taskId: Unique task identifier
|
||||
- status: "working", "input_required", "completed", "failed", or "cancelled"
|
||||
- statusMessage: Optional progress message from server
|
||||
"""
|
||||
if status.status == "working":
|
||||
print(f"Progress: {status.statusMessage}")
|
||||
elif status.status == "completed":
|
||||
print("Task completed")
|
||||
elif status.status == "failed":
|
||||
print(f"Task failed: {status.statusMessage}")
|
||||
|
||||
task.on_status_change(status_handler)
|
||||
async with Client(server, mode="auto", elicitation_handler=handle_elicitation) as client:
|
||||
result = await client.call_tool("plan_dinner", {})
|
||||
print(result.data)
|
||||
```
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
You can always pass `task=True` regardless of whether the server supports background tasks. Per the MCP specification, servers without task support execute the operation immediately and return the result inline.
|
||||
|
||||
```python
|
||||
task = await client.call_tool("my_tool", args, task=True)
|
||||
|
||||
if task.returned_immediately:
|
||||
print("Server executed immediately (no background support)")
|
||||
else:
|
||||
print("Running in background")
|
||||
|
||||
# Either way, this works
|
||||
result = await task.result()
|
||||
```
|
||||
|
||||
This lets you write task-aware client code without worrying about server capabilities.
|
||||
Without an `elicitation_handler`, a task that asks for input raises `ToolError` rather than hanging. See [server-side background tasks](/servers/tasks#gathering-input-mid-task) for how a tool asks a question in the first place.
|
||||
|
||||
## Example
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from fastmcp import Client
|
||||
from fastmcp_tasks import call_tool_task
|
||||
|
||||
async def main():
|
||||
async with Client(server, mode="legacy") as client:
|
||||
# Start background task
|
||||
task = await client.call_tool(
|
||||
"slow_computation",
|
||||
{"duration": 10},
|
||||
task=True,
|
||||
)
|
||||
async with Client(server, mode="auto") as client:
|
||||
# Return immediately and drive the task yourself
|
||||
task = await call_tool_task(client, "slow_computation", {"duration": 10})
|
||||
print(f"Task started: {task.task_id}")
|
||||
|
||||
# Subscribe to updates
|
||||
def on_update(status):
|
||||
print(f"Progress: {status.statusMessage}")
|
||||
# Do other work while the task runs
|
||||
while True:
|
||||
status = await task.status()
|
||||
if status.status in ("completed", "failed", "cancelled"):
|
||||
break
|
||||
print(f"Still working... ({status.status})")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
task.on_status_change(on_update)
|
||||
|
||||
# Do other work while task runs
|
||||
print("Doing other work...")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Wait for completion and get result
|
||||
result = await task.result()
|
||||
print(f"Result: {result.content}")
|
||||
print(f"Result: {result.data}")
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
|
|
|||
|
|
@ -939,7 +939,7 @@ v3.0 implements MCP SEP-1686 for background task execution via Docket integratio
|
|||
**Configuration** (`fastmcp_slim/fastmcp/server/tasks/config.py`):
|
||||
|
||||
```python
|
||||
from fastmcp.server.tasks import TaskConfig
|
||||
from fastmcp.utilities.tasks import TaskConfig
|
||||
|
||||
@mcp.tool(task=TaskConfig(mode="required"))
|
||||
async def long_running_task():
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
title: Background Tasks (SEP-2663)
|
||||
---
|
||||
|
||||
**Status: Designed — approved for implementation.** This page is the approved design for rebuilding FastMCP's background-task support on the `io.modelcontextprotocol/tasks` extension. It supersedes the earlier "delete the task machinery" direction recorded during the SDK v2 migration. Implementation is sequenced behind the [extension API](#the-extension-api); the [Feature Program](/development/v4-notes/feature-program#background-tasks-sep-2663) carries the one-line status.
|
||||
**Status: Shipped (#4602, #4603).** This page is the approved design for rebuilding FastMCP's background-task support on the `io.modelcontextprotocol/tasks` extension. It supersedes the earlier "delete the task machinery" direction recorded during the SDK v2 migration. The [Feature Program](/development/v4-notes/feature-program#background-tasks-sep-2663) carries the one-line status; user-facing usage is documented at [Background Tasks](/servers/tasks) and [Background Tasks (client)](/clients/tasks).
|
||||
|
||||
## TL;DR
|
||||
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ A cluster of protocol features tracked for v4. Their statuses have diverged:
|
|||
|
||||
## FastMCP-native extension API
|
||||
|
||||
**Status: Designed.**
|
||||
**Status: Shipped (#4602).**
|
||||
|
||||
MCP extensions (SEP-2133) are optional, capability-negotiated protocol features identified by a reverse-DNS string — `io.modelcontextprotocol/ui` (MCP Apps), `io.modelcontextprotocol/tasks` (SEP-2663). They are a genuinely new abstraction in SDK v2; they did not exist in v1. The SDK exposes them through an `Extension` server class that contributes a capability, additive request methods, and a `tools/call` interceptor, plus a symmetric `ClientExtension` with result claims and notification bindings.
|
||||
|
||||
|
|
@ -125,7 +125,7 @@ The Designed work is a FastMCP-native server extension API — a single registra
|
|||
|
||||
## Background tasks (SEP-2663)
|
||||
|
||||
**Status: Designed — approved for implementation.**
|
||||
**Status: Shipped (#4603).**
|
||||
|
||||
Background tasks return to the modern era as `fastmcp-tasks`, an in-repo optional package rebuilt on the `io.modelcontextprotocol/tasks` extension (SEP-2663, Final, merged upstream 2026-05-15). SEP-2663 supersedes SEP-1686 but keeps its polling core: a client that advertises the tasks capability issues an augmented `tools/call`; the server decides whether to run it as a task and returns a `CreateTaskResult` carrying a server-generated task id; the client polls `tasks/get` until terminal and reads the result inlined there. FastMCP's existing SEP-1686 wire layer is removed while the Docket/Redis execution engine underneath moves into `fastmcp-tasks` intact — the spec moved toward what FastMCP already built, so the rebuild is mostly deletion plus a thin wire adapter. `task=True` stays the authoring surface (gated by the `fastmcp[tasks]` extra and an explicit `mcp.add_extension(TasksExtension(...))`, the first consumer of the [extension API](#fastmcp-native-extension-api) above), so a server that already uses tasks needs no code change. Scope for v1 is polling-only and `tools/call`-only.
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ title: v4.0 Development Notes
|
|||
This directory is the working map of FastMCP v4.0: the complete register of user-facing changes from the MCP Python SDK v2 migration ([PR #4437](https://github.com/PrefectHQ/fastmcp/pull/4437)), plus the forward v4 feature program. It plays three roles at once.
|
||||
|
||||
1. **A change register.** Every user-visible change from the migration, organized by subsystem, with a note on how FastMCP handles it (absorbed, bridged, breaking, or deprecated) and where to find it in the diff. This is the [Change Register](/development/v4-notes/change-register).
|
||||
2. **A feature program.** The forward v4 work — sampling removal, multi-round-trip elicitation, the first-class 2026 client, a FastMCP-native extension API, the SEP-2663 background-tasks rebuild, and the SDK-delegation round-two convergence — now a mix of shipped, designed, and pending. Multi-round-trip guard tools (#4544) and the client's `mode="auto"` default with a partial SDK-composition (#4572/#4574, full composition blocked upstream) have shipped; sampling removal, the extension API, tasks, and SDK delegation remain ahead. Each carries an explicit status in the [Feature Program](/development/v4-notes/feature-program). The shipped side — what a v4 deployment provides on the modern protocol today, including the complete server-side SEP-990 identity assertion implementation — is cataloged in [2026-07-28 Protocol Support](/development/v4-notes/protocol-2026).
|
||||
2. **A feature program.** The forward v4 work — sampling removal, multi-round-trip elicitation, the first-class 2026 client, a FastMCP-native extension API, the SEP-2663 background-tasks rebuild, and the SDK-delegation round-two convergence — now a mix of shipped, designed, and pending. Multi-round-trip guard tools (#4544), the client's `mode="auto"` default with a partial SDK-composition (#4572/#4574, full composition blocked upstream), the extension API (#4602), and background tasks on SEP-2663 (#4603) have shipped; sampling removal and SDK delegation remain ahead. Each carries an explicit status in the [Feature Program](/development/v4-notes/feature-program). The shipped side — what a v4 deployment provides on the modern protocol today, including the complete server-side SEP-990 identity assertion implementation — is cataloged in [2026-07-28 Protocol Support](/development/v4-notes/protocol-2026).
|
||||
3. **A review lens.** Because the migration PR is too large to review line by line, the change register is organized so a reviewer can take one subsystem, read its claimed changes, and verify each against the diff. The [Known Gaps](/development/v4-notes/known-gaps) page collects the deliberate xfails and the upstream dependencies that gate the follow-up work.
|
||||
|
||||
## Why v4 exists
|
||||
|
|
|
|||
|
|
@ -46,11 +46,8 @@ The complete picture of what a FastMCP v4 server and client provide on the `2026
|
|||
| **Composition** | `mount()`, providers, proxying, and tool transforms compose servers dynamically at runtime, with lifespans and middleware driven through the SDK session manager. |
|
||||
| **Pagination** | Declarative `FastMCP(list_page_size=...)` paginates all list operations in the high-level server; the client auto-paginates with cycle detection. |
|
||||
| **Telemetry** | OpenTelemetry spans on by default (no-op without an exporter), SDK-aligned attributes (`mcp.method.name`, `mcp.protocol.version`, `gen_ai.*`), plus auth and provider-delegation spans; `FASTMCP_ENABLE_TELEMETRY=false` disables cleanly. |
|
||||
|
||||
**Background tasks are not yet in the table because their modern-era support is being rebuilt.** The current `@mcp.tool(task=True)` runtime implements the 2025 task wire protocol (SEP-1686), which left the core MCP spec. Tasks did not disappear — they were reworked into the `io.modelcontextprotocol/tasks` extension (SEP-2663, Final, merged upstream 2026-05-15), a capability-negotiated feature layered on the extensions mechanism. So on `2026-07-28` the current SEP-1686 wire layer does not apply, and `task=True` completes only on handshake-era connections today.
|
||||
|
||||
The plan is to rebuild task support on SEP-2663 as an in-repo optional package, `fastmcp-tasks`, gated by `task=True` exactly as `app=True` gates `prefab-ui`. The SEP-1686 wire layer is removed, but the Docket/Redis execution engine underneath it is extracted and re-adapted to the SEP-2663 wire shape — a polling protocol (augmented `tools/call` → `CreateTaskResult` → poll `tasks/get`, resolve in-task input via `tasks/update`) that the durable engine already fits. `task=True` stays the authoring surface, so a server that opts into tasks needs no code change when the wire underneath modernizes. This is a Designed feature — see [Background Tasks (SEP-2663)](/development/v4-notes/background-tasks) for the full design, and [Known Gaps](/development/v4-notes/known-gaps#the-xfail-register) for the SEP-1686-layer removal tracking.
|
||||
| **Background tasks (SEP-2663)** | `fastmcp-tasks` implements the `io.modelcontextprotocol/tasks` extension end to end: `mcp.add_extension(TasksExtension())` plus `task=True` runs a tool as a background task, driven by the same Docket engine FastMCP 3 used. A client transparently completes a tasked call; gathering input mid-task uses the same guard pattern as foreground multi-round-trip tools, so a tool is written once and works either way. Modern-protocol only — the `task=True` runtime this replaced (SEP-1686) is gone entirely, not bridged. See [Background Tasks (SEP-2663)](/development/v4-notes/background-tasks) for the design and [servers/tasks](/servers/tasks) for usage. |
|
||||
|
||||
## Still in the program
|
||||
|
||||
Elicitation on the modern protocol is now shipped in its **guard form** — a tool returns an `InputRequiredResult` and re-runs per round to gather user input via multi-round trips (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)). The declarative `Resolve(...)` layer over that primitive remains staged, tracked in the [Feature Program](/development/v4-notes/feature-program), along with the unified `subscriptions/listen` stream and the SEP-2663 tasks extension. The [Known Gaps](/development/v4-notes/known-gaps) page tracks the upstream dependencies that gate them.
|
||||
Elicitation on the modern protocol is now shipped in its **guard form** — a tool returns an `InputRequiredResult` and re-runs per round to gather user input via multi-round trips (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)). The declarative `Resolve(...)` layer over that primitive remains staged, tracked in the [Feature Program](/development/v4-notes/feature-program), along with the unified `subscriptions/listen` stream. The [Known Gaps](/development/v4-notes/known-gaps) page tracks the upstream dependencies that gate them.
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ BREAKING CHANGES (will crash at import or runtime):
|
|||
|
||||
12. REPO MOVE: GitHub repository moved from jlowin/fastmcp to PrefectHQ/fastmcp. Update git remotes and dependency URLs that reference the old location.
|
||||
|
||||
13. BACKGROUND TASKS: FastMCP's background task system (SEP-1686) is now an optional dependency. If the code uses task=True or TaskConfig, add pip install "fastmcp[tasks]".
|
||||
13. BACKGROUND TASKS: FastMCP's background task system is now an optional dependency. If the code uses task=True or TaskConfig, add pip install "fastmcp[tasks]".
|
||||
|
||||
DEPRECATIONS (still work but emit warnings):
|
||||
|
||||
|
|
@ -321,7 +321,7 @@ If you have code that treats the decorated result as a `FunctionTool` (e.g., acc
|
|||
|
||||
**Background tasks require optional dependency**
|
||||
|
||||
FastMCP's background task system (SEP-1686) is now behind an optional extra. If your server uses background tasks, install with:
|
||||
FastMCP's background task system is now behind an optional extra. If your server uses background tasks, install with:
|
||||
|
||||
```bash
|
||||
pip install "fastmcp[tasks]"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ description: Configure FastMCP behavior through environment variables or a .env
|
|||
icon: gear
|
||||
---
|
||||
|
||||
FastMCP uses [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) for configuration. Every setting is available as an environment variable with a `FASTMCP_` prefix. Settings are loaded from environment variables and from a `.env` file (see the [Tasks (Docket)](#tasks-docket) section for a caveat about nested settings in `.env` files).
|
||||
FastMCP uses [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) for configuration. Every setting is available as an environment variable with a `FASTMCP_` prefix. Settings are loaded from environment variables and from a `.env` file.
|
||||
|
||||
```bash
|
||||
# Set via environment
|
||||
|
|
@ -63,7 +63,7 @@ These control how the server listens when running with an HTTP transport.
|
|||
|---|---|---|---|
|
||||
| `FASTMCP_CLIENT_INIT_TIMEOUT` | `float \| None` | None | Timeout in seconds for the client initialization handshake. Set to `0` or leave unset to disable. |
|
||||
| `FASTMCP_CLIENT_DISCONNECT_TIMEOUT` | `float` | `5` | Maximum time in seconds to wait for a clean disconnect before giving up. |
|
||||
| `FASTMCP_CLIENT_TASK_POLL_INTERVAL` | `float` | `0.5` | Ceiling in seconds for the fallback poll backoff while waiting on a [background task](/servers/tasks). 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 rather than polling at a fixed cadence. When the server advertises a `pollInterval`, that interval is honored exactly and this setting is ignored. |
|
||||
| `FASTMCP_TASKS_CLIENT_POLL_INTERVAL` | `float` | `0.5` | Ceiling in seconds for the fallback poll backoff while waiting on a [background task](/servers/tasks). Requires the `fastmcp-tasks` package. 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 rather than polling at a fixed cadence. When the server advertises a `pollInterval`, that interval is honored exactly and this setting is ignored. |
|
||||
| `FASTMCP_CLIENT_RAISE_FIRST_EXCEPTIONGROUP_ERROR` | `bool` | `true` | When an `ExceptionGroup` is raised, re-raise the first error directly instead of the group. Simplifies debugging but may mask secondary errors. |
|
||||
|
||||
## CLI & Display
|
||||
|
|
@ -81,21 +81,7 @@ These control how the server listens when running with an HTTP transport.
|
|||
|
||||
## Tasks (Docket)
|
||||
|
||||
These configure the [Docket](https://github.com/prefecthq/docket) task queue used by [server tasks](/servers/tasks). All use the `FASTMCP_DOCKET_` prefix.
|
||||
|
||||
<Warning>
|
||||
When setting Docket values in a `.env` file, use a **double** underscore: `FASTMCP_DOCKET__URL` (not `FASTMCP_DOCKET_URL`). This is because `.env` values are resolved through the parent `Settings` class, which uses `__` as its nested delimiter. As regular environment variables (e.g., `export`), the single-underscore form `FASTMCP_DOCKET_URL` works fine.
|
||||
</Warning>
|
||||
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `FASTMCP_DOCKET_NAME` | `str` | `fastmcp` | Queue name. Servers and workers sharing the same name and backend URL share a task queue. |
|
||||
| `FASTMCP_DOCKET_URL` | `str` | `memory://` | Backend URL. Use `memory://` for single-process or `redis://host:port/db` for distributed workers. |
|
||||
| `FASTMCP_DOCKET_WORKER_NAME` | `str \| None` | None | Worker name. Auto-generated if unset. |
|
||||
| `FASTMCP_DOCKET_CONCURRENCY` | `int` | `10` | Maximum concurrent tasks per worker. |
|
||||
| `FASTMCP_DOCKET_REDELIVERY_TIMEOUT` | `timedelta` | `300s` | If a worker doesn't complete a task within this time, it's redelivered to another worker. |
|
||||
| `FASTMCP_DOCKET_RECONNECTION_DELAY` | `timedelta` | `5s` | Delay between reconnection attempts when the worker loses its backend connection. |
|
||||
| `FASTMCP_DOCKET_MINIMUM_CHECK_INTERVAL` | `timedelta` | `50ms` | How frequently the worker polls for new tasks. Lower values reduce latency at the cost of more CPU usage. |
|
||||
Task settings (the `FASTMCP_DOCKET_` variables) moved to the optional `fastmcp-tasks` package. See [server tasks](/servers/tasks) for configuration.
|
||||
|
||||
## Security
|
||||
|
||||
|
|
|
|||
|
|
@ -160,10 +160,9 @@ def get_client_ip() -> str:
|
|||
```
|
||||
|
||||
<Note>
|
||||
Both raise `RuntimeError` when called outside an HTTP context (e.g., STDIO transport).
|
||||
For background tasks created from an HTTP request, FastMCP restores a minimal request
|
||||
backed by the originating request's snapshotted headers. Use HTTP Headers if you need
|
||||
graceful fallback.
|
||||
Both raise `RuntimeError` when called outside an HTTP context (e.g., STDIO transport,
|
||||
or inside a background task — there is no live request object to reconstruct there).
|
||||
Use HTTP Headers below if you need graceful fallback, including inside background tasks.
|
||||
</Note>
|
||||
|
||||
### HTTP Headers
|
||||
|
|
@ -282,7 +281,8 @@ For background task execution, FastMCP provides dependencies that integrate with
|
|||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.dependencies import CurrentDocket, CurrentWorker, Progress
|
||||
from fastmcp.dependencies import Progress
|
||||
from fastmcp_tasks.dependencies import CurrentDocket, CurrentWorker
|
||||
|
||||
mcp = FastMCP("Task Demo")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,58 +1,59 @@
|
|||
---
|
||||
title: Background Tasks
|
||||
sidebarTitle: Background Tasks
|
||||
description: Run long-running operations asynchronously with progress tracking
|
||||
description: Run long-running tools asynchronously with progress tracking
|
||||
icon: clock
|
||||
tag: "NEW"
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
<VersionBadge version="2.14.0" />
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
<Tip>
|
||||
Background tasks require the `tasks` optional extra. See [installation instructions](#enabling-background-tasks) below.
|
||||
Background tasks require the `fastmcp-tasks` package. See [enabling background tasks](#enabling-background-tasks) below.
|
||||
</Tip>
|
||||
|
||||
FastMCP implements the MCP background task protocol ([SEP-1686](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks)), giving your servers a production-ready distributed task scheduler with a single decorator change.
|
||||
FastMCP implements the MCP background tasks extension ([`io.modelcontextprotocol/tasks`](https://modelcontextprotocol.io/extensions/tasks/overview), SEP-2663), giving your servers a production-ready distributed task scheduler with one extension registration and a decorator change.
|
||||
|
||||
<Tip>
|
||||
**What is Docket?** FastMCP's task system is powered by [Docket](https://github.com/chrisguidry/docket), originally built by [Prefect](https://prefect.io) to power [Prefect Cloud](https://www.prefect.io/prefect/cloud)'s managed task scheduling and execution service, where it processes millions of concurrent tasks every day. Docket is now open-sourced for the community.
|
||||
</Tip>
|
||||
|
||||
|
||||
## What Are MCP Background Tasks?
|
||||
|
||||
In MCP, all component interactions are blocking by default. When a client calls a tool, reads a resource, or fetches a prompt, it sends a request and waits for the response. For operations that take seconds or minutes, this creates a poor user experience.
|
||||
In MCP, a tool call is blocking by default. When a client calls a tool, it sends a request and waits for the response. For operations that take seconds or minutes, this creates a poor user experience.
|
||||
|
||||
The MCP background task protocol solves this by letting clients:
|
||||
1. **Start** an operation and receive a task ID immediately
|
||||
2. **Track** progress as the operation runs
|
||||
3. **Retrieve** the result when ready
|
||||
Background tasks solve this by letting a server tell a supporting client:
|
||||
1. **Start** the tool and return a task ID immediately
|
||||
2. **Poll** for status as the tool runs
|
||||
3. **Retrieve** the result when ready — or answer a question the tool asks mid-run
|
||||
|
||||
FastMCP handles all of this for you. Add `task=True` to your decorator, and your function gains full background execution with progress reporting, distributed processing, and horizontal scaling.
|
||||
FastMCP handles all of this for you. Add `task=True` to a tool decorator and register the tasks extension, and your function gains background execution with progress reporting, distributed processing, and horizontal scaling.
|
||||
|
||||
### MCP Background Tasks vs Python Concurrency
|
||||
|
||||
You can always use Python's concurrency primitives (asyncio, threads, multiprocessing) or external task queues in your FastMCP servers. FastMCP is just Python—run code however you like.
|
||||
|
||||
MCP background tasks are different: they're **protocol-native**. This means MCP clients that support the task protocol can start operations, receive progress updates, and retrieve results through the standard MCP interface. The coordination happens at the protocol level, not inside your application code.
|
||||
MCP background tasks are different: they're **protocol-native**. This means MCP clients that support the tasks extension can start a call, poll it, and retrieve its result through the standard MCP interface. The coordination happens at the protocol level, not inside your application code.
|
||||
|
||||
## Enabling Background Tasks
|
||||
|
||||
<VersionBadge version="3.0.0" /> Background tasks require the `tasks` extra:
|
||||
Background tasks require the `fastmcp-tasks` package:
|
||||
|
||||
```bash
|
||||
pip install "fastmcp[tasks]"
|
||||
```
|
||||
|
||||
Add `task=True` to any tool, resource, resource template, or prompt decorator. This marks the component as capable of background execution.
|
||||
Register `TasksExtension` on your server, then add `task=True` to a tool decorator. `task=True` marks the tool as *capable* of background execution; the extension is what actually runs it — a `task=True` tool on a server with no tasks extension registered raises at server startup.
|
||||
|
||||
```python {6}
|
||||
```python {5,8}
|
||||
import asyncio
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp_tasks import TasksExtension
|
||||
|
||||
mcp = FastMCP("MyServer")
|
||||
mcp.add_extension(TasksExtension())
|
||||
|
||||
@mcp.tool(task=True)
|
||||
async def slow_computation(duration: int) -> str:
|
||||
|
|
@ -62,34 +63,38 @@ async def slow_computation(duration: int) -> str:
|
|||
return f"Completed in {duration} seconds"
|
||||
```
|
||||
|
||||
When a client requests background execution, the call returns immediately with a task ID. The work executes in a background worker, and the client can poll for status or wait for the result.
|
||||
Whether a given call actually runs as a task depends on the client: it opts in per request, and the *server* decides based on the tool's execution mode (below). When it does run as a task, the call returns immediately with a task ID; the work executes in a background worker, and the client polls for the result. A [FastMCP client](/clients/tasks) does all of this transparently — `client.call_tool(...)` looks the same either way.
|
||||
|
||||
Background tasks are a modern-protocol feature: the tasks capability is negotiated over `2026-07-28` connections, so a client pinned to `mode="legacy"` never triggers one — the tool always runs synchronously for it.
|
||||
|
||||
<Warning>
|
||||
Background tasks require async functions. Attempting to use `task=True` with a sync function raises a `ValueError` at registration time.
|
||||
Background tasks require async functions. Attempting to use `task=True` with a sync function raises a `ValueError` at registration time. Only tools can be task-enabled; resources, resource templates, and prompts do not carry `task=`.
|
||||
</Warning>
|
||||
|
||||
## Execution Modes
|
||||
|
||||
For fine-grained control over task execution behavior, use `TaskConfig` instead of the boolean shorthand. The MCP task protocol defines three execution modes:
|
||||
For fine-grained control over task execution behavior, use `TaskConfig` instead of the boolean shorthand. The tasks extension defines three execution modes:
|
||||
|
||||
| Mode | Client calls without task | Client calls with task |
|
||||
| Mode | Client calls without the tasks capability | Client calls with the tasks capability |
|
||||
|------|--------------------------|------------------------|
|
||||
| `"forbidden"` | Executes synchronously | Error: task not supported |
|
||||
| `"optional"` | Executes synchronously | Executes as background task |
|
||||
| `"required"` | Error: task required | Executes as background task |
|
||||
| `"forbidden"` | Executes synchronously | Executes synchronously (never tasked) |
|
||||
| `"optional"` | Executes synchronously | Executes as a background task |
|
||||
| `"required"` | Error: task required | Executes as a background task |
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.tasks import TaskConfig
|
||||
from fastmcp.utilities.tasks import TaskConfig
|
||||
from fastmcp_tasks import TasksExtension
|
||||
|
||||
mcp = FastMCP("MyServer")
|
||||
mcp.add_extension(TasksExtension())
|
||||
|
||||
# Supports both sync and background execution (default when task=True)
|
||||
@mcp.tool(task=TaskConfig(mode="optional"))
|
||||
async def flexible_task() -> str:
|
||||
return "Works either way"
|
||||
|
||||
# Requires background execution - errors if client doesn't request task
|
||||
# Requires background execution - errors if the client didn't opt in
|
||||
@mcp.tool(task=TaskConfig(mode="required"))
|
||||
async def must_be_background() -> str:
|
||||
return "Only runs as a background task"
|
||||
|
|
@ -104,18 +109,20 @@ The boolean shortcuts map to these modes:
|
|||
- `task=True` → `TaskConfig(mode="optional")`
|
||||
- `task=False` → `TaskConfig(mode="forbidden")`
|
||||
|
||||
When a `mode="required"` tool is called by a client that didn't opt in, FastMCP returns a "missing required capability" error rather than running it synchronously.
|
||||
|
||||
### Poll Interval
|
||||
|
||||
<VersionBadge version="2.15.0" />
|
||||
|
||||
When clients poll for task status, the server tells them how frequently to check back. By default, FastMCP suggests a 5-second interval, but you can customize this per component:
|
||||
When a client polls for task status, the server can suggest how frequently to check back:
|
||||
|
||||
```python
|
||||
from datetime import timedelta
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.tasks import TaskConfig
|
||||
from fastmcp.utilities.tasks import TaskConfig
|
||||
from fastmcp_tasks import TasksExtension
|
||||
|
||||
mcp = FastMCP("MyServer")
|
||||
mcp.add_extension(TasksExtension())
|
||||
|
||||
# Poll every 2 seconds for a fast-completing task
|
||||
@mcp.tool(task=TaskConfig(mode="optional", poll_interval=timedelta(seconds=2)))
|
||||
|
|
@ -128,31 +135,33 @@ async def slow_task() -> str:
|
|||
return "Eventually done"
|
||||
```
|
||||
|
||||
Shorter intervals give clients faster feedback but increase server load. Longer intervals reduce load but delay status updates. FastMCP clients honor the advertised interval exactly, so this is a real load control — but note that status notifications still wake a waiting client immediately, so the interval only governs how quickly a *missed* notification is noticed.
|
||||
Shorter intervals give clients faster feedback but increase server load. The interval is a ceiling, not an exact cadence — the FastMCP client starts polling quickly and backs off toward it, so a fast task is still observed as done almost immediately.
|
||||
|
||||
### Server-Wide Default
|
||||
|
||||
To enable background task support for all components by default, pass `tasks=True` to the constructor. Individual decorators can still override this with `task=False`.
|
||||
To enable background task support for all tools by default, pass `tasks=True` to the constructor. Individual decorators can still override this with `task=False`.
|
||||
|
||||
```python
|
||||
mcp = FastMCP("MyServer", tasks=True)
|
||||
```
|
||||
|
||||
<Warning>
|
||||
If your server defines any synchronous tools, resources, or prompts, you will need to explicitly set `task=False` on their decorators to avoid an error.
|
||||
If your server defines any synchronous tools, you will need to explicitly set `task=False` on their decorators to avoid an error.
|
||||
</Warning>
|
||||
|
||||
### Graceful Degradation
|
||||
|
||||
When a client requests background execution but the component has `mode="forbidden"`, FastMCP rejects the task-augmented request with a `METHOD_NOT_FOUND` error saying the component does not support task execution. The high-level FastMCP tool client can surface this as an immediate errored `ToolTask` when you call `client.call_tool(..., task=True, raise_on_error=False)`, but the server does not run the forbidden component synchronously for that task request.
|
||||
|
||||
Conversely, when a component has `mode="required"` but the client doesn't request background execution, FastMCP returns an error indicating that task execution is required.
|
||||
|
||||
### Configuration
|
||||
|
||||
`TasksExtension` takes the backend configuration directly, with `FASTMCP_DOCKET_*` environment variables as defaults — so `TasksExtension()` works out of the box against an env-configured deployment:
|
||||
|
||||
```python
|
||||
mcp.add_extension(TasksExtension(url="redis://localhost:6379/0", concurrency=20))
|
||||
```
|
||||
|
||||
| Environment Variable | Default | Description |
|
||||
|---------------------|---------|-------------|
|
||||
| `FASTMCP_DOCKET_URL` | `memory://` | Backend URL (`memory://` or `redis://host:port/db`) |
|
||||
| `FASTMCP_DOCKET_NAME` | `fastmcp` | Queue name. Servers and workers sharing a name and URL share a queue. |
|
||||
| `FASTMCP_DOCKET_CONCURRENCY` | `10` | Maximum concurrent tasks per worker. |
|
||||
|
||||
## Backends
|
||||
|
||||
|
|
@ -173,7 +182,11 @@ The in-memory backend (`memory://`) requires zero configuration and works out of
|
|||
|
||||
### Redis Backend
|
||||
|
||||
For production deployments, use Redis (or Valkey) as your backend by setting `FASTMCP_DOCKET_URL=redis://localhost:6379`.
|
||||
For production deployments, use Redis (or Valkey) as your backend:
|
||||
|
||||
```python
|
||||
mcp.add_extension(TasksExtension(url="redis://localhost:6379/0"))
|
||||
```
|
||||
|
||||
**Advantages:**
|
||||
- **Persistent**: Tasks survive server restarts
|
||||
|
|
@ -182,19 +195,19 @@ For production deployments, use Redis (or Valkey) as your backend by setting `FA
|
|||
|
||||
## Workers
|
||||
|
||||
Every FastMCP server with task-enabled components automatically starts an **embedded worker**. You do not need to start a separate worker process for tasks to execute.
|
||||
Every FastMCP server with task-enabled tools automatically starts an **embedded worker**. You do not need to start a separate worker process for tasks to execute.
|
||||
|
||||
To scale horizontally, add more workers using the CLI:
|
||||
To scale horizontally, add more workers:
|
||||
|
||||
```bash
|
||||
fastmcp tasks worker server.py
|
||||
python -m fastmcp_tasks.worker_cli worker server.py
|
||||
```
|
||||
|
||||
Each additional worker pulls tasks from the same queue, distributing load across processes. Configure worker concurrency via environment:
|
||||
|
||||
```bash
|
||||
export FASTMCP_DOCKET_CONCURRENCY=20
|
||||
fastmcp tasks worker server.py
|
||||
python -m fastmcp_tasks.worker_cli worker server.py
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
|
@ -202,7 +215,47 @@ Additional workers only work with Redis/Valkey backends. The in-memory backend i
|
|||
</Note>
|
||||
|
||||
<Warning>
|
||||
Task-enabled components must be defined at server startup to be registered with all workers. Components added dynamically after the server starts will not be available for background execution.
|
||||
Task-enabled tools must be defined at server startup to be registered with all workers. Tools added dynamically after the server starts will not be available for background execution.
|
||||
</Warning>
|
||||
|
||||
## Gathering Input Mid-Task
|
||||
|
||||
A tool can ask the client a question partway through — the same [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol) used for multi-round-trip input on foreground calls: instead of awaiting a response, the tool *returns* one, and FastMCP re-runs it once the client answers.
|
||||
|
||||
```python
|
||||
from fastmcp import Context, FastMCP
|
||||
from fastmcp_tasks import TasksExtension
|
||||
import mcp_types
|
||||
|
||||
mcp = FastMCP("MyServer")
|
||||
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:
|
||||
# First leg: ask a question and end here.
|
||||
request = mcp_types.ElicitRequest(
|
||||
params=mcp_types.ElicitRequestFormParams(
|
||||
message="What are you in the mood for?",
|
||||
requested_schema={"type": "object", "properties": {"cuisine": {"type": "string"}}},
|
||||
)
|
||||
)
|
||||
return mcp_types.InputRequiredResult(
|
||||
result_type="input_required",
|
||||
input_requests={"prefs": request},
|
||||
)
|
||||
|
||||
# Re-entered leg: the client's answer is on ctx.input_responses.
|
||||
answer = responses["prefs"]
|
||||
assert isinstance(answer, mcp_types.ElicitResult)
|
||||
return f"Tonight: {answer.content['cuisine']}!"
|
||||
```
|
||||
|
||||
Run as a task, this "ends" the tool's first leg entirely rather than blocking a worker on the client's answer: the task reports `input_required`, the client answers, and FastMCP re-invokes the tool with the answer attached. No worker ever sits idle waiting on a round-trip — the same tool works identically whether it's called synchronously or as a background task, and a [FastMCP client](/clients/tasks) answers the question automatically through its elicitation handler.
|
||||
|
||||
<Warning>
|
||||
Imperative `await ctx.elicit(...)` is not supported inside a background task — it would require blocking a worker for the length of a client round-trip. Use the guard pattern (return `InputRequiredResult`) instead; calling `ctx.elicit()` from a task-enabled tool raises with guidance toward the guard pattern.
|
||||
</Warning>
|
||||
|
||||
## Progress Reporting
|
||||
|
|
@ -241,7 +294,8 @@ FastMCP exposes Docket's full dependency injection system within your task-enabl
|
|||
```python
|
||||
from docket import Docket, Worker
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.dependencies import Progress, CurrentDocket, CurrentWorker
|
||||
from fastmcp.dependencies import Progress
|
||||
from fastmcp_tasks.dependencies import CurrentDocket, CurrentWorker
|
||||
|
||||
mcp = FastMCP("MyServer")
|
||||
|
||||
|
|
@ -260,4 +314,4 @@ async def my_task(
|
|||
return "Done"
|
||||
```
|
||||
|
||||
With `CurrentDocket()`, you can schedule additional background tasks, chain work together, and coordinate complex workflows. See the [Docket documentation](https://chrisguidry.github.io/docket/) for the complete API, including retry policies, timeouts, and custom dependencies.
|
||||
With `CurrentDocket()`, you can schedule additional background tasks, chain work together, and coordinate complex workflows. See the [Docket documentation](https://docket.lol/) for the complete API, including retry policies, timeouts, and custom dependencies.
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ The server creates spans for each operation using [MCP semantic conventions](htt
|
|||
| `tools/call {name}` | Tool execution (e.g., `tools/call get_weather`) |
|
||||
| `resources/read` | Resource read (URI in `mcp.resource.uri` attribute, not span name) |
|
||||
| `prompts/get {name}` | Prompt render (e.g., `prompts/get greeting`) |
|
||||
| `tasks/{operation}` | Task management (`tasks/get`, `tasks/result`, `tasks/list`, or `tasks/cancel`) |
|
||||
| `tasks/{operation}` | Task management (`tasks/get`, `tasks/update`, or `tasks/cancel`) |
|
||||
|
||||
For mounted servers, an additional `delegate {name}` span shows the delegation to the child server.
|
||||
|
||||
|
|
@ -100,12 +100,12 @@ tools/call remote_search (CLIENT)
|
|||
|
||||
Background task traces have two parts:
|
||||
|
||||
- Task submission and management requests use normal client-to-server context propagation. `tasks/get`, `tasks/result`, `tasks/list`, and `tasks/cancel` server spans are descendants of the corresponding FastMCP client spans.
|
||||
- Task submission and management requests use normal client-to-server context propagation. `tasks/get`, `tasks/update`, and `tasks/cancel` server spans are descendants of the corresponding FastMCP client spans.
|
||||
- Deferred execution runs in a Docket worker. Docket records its `CONSUMER` span as a new trace root with a span link to the submission context, rather than making it a child of the submission span. Custom spans created inside the task are children of that worker span.
|
||||
|
||||
Span links preserve the causal relationship without forcing worker sampling to inherit the submit trace's sampling decision. Some tracing backends do not display links prominently, so the worker trace may look disconnected even though the link is present.
|
||||
|
||||
Frequent status and list polling can produce more detail than you need. You can drop those client and server spans with a sampler that checks the span name before delegating to `ParentBased`:
|
||||
Frequent status polling can produce more detail than you need. You can drop those client and server spans with a sampler that checks the span name before delegating to `ParentBased`:
|
||||
|
||||
```python
|
||||
from opentelemetry import trace
|
||||
|
|
@ -124,7 +124,7 @@ class DropTaskPolls(Sampler):
|
|||
self._delegate = ParentBased(ALWAYS_ON)
|
||||
|
||||
def should_sample(self, parent_context, trace_id, name, *args, **kwargs):
|
||||
if name in {"tasks/get", "tasks/list"}:
|
||||
if name in {"tasks/get"}:
|
||||
return SamplingResult(Decision.DROP)
|
||||
return self._delegate.should_sample(
|
||||
parent_context,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
"""
|
||||
Background task elicitation demo.
|
||||
Background task input demo (SEP-2663 guard pattern).
|
||||
|
||||
A background task (Docket) that pauses mid-execution to ask the user a
|
||||
question, waits for the answer, then resumes and finishes.
|
||||
A background task that pauses to ask the user a question, waits for the answer,
|
||||
then resumes and finishes. Under SEP-2663 a task gathers input by the *guard
|
||||
pattern*: instead of awaiting `ctx.elicit()` (which would block a worker), the
|
||||
tool *returns* an `InputRequiredResult`. That ends the leg; the client answers
|
||||
via the tasks protocol; the framework re-runs the tool with the answer on
|
||||
`ctx.input_responses`. No worker is ever blocked.
|
||||
|
||||
The client side is transparent: `client.call_tool(...)` drives the whole
|
||||
round-trip — poll, answer via the `elicitation_handler`, poll again — and returns
|
||||
the finished result.
|
||||
|
||||
Works with both in-memory and Redis backends:
|
||||
|
||||
|
|
@ -22,13 +30,15 @@ Requires the `docket` extra (included in dev dependencies).
|
|||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
|
||||
import mcp_types
|
||||
from mcp_types import TextContent
|
||||
|
||||
from fastmcp import Context, FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.server.elicitation import AcceptedElicitation
|
||||
from fastmcp_tasks import TasksExtension
|
||||
|
||||
mcp = FastMCP("Task Elicitation Demo")
|
||||
mcp.add_extension(TasksExtension())
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -37,44 +47,60 @@ class DinnerPrefs:
|
|||
vegetarian: bool
|
||||
|
||||
|
||||
@mcp.tool(task=True)
|
||||
async def plan_dinner(ctx: Context) -> str:
|
||||
"""Plan a dinner menu, asking the user what they're in the mood for."""
|
||||
|
||||
await ctx.report_progress(0, 2, "Asking what you'd like...")
|
||||
|
||||
result = await ctx.elicit(
|
||||
"What kind of dinner are you in the mood for?",
|
||||
response_type=DinnerPrefs,
|
||||
def _ask_dinner_prefs() -> mcp_types.InputRequiredResult:
|
||||
"""Return the input request that pauses the task until the client answers."""
|
||||
request = mcp_types.ElicitRequest(
|
||||
params=mcp_types.ElicitRequestFormParams(
|
||||
message="What kind of dinner are you in the mood for?",
|
||||
requested_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cuisine": {"type": "string"},
|
||||
"vegetarian": {"type": "boolean"},
|
||||
},
|
||||
"required": ["cuisine", "vegetarian"],
|
||||
},
|
||||
)
|
||||
)
|
||||
return mcp_types.InputRequiredResult(
|
||||
result_type="input_required",
|
||||
input_requests={"prefs": request},
|
||||
)
|
||||
|
||||
if not isinstance(result, AcceptedElicitation):
|
||||
|
||||
@mcp.tool(task=True)
|
||||
async def plan_dinner(ctx: Context) -> str | mcp_types.InputRequiredResult:
|
||||
"""Plan a dinner menu, asking the user what they're in the mood for."""
|
||||
responses = ctx.input_responses
|
||||
if responses is None:
|
||||
# First leg: ask for preferences and end the leg.
|
||||
return _ask_dinner_prefs()
|
||||
|
||||
# Re-entered leg: the client's answer is on ctx.input_responses.
|
||||
answer = responses["prefs"]
|
||||
assert isinstance(answer, mcp_types.ElicitResult)
|
||||
if answer.action != "accept" or answer.content is None:
|
||||
return "Dinner cancelled!"
|
||||
|
||||
prefs = result.data
|
||||
assert isinstance(prefs, DinnerPrefs)
|
||||
await ctx.report_progress(1, 2, "Planning your menu...")
|
||||
await asyncio.sleep(1)
|
||||
await ctx.report_progress(2, 2, "Done!")
|
||||
|
||||
veg = "vegetarian " if prefs.vegetarian else ""
|
||||
return f"Tonight's menu: a lovely {veg}{prefs.cuisine} dinner!"
|
||||
await asyncio.sleep(1) # "planning the menu"
|
||||
veg = "vegetarian " if answer.content["vegetarian"] else ""
|
||||
return f"Tonight's menu: a lovely {veg}{answer.content['cuisine']} dinner!"
|
||||
|
||||
|
||||
async def handle_elicitation(message, response_type, params, context):
|
||||
"""Handle elicitation requests from background tasks."""
|
||||
"""Answer elicitation requests raised by the background task."""
|
||||
print(f" Server asks: {message}")
|
||||
print(" Responding with: cuisine=Thai, vegetarian=True")
|
||||
return DinnerPrefs(cuisine="Thai", vegetarian=True)
|
||||
|
||||
|
||||
async def main():
|
||||
async with Client(mcp, elicitation_handler=handle_elicitation) as client:
|
||||
print("Starting background task...")
|
||||
task = await client.call_tool("plan_dinner", {}, task=True)
|
||||
print(f" task_id = {task.task_id}\n")
|
||||
|
||||
result = await task.result()
|
||||
client = Client(mcp, mode="auto", elicitation_handler=handle_elicitation)
|
||||
async with client:
|
||||
print("Calling plan_dinner (runs as a background task)...")
|
||||
# call_tool drives the whole round-trip transparently: it polls, answers
|
||||
# the task's input request via handle_elicitation, and returns the result.
|
||||
result = await client.call_tool("plan_dinner", {})
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
print(f"\nResult: {result.content[0].text}")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
# FastMCP Tasks Example Environment Configuration
|
||||
# This file is loaded by direnv (https://direnv.net/) when you cd into this directory
|
||||
# Run `direnv allow` to enable automatic environment loading
|
||||
# Loaded by direnv (https://direnv.net/) when you cd into this directory.
|
||||
# Run `direnv allow` to enable automatic loading — or just `source .envrc`.
|
||||
|
||||
# Configure Docket backend URL
|
||||
# Use Redis backend (requires docker-compose up)
|
||||
export FASTMCP_DOCKET_URL=redis://localhost:24242/0
|
||||
# In-process worker on an in-memory backend: no Redis, nothing to start.
|
||||
# This is the default the example runs on.
|
||||
export FASTMCP_DOCKET_URL=memory://
|
||||
|
||||
# Or uncomment to use memory:// for single-process testing
|
||||
# export FASTMCP_DOCKET_URL=memory://
|
||||
# For distributed workers across separate processes (the `fastmcp tasks worker`
|
||||
# CLI), point at Redis instead and run `docker compose up -d` first:
|
||||
# export FASTMCP_DOCKET_URL=redis://localhost:24242/0
|
||||
|
|
|
|||
|
|
@ -1,60 +1,75 @@
|
|||
# FastMCP Tasks Example
|
||||
# FastMCP Background Tasks Example
|
||||
|
||||
Demonstrates background task execution with Docket, including progress tracking, distributed backends, and CLI worker management.
|
||||
A runnable client/server pair for SEP-2663 background tasks. The server exposes
|
||||
one `task=True` tool that reports progress as it works; the client drives it
|
||||
three ways — transparently, through an explicit handle, and several at once in
|
||||
parallel.
|
||||
|
||||
## Setup
|
||||
This runs on the in-memory backend by default, so there's nothing to install or
|
||||
start beyond the two processes.
|
||||
|
||||
## Run it
|
||||
|
||||
In one terminal, start the server:
|
||||
|
||||
```bash
|
||||
# From the fastmcp root directory
|
||||
uv sync
|
||||
uv sync # from the fastmcp root, once
|
||||
python examples/tasks/server.py # listens on http://127.0.0.1:8000/mcp
|
||||
```
|
||||
|
||||
# Start Redis
|
||||
In another terminal, drive it from the client:
|
||||
|
||||
```bash
|
||||
# Transparent — call_tool runs the background task and returns its result
|
||||
python examples/tasks/client.py --duration 8
|
||||
|
||||
# Explicit handle — returns immediately, poll it yourself, then collect
|
||||
python examples/tasks/client.py handle --duration 6
|
||||
|
||||
# Parallel — fire several tasks at once and watch them overlap
|
||||
python examples/tasks/client.py parallel
|
||||
python examples/tasks/client.py parallel 8 6 4 2
|
||||
```
|
||||
|
||||
The `parallel` run is the one to watch: four tasks of decreasing duration all
|
||||
start at once and total wall-clock tracks the *longest* task rather than the
|
||||
sum, because the worker runs them concurrently.
|
||||
|
||||
## How it works
|
||||
|
||||
The server enables tasks with one line:
|
||||
|
||||
```python
|
||||
mcp = FastMCP("Tasks Example")
|
||||
mcp.add_extension(TasksExtension())
|
||||
```
|
||||
|
||||
The client opts in by importing `fastmcp_tasks` (which it does to use
|
||||
`call_tool_task`). That single import enables task support for every `Client`
|
||||
in the process — without it, a `Client` never advertises the tasks capability,
|
||||
so the server would run the calls synchronously.
|
||||
|
||||
## Distributed workers (optional)
|
||||
|
||||
The default `memory://` backend runs the worker in the server process. To run
|
||||
workers as separate processes, point Docket at Redis and start it first:
|
||||
|
||||
```bash
|
||||
cd examples/tasks
|
||||
docker compose up -d
|
||||
export FASTMCP_DOCKET_URL=redis://localhost:24242/0 # or: direnv allow
|
||||
|
||||
# Load environment (or source .envrc manually)
|
||||
direnv allow
|
||||
|
||||
# Run the server
|
||||
fastmcp run server.py
|
||||
python server.py # in one terminal
|
||||
python -m fastmcp_tasks.worker_cli worker server.py # extra worker(s) in others
|
||||
```
|
||||
|
||||
For single-process mode without Redis, set `FASTMCP_DOCKET_URL=memory://` (note: CLI workers won't work).
|
||||
| Backend | Workers |
|
||||
| ------------ | ------------------------------- |
|
||||
| `memory://` | in-process only (default) |
|
||||
| `redis://…` | distributed across processes |
|
||||
|
||||
## Running the Client
|
||||
## Learn more
|
||||
|
||||
```bash
|
||||
# Background execution with progress callbacks
|
||||
python examples/tasks/client.py --duration 10
|
||||
|
||||
# Immediate execution (blocks)
|
||||
python examples/tasks/client.py immediate --duration 5
|
||||
```
|
||||
|
||||
## Starting Additional Workers
|
||||
|
||||
With Redis, you can run additional workers to process tasks in parallel:
|
||||
|
||||
```bash
|
||||
fastmcp tasks worker server.py
|
||||
|
||||
# Configure via environment:
|
||||
export FASTMCP_DOCKET_CONCURRENCY=20
|
||||
fastmcp tasks worker server.py
|
||||
```
|
||||
|
||||
**Backend options:**
|
||||
- `memory://` - Single-process only (default)
|
||||
- `redis://` - Distributed, multi-process (Redis or Valkey)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `FASTMCP_DOCKET_URL` | `memory://` | Docket backend URL |
|
||||
|
||||
## Learn More
|
||||
|
||||
- [FastMCP Tasks Documentation](https://gofastmcp.com/docs/tasks)
|
||||
- [Docket Documentation](https://github.com/PrefectHQ/docket)
|
||||
- [MCP Task Protocol (SEP-1686)](https://spec.modelcontextprotocol.io/specification/architecture/tasks/)
|
||||
- [Server background tasks](https://gofastmcp.com/servers/tasks)
|
||||
- [Client background tasks](https://gofastmcp.com/clients/tasks)
|
||||
- [Docket](https://github.com/chrisguidry/docket)
|
||||
|
|
|
|||
|
|
@ -1,159 +1,136 @@
|
|||
"""
|
||||
FastMCP Tasks Example Client
|
||||
"""FastMCP background-tasks example client (SEP-2663).
|
||||
|
||||
Demonstrates calling tools both immediately and as background tasks,
|
||||
with real-time progress updates via status callbacks.
|
||||
Start the server first (`python examples/tasks/server.py`), then run any of the
|
||||
commands below against it over HTTP.
|
||||
|
||||
Usage:
|
||||
# Make sure environment is configured (source .envrc or use direnv)
|
||||
source .envrc
|
||||
# Transparent: call_tool drives the background task and returns its result
|
||||
python examples/tasks/client.py --duration 8
|
||||
|
||||
# Background task execution with progress callbacks (default)
|
||||
python client.py --duration 10
|
||||
# Explicit handle: return immediately, poll it yourself, then collect
|
||||
python examples/tasks/client.py handle --duration 6
|
||||
|
||||
# Immediate execution (blocks until complete)
|
||||
python client.py immediate --duration 5
|
||||
# Parallel: fire several tasks at once and watch them overlap
|
||||
python examples/tasks/client.py parallel
|
||||
|
||||
Importing `fastmcp_tasks` (below) enables client task support for every Client
|
||||
in the process — without it, a Client never advertises the tasks capability and
|
||||
the server runs its calls synchronously.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import time
|
||||
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 # importing enables client task support
|
||||
|
||||
SERVER_URL = "http://127.0.0.1:8000/mcp"
|
||||
|
||||
console = Console()
|
||||
app = cyclopts.App(name="tasks-client", help="FastMCP Tasks Example Client")
|
||||
app = cyclopts.App(name="tasks-client", help="FastMCP background-tasks example client")
|
||||
|
||||
|
||||
def load_server():
|
||||
"""Load the example server."""
|
||||
examples_dir = Path(__file__).parent.parent.parent
|
||||
if str(examples_dir) not in sys.path:
|
||||
sys.path.insert(0, str(examples_dir))
|
||||
|
||||
import examples.tasks.server as server_module
|
||||
|
||||
return server_module.mcp
|
||||
|
||||
|
||||
# Track last message to deduplicate consecutive identical notifications
|
||||
# Note: Docket fires separate events for progress.increment() and progress.set_message(),
|
||||
# but MCP's 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}]"
|
||||
)
|
||||
def _text(result) -> str:
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
return result.content[0].text
|
||||
|
||||
|
||||
@app.default
|
||||
async def task(
|
||||
duration: Annotated[
|
||||
int,
|
||||
cyclopts.Parameter(help="Duration of computation in seconds (1-60)"),
|
||||
] = 10,
|
||||
async def transparent(
|
||||
duration: Annotated[int, cyclopts.Parameter(help="Seconds (1-60)")] = 8,
|
||||
):
|
||||
"""Execute as background task with real-time progress callbacks."""
|
||||
if duration < 1 or duration > 60:
|
||||
console.print("[red]Error: Duration must be between 1 and 60 seconds[/red]")
|
||||
sys.exit(1)
|
||||
"""Call the tool transparently: the client drives the task to completion.
|
||||
|
||||
server = load_server()
|
||||
|
||||
console.print(f"\n[bold]Calling slow_computation(duration={duration})[/bold]")
|
||||
console.print("Mode: [cyan]Background task[/cyan]\n")
|
||||
|
||||
async with Client(server) as client:
|
||||
task_obj = await client.call_tool(
|
||||
The server runs `slow_computation` as a background task, but `call_tool`
|
||||
polls it under the hood and returns the tool's real result — the calling
|
||||
code looks exactly like an ordinary synchronous tool call.
|
||||
"""
|
||||
async with Client(SERVER_URL, mode="auto") as client:
|
||||
console.print(f"\n[bold]Transparent call[/bold] (duration={duration})\n")
|
||||
started = time.perf_counter()
|
||||
result = await client.call_tool(
|
||||
"slow_computation",
|
||||
arguments={"duration": duration},
|
||||
task=True,
|
||||
{"label": "transparent", "duration": duration},
|
||||
)
|
||||
|
||||
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}")
|
||||
console.print(f"[green]{_text(result)}[/green]")
|
||||
console.print(f"[dim]elapsed {time.perf_counter() - started:.1f}s[/dim]")
|
||||
|
||||
|
||||
@app.command
|
||||
async def immediate(
|
||||
duration: Annotated[
|
||||
int,
|
||||
cyclopts.Parameter(help="Duration of computation in seconds (1-60)"),
|
||||
] = 5,
|
||||
async def handle(
|
||||
duration: Annotated[int, cyclopts.Parameter(help="Seconds (1-60)")] = 6,
|
||||
):
|
||||
"""Execute the tool immediately (blocks until complete)."""
|
||||
if duration < 1 or duration > 60:
|
||||
console.print("[red]Error: Duration must be between 1 and 60 seconds[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
server = load_server()
|
||||
|
||||
console.print(f"\n[bold]Calling slow_computation(duration={duration})[/bold]")
|
||||
console.print("Mode: [cyan]Immediate execution[/cyan]\n")
|
||||
|
||||
async with Client(server) as client:
|
||||
result = await client.call_tool(
|
||||
"slow_computation",
|
||||
arguments={"duration": duration},
|
||||
"""Use the explicit handle: return immediately, then drive the task yourself."""
|
||||
async with Client(SERVER_URL, mode="auto") as client:
|
||||
console.print(f"\n[bold]Explicit handle[/bold] (duration={duration})\n")
|
||||
task = await call_tool_task(
|
||||
client, "slow_computation", {"label": "handle", "duration": duration}
|
||||
)
|
||||
console.print(f"Task started: [cyan]{task.task_id}[/cyan]\n")
|
||||
|
||||
console.print("\n[bold]Result:[/bold]")
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
console.print(f" {result.content[0].text}")
|
||||
# Do other work while the task runs, checking its status as you go.
|
||||
while True:
|
||||
status = await task.status()
|
||||
if status.status in ("completed", "failed", "cancelled"):
|
||||
break
|
||||
console.print(f"[dim]still {status.status}: {status.status_message}[/dim]")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
result = await task.result()
|
||||
console.print(f"\n[green]{_text(result)}[/green]")
|
||||
|
||||
|
||||
@app.command
|
||||
async def parallel(
|
||||
durations: Annotated[
|
||||
list[int] | None,
|
||||
cyclopts.Parameter(help="One task per duration (default: 5 4 3 2)"),
|
||||
] = None,
|
||||
):
|
||||
"""Fire several background tasks at once and drive them concurrently.
|
||||
|
||||
Each `call_tool_task` returns immediately, so we start every task before
|
||||
awaiting any of them. The worker runs them in parallel, so total wall-clock
|
||||
tracks the *longest* task, not the sum — proof the work actually overlaps.
|
||||
"""
|
||||
durations = durations or [5, 4, 3, 2]
|
||||
|
||||
async with Client(SERVER_URL, mode="auto") as client:
|
||||
console.print(f"\n[bold]Parallel tasks[/bold]: durations={durations}\n")
|
||||
started = time.perf_counter()
|
||||
|
||||
# Start every task up front — none of these await completion.
|
||||
tasks = [
|
||||
await call_tool_task(
|
||||
client,
|
||||
"slow_computation",
|
||||
{"label": f"task-{i}({d}s)", "duration": d},
|
||||
)
|
||||
for i, d in enumerate(durations)
|
||||
]
|
||||
for task in tasks:
|
||||
console.print(f" started [cyan]{task.task_id}[/cyan]")
|
||||
|
||||
# Await them together; results print as each task finishes.
|
||||
async def collect(task):
|
||||
result = await task.result()
|
||||
console.print(
|
||||
f"[green]✓[/green] {_text(result)} "
|
||||
f"[dim](+{time.perf_counter() - started:.1f}s)[/dim]"
|
||||
)
|
||||
|
||||
console.print()
|
||||
await asyncio.gather(*(collect(task) for task in tasks))
|
||||
|
||||
total = time.perf_counter() - started
|
||||
console.print(
|
||||
f"\n[bold]All {len(tasks)} tasks done in {total:.1f}s[/bold] "
|
||||
f"[dim](longest single task: {max(durations)}s)[/dim]"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -1,75 +1,65 @@
|
|||
"""
|
||||
FastMCP Tasks Example Server
|
||||
"""FastMCP background-tasks example server (SEP-2663).
|
||||
|
||||
Demonstrates background task execution with progress tracking using Docket.
|
||||
Run this in one terminal, then drive it from `client.py` in another. It exposes
|
||||
one `task=True` tool that reports progress as it works, so you can watch the
|
||||
client poll a real background task over HTTP.
|
||||
|
||||
Setup:
|
||||
1. Start Redis: docker compose up -d
|
||||
2. Load environment: source .envrc
|
||||
3. Run server: fastmcp run server.py
|
||||
# From the fastmcp root (memory:// backend, no Redis needed):
|
||||
python examples/tasks/server.py
|
||||
|
||||
The example uses Redis by default to demonstrate distributed task execution
|
||||
and the fastmcp tasks CLI commands.
|
||||
The server listens on http://localhost:8000/mcp. The tasks extension runs its
|
||||
Docket worker in-process on the default `memory://` backend, so several tasks
|
||||
submitted at once execute concurrently (worker concurrency defaults to 10).
|
||||
Point `FASTMCP_DOCKET_URL` at Redis to distribute work across separate worker
|
||||
processes instead — see README.md.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from docket import Logged
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.dependencies import Progress
|
||||
from fastmcp.utilities.tasks import TaskConfig
|
||||
from fastmcp_tasks import TasksExtension
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
|
||||
logger = logging.getLogger("tasks-example")
|
||||
|
||||
# Create server
|
||||
# Enable SEP-2663 background tasks. With no arguments the extension reads the
|
||||
# FASTMCP_DOCKET_* environment and falls back to an in-process memory:// worker.
|
||||
mcp = FastMCP("Tasks Example")
|
||||
mcp.add_extension(TasksExtension())
|
||||
|
||||
|
||||
@mcp.tool(task=True)
|
||||
# A short poll interval keeps the example snappy: the client observes each
|
||||
# task finishing within ~1s. The default is 5s, tuned for real workloads.
|
||||
@mcp.tool(task=TaskConfig(poll_interval=timedelta(seconds=1)))
|
||||
async def slow_computation(
|
||||
duration: Annotated[int, Logged],
|
||||
label: Annotated[str, "A name for this run, echoed back in progress logs"],
|
||||
duration: Annotated[int, "How many seconds the computation should take (1-60)"],
|
||||
progress: Progress = Progress(),
|
||||
) -> str:
|
||||
"""Spend `duration` seconds working, reporting progress once per second.
|
||||
|
||||
Marked `task=True`, so a task-aware client runs it in the background and
|
||||
polls for progress and the final result instead of blocking on the call.
|
||||
"""
|
||||
Perform a slow computation that takes `duration` seconds.
|
||||
if not 1 <= duration <= 60:
|
||||
raise ValueError("duration must be between 1 and 60 seconds")
|
||||
|
||||
This tool demonstrates progress tracking with background tasks.
|
||||
It logs progress every 1-2 seconds and reports progress via Docket.
|
||||
|
||||
Args:
|
||||
duration: Number of seconds the computation should take (1-60)
|
||||
|
||||
Returns:
|
||||
A completion message with the total duration
|
||||
"""
|
||||
if duration < 1 or duration > 60:
|
||||
raise ValueError("Duration must be between 1 and 60 seconds")
|
||||
|
||||
logger.info(f"Starting slow computation for {duration} seconds")
|
||||
|
||||
# Set total progress units
|
||||
logger.info("[%s] starting — %ds", label, duration)
|
||||
await progress.set_total(duration)
|
||||
|
||||
# Process each second
|
||||
for i in range(duration):
|
||||
# Sleep for 1 second
|
||||
for elapsed in range(1, duration + 1):
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Update progress
|
||||
elapsed = i + 1
|
||||
remaining = duration - elapsed
|
||||
await progress.increment()
|
||||
await progress.set_message(
|
||||
f"Working... {elapsed}/{duration}s ({remaining}s remaining)"
|
||||
)
|
||||
await progress.set_message(f"{label}: {elapsed}/{duration}s")
|
||||
|
||||
# Log every 1-2 seconds
|
||||
if elapsed % 2 == 0 or elapsed == duration:
|
||||
logger.info(f"Progress: {elapsed}/{duration}s")
|
||||
logger.info("[%s] done", label)
|
||||
return f"{label} finished in {duration}s"
|
||||
|
||||
logger.info(f"Completed computation in {duration} seconds")
|
||||
return f"Computation completed successfully in {duration} seconds!"
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="http", host="127.0.0.1", port=8000)
|
||||
|
|
|
|||
|
|
@ -5,14 +5,10 @@ import warnings
|
|||
from importlib.metadata import PackageNotFoundError, version as _version
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastmcp import _install_hints, _sdk_patches
|
||||
from fastmcp import _install_hints
|
||||
from fastmcp.settings import Settings
|
||||
from fastmcp.utilities.logging import configure_logging as _configure_logging
|
||||
|
||||
# Apply temporary SDK registry patches (SEP-1686 task methods) before any
|
||||
# client/server use. See fastmcp._sdk_patches for the upstream-gap rationale.
|
||||
_sdk_patches.install()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.client import Client as Client
|
||||
from fastmcp.apps.app import FastMCPApp as FastMCPApp
|
||||
|
|
|
|||
|
|
@ -1,131 +0,0 @@
|
|||
"""Temporary in-place patches for gaps in the pinned MCP SDK.
|
||||
|
||||
## SEP-1686 task methods missing from the handshake-era method registries
|
||||
|
||||
This shim compensates for a genuine gap in the SDK's *handshake-era*
|
||||
(2025-11-25 and earlier) task registry. In the 2025-11-25 SEP-1686 model, tasks
|
||||
are a first-class part of the core protocol: `CallToolRequestParams` carries a
|
||||
`task: TaskMetadata` field and a task-augmented `tools/call` returns a
|
||||
`CreateTaskResult`. `mcp==2.0.0b1` ships those task types (`CreateTaskResult`,
|
||||
`GetTaskResult`, `GetTaskPayloadResult`, `ListTasksResult`, `CancelTaskResult`)
|
||||
and the `task` request field, but its `mcp_types.methods` registries were never
|
||||
wired for them: there are no `tasks/*` rows, and the handshake-era `tools/call`
|
||||
result rows are a plain `CallToolResult` with no `CreateTaskResult` arm.
|
||||
|
||||
The lowlevel server runner (`mcp.server.runner`) serializes a handler's result
|
||||
through `serialize_server_result(method, version, ...)` for any method in
|
||||
`SPEC_CLIENT_METHODS`. `tools/call` is such a method, so when a FastMCP tool is
|
||||
submitted as a background task (`client.call_tool(..., task=True)`) the handler
|
||||
returns a `CreateTaskResult`, which fails validation against the un-widened
|
||||
`tools/call` surface row -> the client sees "Handler returned an invalid
|
||||
result". The `tasks/*` methods themselves are NOT in `SPEC_CLIENT_METHODS`, so
|
||||
their handler results already bypass serialization and reach the wire
|
||||
unvalidated; we still register their result rows here for symmetry and so the
|
||||
maps are consistent if a future SDK adds them to the spec method set.
|
||||
|
||||
## Scope: handshake-era versions only
|
||||
|
||||
The widening + `tasks/*` registration is gated to
|
||||
`HANDSHAKE_PROTOCOL_VERSIONS` (2025-11-25 and earlier) because those are the
|
||||
versions where the 2025 SEP-1686 task model actually applies and where the
|
||||
SDK's registry has the genuine gap we compensate for.
|
||||
|
||||
The 2026-07-28 protocol is intentionally NOT patched here. Tasks left the core
|
||||
protocol in 2026-07-28 and became the separate `io.modelcontextprotocol/tasks`
|
||||
extension; `CreateTaskResult` and the `task` field on `CallToolRequestParams`
|
||||
do not exist in that schema (a task-augmented `tools/call` was replaced by the
|
||||
mutually-recursive `CallToolResult | InputRequiredResult` result). Injecting the
|
||||
2025-era `CreateTaskResult` into the 2026 `tools/call` union would assert the
|
||||
wrong task model onto that protocol, so we leave its rows untouched.
|
||||
|
||||
This module widens the registries IN PLACE (the maps are `MappingProxyType`
|
||||
views over private dicts, so we reach the backing dict via `gc.get_referents`
|
||||
and mutate it, which the already-bound default-argument references in
|
||||
`mcp_types.methods` observe). `install()` is idempotent.
|
||||
|
||||
# TODO(sdk-upstream): remove when mcp>=2.0.0bX wires SEP-1686 into the
|
||||
# handshake-era method registries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
from types import MappingProxyType, UnionType
|
||||
|
||||
import mcp_types
|
||||
from mcp_types import methods as _methods
|
||||
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS
|
||||
|
||||
# Result type for each task method, keyed by the client request method name.
|
||||
_TASK_RESULT_TYPES: dict[str, type] = {
|
||||
"tasks/get": mcp_types.GetTaskResult,
|
||||
"tasks/result": mcp_types.GetTaskPayloadResult,
|
||||
"tasks/list": mcp_types.ListTasksResult,
|
||||
"tasks/cancel": mcp_types.CancelTaskResult,
|
||||
}
|
||||
|
||||
_installed = False
|
||||
|
||||
|
||||
def _backing_dict(proxy: object) -> dict:
|
||||
"""Return the mutable dict a MappingProxyType wraps.
|
||||
|
||||
The `mcp_types.methods` surface maps are `MappingProxyType` views; their
|
||||
sole dict referent is the backing store the module's functions read through
|
||||
their default `surface=` arguments.
|
||||
"""
|
||||
referents = [r for r in gc.get_referents(proxy) if isinstance(r, dict)]
|
||||
if len(referents) != 1:
|
||||
raise RuntimeError(
|
||||
"expected exactly one backing dict for the method registry proxy, "
|
||||
f"found {len(referents)}"
|
||||
)
|
||||
return referents[0]
|
||||
|
||||
|
||||
def install() -> None:
|
||||
"""Widen the SDK's server-result registry for SEP-1686 task methods.
|
||||
|
||||
Idempotent. Safe to call at import time before any client/server use.
|
||||
"""
|
||||
global _installed
|
||||
if _installed:
|
||||
return
|
||||
|
||||
if not isinstance(_methods.SERVER_RESULTS, MappingProxyType):
|
||||
# Registry shape changed upstream; the shim no longer applies.
|
||||
_installed = True
|
||||
return
|
||||
|
||||
server_results = _backing_dict(_methods.SERVER_RESULTS)
|
||||
|
||||
# Gate to handshake-era versions only: the 2025 SEP-1686 task model applies
|
||||
# there, and 2026-07-28 tasks are the separate io.modelcontextprotocol/tasks
|
||||
# extension (see module docstring) — its rows must stay untouched.
|
||||
versions_with_tools_call = {
|
||||
version
|
||||
for (method, version) in server_results
|
||||
if method == "tools/call" and version in HANDSHAKE_PROTOCOL_VERSIONS
|
||||
}
|
||||
|
||||
for version in versions_with_tools_call:
|
||||
# (a) widen tools/call so a CreateTaskResult validates (task submission).
|
||||
existing = server_results[("tools/call", version)]
|
||||
arms = get_union_arms(existing)
|
||||
if mcp_types.CreateTaskResult not in arms:
|
||||
server_results[("tools/call", version)] = (
|
||||
existing | mcp_types.CreateTaskResult
|
||||
)
|
||||
|
||||
# (b) register the tasks/* result rows for the same versions.
|
||||
for method, result_type in _TASK_RESULT_TYPES.items():
|
||||
server_results.setdefault((method, version), result_type)
|
||||
|
||||
_installed = True
|
||||
|
||||
|
||||
def get_union_arms(row: type | UnionType) -> tuple[type, ...]:
|
||||
"""Return the member types of a result row, whether a single type or union."""
|
||||
if isinstance(row, UnionType):
|
||||
return tuple(row.__args__)
|
||||
return (row,)
|
||||
|
|
@ -23,7 +23,6 @@ from fastmcp.cli.auth import auth_app
|
|||
from fastmcp.cli.client import call_command, discover_command, list_command
|
||||
from fastmcp.cli.generate import generate_cli_command
|
||||
from fastmcp.cli.install import install_app
|
||||
from fastmcp.cli.tasks import tasks_app
|
||||
from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config
|
||||
from fastmcp.utilities.inspect import (
|
||||
InspectFormat,
|
||||
|
|
@ -1126,9 +1125,6 @@ app.command(project_app)
|
|||
# Add install subcommands using proper Cyclopts pattern
|
||||
app.command(install_app)
|
||||
|
||||
# Add tasks subcommand group
|
||||
app.command(tasks_app)
|
||||
|
||||
# Add client query commands
|
||||
app.command(list_command, name="list")
|
||||
app.command(call_command, name="call")
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import hashlib
|
|||
import secrets
|
||||
import ssl
|
||||
import uuid
|
||||
import weakref
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping, Sequence
|
||||
from contextlib import AsyncExitStack, asynccontextmanager, suppress
|
||||
from dataclasses import dataclass, field
|
||||
|
|
@ -42,11 +41,10 @@ from mcp.client.extension import (
|
|||
NotificationBinding,
|
||||
ResultClaim,
|
||||
)
|
||||
from mcp.client.session import ClientRequestContext, MessageHandlerFnT
|
||||
from mcp_types import (
|
||||
GetTaskResult,
|
||||
TaskStatusNotification,
|
||||
TaskStatusNotificationParams,
|
||||
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
|
||||
|
|
@ -58,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,
|
||||
|
|
@ -67,7 +66,6 @@ from fastmcp.client.messages import MessageHandler, MessageHandlerT
|
|||
from fastmcp.client.mixins import (
|
||||
ClientPromptsMixin,
|
||||
ClientResourcesMixin,
|
||||
ClientTaskManagementMixin,
|
||||
ClientToolsMixin,
|
||||
)
|
||||
from fastmcp.client.progress import ProgressHandler, default_progress_handler
|
||||
|
|
@ -80,12 +78,6 @@ from fastmcp.client.sampling import (
|
|||
SamplingHandler,
|
||||
create_sampling_callback,
|
||||
)
|
||||
from fastmcp.client.tasks import (
|
||||
PromptTask,
|
||||
ResourceTask,
|
||||
TaskNotificationHandler,
|
||||
ToolTask,
|
||||
)
|
||||
from fastmcp.mcp_config import MCPConfig
|
||||
from fastmcp.utilities.exceptions import get_catch_handlers
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
|
@ -256,7 +248,6 @@ class Client(
|
|||
ClientResourcesMixin,
|
||||
ClientPromptsMixin,
|
||||
ClientToolsMixin,
|
||||
ClientTaskManagementMixin,
|
||||
):
|
||||
"""
|
||||
MCP client that delegates connection management to a Transport instance.
|
||||
|
|
@ -348,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: ...
|
||||
|
||||
|
|
@ -500,12 +498,10 @@ class Client(
|
|||
cache
|
||||
)
|
||||
|
||||
# The unwrapped base handler (default routes task notifications; a user
|
||||
# handler is preserved as-is). Retained so `new()` can rebuild the clone's
|
||||
# handler without unwrapping the cache-eviction wrapper below.
|
||||
self._base_message_handler: MessageHandlerFnT | None = (
|
||||
message_handler or TaskNotificationHandler(self)
|
||||
)
|
||||
# The unwrapped base handler (a user handler is preserved as-is).
|
||||
# Retained so `new()` can rebuild the clone's handler without unwrapping
|
||||
# the cache-eviction wrapper below.
|
||||
self._base_message_handler: MessageHandlerFnT | None = message_handler
|
||||
effective_message_handler = self._base_message_handler
|
||||
if self._response_cache is not None:
|
||||
effective_message_handler = _evicting_message_handler(
|
||||
|
|
@ -520,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,
|
||||
|
|
@ -543,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
|
||||
|
|
@ -557,15 +561,6 @@ class Client(
|
|||
self._session_state = ClientSessionState()
|
||||
self._transport_options: TransportOptions | None = None
|
||||
|
||||
# Track task IDs submitted by this client (for list_tasks support)
|
||||
self._submitted_task_ids: set[str] = set()
|
||||
|
||||
# Registry for routing notifications/tasks/status to Task objects
|
||||
|
||||
self._task_registry: dict[
|
||||
str, weakref.ref[ToolTask | PromptTask | ResourceTask]
|
||||
] = {}
|
||||
|
||||
def _build_response_cache(
|
||||
self, cache: CacheConfig | bool | None
|
||||
) -> ClientResponseCache | None:
|
||||
|
|
@ -713,9 +708,12 @@ class Client(
|
|||
self, elicitation_callback: ElicitationHandler
|
||||
) -> None:
|
||||
"""Set the elicitation callback for the client."""
|
||||
self._session_kwargs["elicitation_callback"] = create_elicitation_callback(
|
||||
elicitation_callback
|
||||
)
|
||||
self._elicitation_callback = create_elicitation_callback(elicitation_callback)
|
||||
self._session_kwargs["elicitation_callback"] = self._elicitation_callback
|
||||
# Rebuild internal extensions (e.g. the tasks extension) so a background
|
||||
# task's in-task input is answered through the newly-set handler, not the
|
||||
# one captured when the client was constructed.
|
||||
self._session_kwargs.update(self._build_extension_kwargs())
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if the client is currently connected."""
|
||||
|
|
@ -746,26 +744,16 @@ class Client(
|
|||
new_client._session_state = ClientSessionState()
|
||||
new_client._transport_options = self._transport_options
|
||||
|
||||
# Reset mutable task tracking state so new client is independent
|
||||
new_client._task_registry = {}
|
||||
new_client._submitted_task_ids = set()
|
||||
|
||||
# Give the clone its own response cache so cached entries are not shared
|
||||
# across independent sessions, and rebuild the negotiated_version closure
|
||||
# to point at the clone's session state.
|
||||
new_client._response_cache = new_client._build_response_cache(self._cache_arg)
|
||||
|
||||
# Create a fresh session kwargs dict so the clone doesn't share
|
||||
# the original's mutable dict. Rebind the task notification handler
|
||||
# to the new client if the default handler is in use; preserve any
|
||||
# custom message handler the user may have set.
|
||||
# the original's mutable dict; preserve any custom message handler the
|
||||
# user may have set, re-wrapping with the clone's own cache if one exists.
|
||||
new_client._session_kwargs = {**self._session_kwargs} # type: ignore[typeddict-item]
|
||||
# Recover the unwrapped base handler (never the cache-evicting wrapper): a
|
||||
# default (TaskNotificationHandler) rebinds to the clone; a user handler is
|
||||
# preserved. Then re-wrap with the clone's own cache if one exists.
|
||||
base_handler: MessageHandlerFnT | None = self._base_message_handler
|
||||
if isinstance(base_handler, TaskNotificationHandler) or base_handler is None:
|
||||
base_handler = TaskNotificationHandler(new_client)
|
||||
new_client._base_message_handler = base_handler
|
||||
if new_client._response_cache is not None:
|
||||
new_client._session_kwargs["message_handler"] = _evicting_message_handler(
|
||||
|
|
@ -774,8 +762,7 @@ class Client(
|
|||
else:
|
||||
new_client._session_kwargs["message_handler"] = base_handler
|
||||
# Rebuild the extension-contributed kwargs (capability ad, result claims,
|
||||
# notification bindings) so the clone's task-status binding routes to the
|
||||
# clone while user extensions still compose with it.
|
||||
# notification bindings) so user extensions compose on the clone.
|
||||
new_client._session_kwargs.update(new_client._build_extension_kwargs())
|
||||
|
||||
new_client.name += f":{secrets.token_hex(2)}"
|
||||
|
|
@ -1241,47 +1228,41 @@ class Client(
|
|||
max_rounds=self.input_required_max_rounds,
|
||||
)
|
||||
|
||||
def _handle_task_status_notification(
|
||||
self, notification: TaskStatusNotification
|
||||
) -> None:
|
||||
"""Route task status notification to appropriate Task object.
|
||||
|
||||
Called when notifications/tasks/status is received from server.
|
||||
Updates Task object's cache and triggers events/callbacks.
|
||||
"""
|
||||
self._handle_task_status_params(notification.params)
|
||||
|
||||
def _handle_task_status_params(self, params: TaskStatusNotificationParams) -> None:
|
||||
"""Route task status notification params to the matching Task object."""
|
||||
task_id = params.task_id
|
||||
if not task_id:
|
||||
return
|
||||
|
||||
# Look up task in registry (weakref)
|
||||
task_ref = self._task_registry.get(task_id)
|
||||
if task_ref:
|
||||
task = task_ref() # Dereference weakref
|
||||
if task:
|
||||
# Convert notification params to GetTaskResult (they share the same fields via Task)
|
||||
status = GetTaskResult.model_validate(params.model_dump())
|
||||
task._handle_status_notification(status)
|
||||
|
||||
def _build_extension_kwargs(self) -> SessionKwargs:
|
||||
"""Session kwargs contributed by `extensions=` / `result_claims=`.
|
||||
|
||||
Folds the user's `ClientExtension` instances into the capability ad, result
|
||||
claims, and notification bindings the SDK `ClientSession` consumes, then
|
||||
merges in any explicitly-passed `result_claims`. The internal task-status
|
||||
binding is always prepended to the folded bindings so user extensions
|
||||
*compose* with it rather than clobbering it; a user extension that binds the
|
||||
same `notifications/tasks/status` method surfaces a duplicate-method error
|
||||
from the SDK rather than silently replacing FastMCP's routing.
|
||||
merges in any explicitly-passed `result_claims`.
|
||||
|
||||
Also rebuilds `self._claim_by_model`, the model→claim 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)
|
||||
|
|
@ -1293,11 +1274,7 @@ class Client(
|
|||
self._claim_by_model = by_model
|
||||
|
||||
kwargs: SessionKwargs = {
|
||||
# The internal task binding must lead so user bindings extend it.
|
||||
"notification_bindings": [
|
||||
self._task_status_binding(),
|
||||
*(folded.bindings or ()),
|
||||
],
|
||||
"notification_bindings": [*(folded.bindings or ())],
|
||||
}
|
||||
if folded.ad:
|
||||
kwargs["extensions"] = folded.ad
|
||||
|
|
@ -1333,26 +1310,6 @@ class Client(
|
|||
await self.session.validate_tool_result(name, final)
|
||||
return final
|
||||
|
||||
def _task_status_binding(self) -> NotificationBinding[TaskStatusNotificationParams]:
|
||||
"""Build a binding routing `notifications/tasks/status` to Task objects.
|
||||
|
||||
SDK v2 drops notifications whose method is absent from the negotiated
|
||||
version's core tables before they reach the message_handler; a binding is
|
||||
the supported channel for observing such vendor notifications.
|
||||
"""
|
||||
client_ref = weakref.ref(self)
|
||||
|
||||
async def _handler(params: TaskStatusNotificationParams) -> None:
|
||||
client = client_ref()
|
||||
if client is not None:
|
||||
client._handle_task_status_params(params)
|
||||
|
||||
return NotificationBinding(
|
||||
method="notifications/tasks/status",
|
||||
params_type=TaskStatusNotificationParams,
|
||||
handler=_handler,
|
||||
)
|
||||
|
||||
async def close(self):
|
||||
await self._disconnect(force=True)
|
||||
await self.transport.close()
|
||||
|
|
|
|||
68
fastmcp_slim/fastmcp/client/extension_hooks.py
Normal file
68
fastmcp_slim/fastmcp/client/extension_hooks.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""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``
|
||||
folds in automatically once the package is imported — so a caller that uses
|
||||
tasks (importing ``fastmcp_tasks`` for ``call_tool_task``, or to register the
|
||||
server extension) gets transparent client task support without passing anything
|
||||
per ``Client``. 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. Task support is
|
||||
opt-in — with ``fastmcp_tasks`` unimported the registry is empty and ``Client``
|
||||
behaves exactly as core alone, so a plain ``from fastmcp import Client`` never
|
||||
advertises the tasks capability and the server never runs its calls as tasks.
|
||||
|
||||
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 companion
|
||||
package has registered a factory (plain core, or ``fastmcp_tasks`` unimported).
|
||||
"""
|
||||
extensions: list[ClientExtension] = []
|
||||
for factory in _internal_client_extension_factories:
|
||||
extension = factory(elicitation_callback)
|
||||
if extension is not None:
|
||||
extensions.append(extension)
|
||||
return extensions
|
||||
|
|
@ -2,12 +2,10 @@
|
|||
|
||||
from fastmcp.client.mixins.prompts import ClientPromptsMixin
|
||||
from fastmcp.client.mixins.resources import ClientResourcesMixin
|
||||
from fastmcp.client.mixins.task_management import ClientTaskManagementMixin
|
||||
from fastmcp.client.mixins.tools import ClientToolsMixin
|
||||
|
||||
__all__ = [
|
||||
"ClientPromptsMixin",
|
||||
"ClientResourcesMixin",
|
||||
"ClientTaskManagementMixin",
|
||||
"ClientToolsMixin",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,19 +2,15 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import weakref
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast, overload
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import mcp_types
|
||||
import pydantic_core
|
||||
from mcp.client.caching import CacheMode
|
||||
from pydantic import RootModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.client.client import Client
|
||||
|
||||
from fastmcp.client.tasks import PromptTask
|
||||
from fastmcp.client.telemetry import client_span
|
||||
from fastmcp.telemetry import inject_trace_context
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
|
@ -23,11 +19,6 @@ logger = get_logger(__name__)
|
|||
|
||||
AUTO_PAGINATION_MAX_PAGES = 250
|
||||
|
||||
# Type alias for task response union (SEP-1686 graceful degradation)
|
||||
PromptTaskResponseUnion = RootModel[
|
||||
mcp_types.CreateTaskResult | mcp_types.GetPromptResult
|
||||
]
|
||||
|
||||
|
||||
class ClientPromptsMixin:
|
||||
"""Mixin providing prompt-related methods for Client."""
|
||||
|
|
@ -192,7 +183,6 @@ class ClientPromptsMixin:
|
|||
)
|
||||
return result
|
||||
|
||||
@overload
|
||||
async def get_prompt(
|
||||
self: Client,
|
||||
name: str,
|
||||
|
|
@ -200,33 +190,7 @@ class ClientPromptsMixin:
|
|||
*,
|
||||
version: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: Literal[False] = False,
|
||||
) -> mcp_types.GetPromptResult: ...
|
||||
|
||||
@overload
|
||||
async def get_prompt(
|
||||
self: Client,
|
||||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
version: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: Literal[True],
|
||||
task_id: str | None = None,
|
||||
ttl: int = 60000,
|
||||
) -> PromptTask: ...
|
||||
|
||||
async def get_prompt(
|
||||
self: Client,
|
||||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
version: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool = False,
|
||||
task_id: str | None = None,
|
||||
ttl: int = 60000,
|
||||
) -> mcp_types.GetPromptResult | PromptTask:
|
||||
) -> mcp_types.GetPromptResult:
|
||||
"""Retrieve a rendered prompt message list from the server.
|
||||
|
||||
Args:
|
||||
|
|
@ -234,13 +198,9 @@ class ClientPromptsMixin:
|
|||
arguments (dict[str, Any] | None, optional): Arguments to pass to the prompt. Defaults to None.
|
||||
version (str | None, optional): Specific prompt version to get. If None, gets highest version.
|
||||
meta (dict[str, Any] | None): Optional request-level metadata.
|
||||
task (bool): If True, execute as background task (SEP-1686). Defaults to False.
|
||||
task_id (str | None): Optional client-provided task ID (auto-generated if not provided).
|
||||
ttl (int): Time to keep results available in milliseconds (default 60s).
|
||||
|
||||
Returns:
|
||||
mcp_types.GetPromptResult | PromptTask: The complete response object if task=False,
|
||||
or a PromptTask object if task=True.
|
||||
mcp_types.GetPromptResult: The complete response object.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If called while the client is not connected.
|
||||
|
|
@ -254,94 +214,7 @@ class ClientPromptsMixin:
|
|||
"version": version,
|
||||
}
|
||||
|
||||
if task:
|
||||
return await self._get_prompt_as_task(
|
||||
name, arguments, task_id, ttl, meta=request_meta or None
|
||||
)
|
||||
|
||||
result = await self.get_prompt_mcp(
|
||||
name=name, arguments=arguments, meta=request_meta or None
|
||||
)
|
||||
return result
|
||||
|
||||
async def _get_prompt_as_task(
|
||||
self: Client,
|
||||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
task_id: str | None = None,
|
||||
ttl: int = 60000,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> PromptTask:
|
||||
"""Get a prompt for background execution (SEP-1686).
|
||||
|
||||
Returns a PromptTask object that handles both background and immediate execution.
|
||||
|
||||
Args:
|
||||
name: Prompt name to get
|
||||
arguments: Prompt arguments
|
||||
task_id: Optional client-provided task ID (ignored, for backward compatibility)
|
||||
ttl: Time to keep results available in milliseconds (default 60s)
|
||||
meta: Optional request metadata (e.g., version info)
|
||||
|
||||
Returns:
|
||||
PromptTask: Future-like object for accessing task status and results
|
||||
"""
|
||||
# Per SEP-1686 final spec: client sends only ttl, server generates taskId
|
||||
# Inject trace context into meta for propagation to server.
|
||||
# SDK v2: request `_meta` is `RequestParamsMeta` (a TypedDict), not
|
||||
# the old `RequestParams.Meta` nested model.
|
||||
propagated_meta = inject_trace_context(meta)
|
||||
request_meta = cast(
|
||||
"mcp_types.RequestParamsMeta | None",
|
||||
propagated_meta if propagated_meta else None,
|
||||
)
|
||||
|
||||
# Serialize arguments for MCP protocol
|
||||
serialized_arguments: dict[str, str] | None = None
|
||||
if arguments:
|
||||
serialized_arguments = {}
|
||||
for key, value in arguments.items():
|
||||
if isinstance(value, str):
|
||||
serialized_arguments[key] = value
|
||||
else:
|
||||
serialized_arguments[key] = pydantic_core.to_json(value).decode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
# SDK v2: GetPromptRequestParams has no `task` field, so this request
|
||||
# cannot carry task metadata over the wire and the server graceful-
|
||||
# degrades to immediate execution (sdk-feedback #3). `ttl` is retained on
|
||||
# the public API but has no wire representation here.
|
||||
request = mcp_types.GetPromptRequest(
|
||||
params=mcp_types.GetPromptRequestParams(
|
||||
name=name,
|
||||
arguments=serialized_arguments,
|
||||
_meta=request_meta, # type: ignore[unknown-argument] # pydantic alias
|
||||
)
|
||||
)
|
||||
|
||||
# Server returns CreateTaskResult (task accepted) or GetPromptResult (graceful degradation)
|
||||
wrapped_result = await self._await_with_session_monitoring(
|
||||
self.session.send_request(
|
||||
request=request, # type: ignore[arg-type]
|
||||
result_type=PromptTaskResponseUnion,
|
||||
)
|
||||
)
|
||||
raw_result = wrapped_result.root
|
||||
|
||||
if isinstance(raw_result, mcp_types.CreateTaskResult):
|
||||
# Task was accepted - extract task info from CreateTaskResult
|
||||
server_task_id = raw_result.task.task_id
|
||||
self._submitted_task_ids.add(server_task_id)
|
||||
|
||||
task_obj = PromptTask(
|
||||
self, server_task_id, prompt_name=name, immediate_result=None
|
||||
)
|
||||
self._task_registry[server_task_id] = weakref.ref(task_obj)
|
||||
return task_obj
|
||||
else:
|
||||
# Graceful degradation - server returned GetPromptResult
|
||||
synthetic_task_id = task_id or str(uuid.uuid4())
|
||||
return PromptTask(
|
||||
self, synthetic_task_id, prompt_name=name, immediate_result=raw_result
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,18 +2,15 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import weakref
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast, overload
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import mcp_types
|
||||
from mcp.client.caching import CacheMode
|
||||
from pydantic import AnyUrl, RootModel
|
||||
from pydantic import AnyUrl
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.client.client import Client
|
||||
|
||||
from fastmcp.client.tasks import ResourceTask
|
||||
from fastmcp.client.telemetry import client_span
|
||||
from fastmcp.telemetry import inject_trace_context
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
|
@ -22,11 +19,6 @@ logger = get_logger(__name__)
|
|||
|
||||
AUTO_PAGINATION_MAX_PAGES = 250
|
||||
|
||||
# Type alias for task response union (SEP-1686 graceful degradation)
|
||||
ResourceTaskResponseUnion = RootModel[
|
||||
mcp_types.CreateTaskResult | mcp_types.ReadResourceResult
|
||||
]
|
||||
|
||||
|
||||
class ClientResourcesMixin:
|
||||
"""Mixin providing resource-related methods for Client."""
|
||||
|
|
@ -272,54 +264,23 @@ class ClientResourcesMixin:
|
|||
)
|
||||
return result
|
||||
|
||||
@overload
|
||||
async def read_resource(
|
||||
self: Client,
|
||||
uri: AnyUrl | str,
|
||||
*,
|
||||
version: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: Literal[False] = False,
|
||||
) -> list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]: ...
|
||||
|
||||
@overload
|
||||
async def read_resource(
|
||||
self: Client,
|
||||
uri: AnyUrl | str,
|
||||
*,
|
||||
version: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: Literal[True],
|
||||
task_id: str | None = None,
|
||||
ttl: int = 60000,
|
||||
) -> ResourceTask: ...
|
||||
|
||||
async def read_resource(
|
||||
self: Client,
|
||||
uri: AnyUrl | str,
|
||||
*,
|
||||
version: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool = False,
|
||||
task_id: str | None = None,
|
||||
ttl: int = 60000,
|
||||
) -> (
|
||||
list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]
|
||||
| ResourceTask
|
||||
):
|
||||
) -> list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]:
|
||||
"""Read the contents of a resource or resolved template.
|
||||
|
||||
Args:
|
||||
uri (AnyUrl | str): The URI of the resource to read. Can be a string or an AnyUrl object.
|
||||
version (str | None): Specific version to read. If None, reads highest version.
|
||||
meta (dict[str, Any] | None): Optional request-level metadata.
|
||||
task (bool): If True, execute as background task (SEP-1686). Defaults to False.
|
||||
task_id (str | None): Optional client-provided task ID (auto-generated if not provided).
|
||||
ttl (int): Time to keep results available in milliseconds (default 60s).
|
||||
|
||||
Returns:
|
||||
list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents] | ResourceTask:
|
||||
A list of content objects if task=False, or a ResourceTask object if task=True.
|
||||
list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]:
|
||||
A list of content objects.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If called while the client is not connected.
|
||||
|
|
@ -333,11 +294,6 @@ class ClientResourcesMixin:
|
|||
"version": version,
|
||||
}
|
||||
|
||||
if task:
|
||||
return await self._read_resource_as_task(
|
||||
uri, task_id, ttl, meta=request_meta or None
|
||||
)
|
||||
|
||||
if isinstance(uri, str):
|
||||
try:
|
||||
uri = AnyUrl(uri) # Ensure AnyUrl
|
||||
|
|
@ -347,77 +303,3 @@ class ClientResourcesMixin:
|
|||
) from e
|
||||
result = await self.read_resource_mcp(uri, meta=request_meta or None)
|
||||
return result.contents
|
||||
|
||||
async def _read_resource_as_task(
|
||||
self: Client,
|
||||
uri: AnyUrl | str,
|
||||
task_id: str | None = None,
|
||||
ttl: int = 60000,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> ResourceTask:
|
||||
"""Read a resource for background execution (SEP-1686).
|
||||
|
||||
Returns a ResourceTask object that handles both background and immediate execution.
|
||||
|
||||
Args:
|
||||
uri: Resource URI to read
|
||||
task_id: Optional client-provided task ID (ignored, for backward compatibility)
|
||||
ttl: Time to keep results available in milliseconds (default 60s)
|
||||
meta: Optional metadata to pass with the request (e.g., version info)
|
||||
|
||||
Returns:
|
||||
ResourceTask: Future-like object for accessing task status and results
|
||||
"""
|
||||
# Per SEP-1686 final spec: client sends only ttl, server generates taskId
|
||||
# Inject trace context into meta for propagation to server.
|
||||
# SDK v2: request `_meta` is `RequestParamsMeta` (a TypedDict), not
|
||||
# the old `RequestParams.Meta` nested model.
|
||||
propagated_meta = inject_trace_context(meta)
|
||||
request_meta = cast(
|
||||
"mcp_types.RequestParamsMeta | None",
|
||||
propagated_meta if propagated_meta else None,
|
||||
)
|
||||
|
||||
# SDK v2: ReadResourceRequestParams.uri is a plain string, but resources
|
||||
# are stored under the AnyUrl-normalized form, so normalize to match.
|
||||
uri_str = str(AnyUrl(uri)) if isinstance(uri, str) else str(uri)
|
||||
|
||||
# SDK v2: ReadResourceRequestParams has no `task` field, so this request
|
||||
# cannot carry task metadata over the wire and the server graceful-
|
||||
# degrades to immediate execution (sdk-feedback #3). `ttl` is retained on
|
||||
# the public API but has no wire representation here.
|
||||
request = mcp_types.ReadResourceRequest(
|
||||
params=mcp_types.ReadResourceRequestParams(
|
||||
uri=uri_str,
|
||||
_meta=request_meta, # type: ignore[unknown-argument] # pydantic alias
|
||||
)
|
||||
)
|
||||
|
||||
# Server returns CreateTaskResult (task accepted) or ReadResourceResult (graceful degradation)
|
||||
wrapped_result = await self._await_with_session_monitoring(
|
||||
self.session.send_request(
|
||||
request=request, # type: ignore[arg-type]
|
||||
result_type=ResourceTaskResponseUnion,
|
||||
)
|
||||
)
|
||||
raw_result = wrapped_result.root
|
||||
|
||||
if isinstance(raw_result, mcp_types.CreateTaskResult):
|
||||
# Task was accepted - extract task info from CreateTaskResult
|
||||
server_task_id = raw_result.task.task_id
|
||||
self._submitted_task_ids.add(server_task_id)
|
||||
|
||||
task_obj = ResourceTask(
|
||||
self, server_task_id, uri=str(uri), immediate_result=None
|
||||
)
|
||||
self._task_registry[server_task_id] = weakref.ref(task_obj)
|
||||
return task_obj
|
||||
else:
|
||||
# Graceful degradation - server returned ReadResourceResult
|
||||
synthetic_task_id = task_id or str(uuid.uuid4())
|
||||
return ResourceTask(
|
||||
self,
|
||||
synthetic_task_id,
|
||||
uri=str(uri),
|
||||
immediate_result=raw_result.contents,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
try:
|
||||
status = await self.get_task_status(task_id)
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
|
@ -2,21 +2,17 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import weakref
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast, overload
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import mcp_types
|
||||
from mcp.client.caching import CacheMode
|
||||
from opentelemetry.trace import Status, StatusCode
|
||||
from pydantic import RootModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import datetime
|
||||
|
||||
from fastmcp.client.client import CallToolResult, Client
|
||||
from fastmcp.client.progress import ProgressHandler
|
||||
from fastmcp.client.tasks import ToolTask
|
||||
from fastmcp.client.telemetry import client_span
|
||||
from fastmcp.exceptions import ToolError
|
||||
from fastmcp.telemetry import inject_trace_context
|
||||
|
|
@ -29,9 +25,6 @@ logger = get_logger(__name__)
|
|||
|
||||
AUTO_PAGINATION_MAX_PAGES = 250
|
||||
|
||||
# Type alias for task response union (SEP-1686 graceful degradation)
|
||||
ToolTaskResponseUnion = RootModel[mcp_types.CreateTaskResult | mcp_types.CallToolResult]
|
||||
|
||||
|
||||
class ClientToolsMixin:
|
||||
"""Mixin providing tool-related methods for Client."""
|
||||
|
|
@ -278,7 +271,6 @@ class ClientToolsMixin:
|
|||
raise_on_error=raise_on_error,
|
||||
)
|
||||
|
||||
@overload
|
||||
async def call_tool(
|
||||
self: Client,
|
||||
name: str,
|
||||
|
|
@ -289,39 +281,7 @@ class ClientToolsMixin:
|
|||
progress_handler: ProgressHandler | None = None,
|
||||
raise_on_error: bool = True,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: Literal[False] = False,
|
||||
) -> CallToolResult: ...
|
||||
|
||||
@overload
|
||||
async def call_tool(
|
||||
self: Client,
|
||||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
version: str | None = None,
|
||||
timeout: datetime.timedelta | float | int | None = None,
|
||||
progress_handler: ProgressHandler | None = None,
|
||||
raise_on_error: bool = True,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: Literal[True],
|
||||
task_id: str | None = None,
|
||||
ttl: int = 60000,
|
||||
) -> ToolTask: ...
|
||||
|
||||
async def call_tool(
|
||||
self: Client,
|
||||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
version: str | None = None,
|
||||
timeout: datetime.timedelta | float | int | None = None,
|
||||
progress_handler: ProgressHandler | None = None,
|
||||
raise_on_error: bool = True,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool = False,
|
||||
task_id: str | None = None,
|
||||
ttl: int = 60000,
|
||||
) -> CallToolResult | ToolTask:
|
||||
) -> CallToolResult:
|
||||
"""Call a tool on the server.
|
||||
|
||||
Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error.
|
||||
|
|
@ -337,15 +297,11 @@ class ClientToolsMixin:
|
|||
This is useful for passing contextual information (like user IDs, trace IDs, or preferences)
|
||||
that shouldn't be tool arguments but may influence server-side processing. The server
|
||||
can access this via `context.request_context.meta`. Defaults to None.
|
||||
task (bool): If True, execute as background task (SEP-1686). Defaults to False.
|
||||
task_id (str | None): Optional client-provided task ID (auto-generated if not provided).
|
||||
ttl (int): Time to keep results available in milliseconds (default 60s).
|
||||
|
||||
Returns:
|
||||
CallToolResult | ToolTask: The content returned by the tool if task=False,
|
||||
or a ToolTask object if task=True. If the tool returns structured
|
||||
outputs, they are returned as a dataclass (if an output schema
|
||||
is available) or a dictionary; otherwise, a list of content
|
||||
CallToolResult: The content returned by the tool. If the tool returns
|
||||
structured outputs, they are returned as a dataclass (if an output
|
||||
schema is available) or a dictionary; otherwise, a list of content
|
||||
blocks is returned. Note: to receive both structured and
|
||||
unstructured outputs, use call_tool_mcp instead and access the
|
||||
raw result object.
|
||||
|
|
@ -363,16 +319,6 @@ class ClientToolsMixin:
|
|||
"version": version,
|
||||
}
|
||||
|
||||
if task:
|
||||
return await self._call_tool_as_task(
|
||||
name,
|
||||
arguments,
|
||||
task_id,
|
||||
ttl,
|
||||
raise_on_error=raise_on_error,
|
||||
meta=request_meta or None,
|
||||
)
|
||||
|
||||
result = await self.call_tool_mcp(
|
||||
name=name,
|
||||
arguments=arguments or {},
|
||||
|
|
@ -384,85 +330,6 @@ class ClientToolsMixin:
|
|||
name, result, raise_on_error=raise_on_error
|
||||
)
|
||||
|
||||
async def _call_tool_as_task(
|
||||
self: Client,
|
||||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
task_id: str | None = None,
|
||||
ttl: int = 60000,
|
||||
raise_on_error: bool = True,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> ToolTask:
|
||||
"""Call a tool for background execution (SEP-1686).
|
||||
|
||||
Returns a ToolTask object that handles both background and immediate execution.
|
||||
If the server accepts background execution, ToolTask will poll for results.
|
||||
If the server declines (graceful degradation), ToolTask wraps the immediate result.
|
||||
|
||||
Args:
|
||||
name: Tool name to call
|
||||
arguments: Tool arguments
|
||||
task_id: Optional client-provided task ID (ignored, for backward compatibility)
|
||||
ttl: Time to keep results available in milliseconds (default 60s)
|
||||
raise_on_error: Whether task.result() should raise ToolError on errors
|
||||
meta: Optional request metadata (e.g., version info)
|
||||
|
||||
Returns:
|
||||
ToolTask: Future-like object for accessing task status and results
|
||||
"""
|
||||
# Per SEP-1686 final spec: client sends only ttl, server generates taskId
|
||||
# Inject trace context into meta for propagation to server
|
||||
propagated_meta = inject_trace_context(meta)
|
||||
# SDK v2: request `_meta` is `RequestParamsMeta` (a TypedDict), not the
|
||||
# old `RequestParams.Meta` nested model.
|
||||
request_meta = cast(mcp_types.RequestParamsMeta | None, propagated_meta)
|
||||
|
||||
# Build request with task metadata
|
||||
request = mcp_types.CallToolRequest(
|
||||
params=mcp_types.CallToolRequestParams(
|
||||
name=name,
|
||||
arguments=arguments or {},
|
||||
task=mcp_types.TaskMetadata(ttl=ttl),
|
||||
_meta=request_meta, # type: ignore[unknown-argument] # pydantic alias
|
||||
)
|
||||
)
|
||||
|
||||
# Server returns CreateTaskResult (task accepted) or CallToolResult (graceful degradation)
|
||||
# Use RootModel with Union to handle both response types (SDK calls model_validate)
|
||||
wrapped_result = await self._await_with_session_monitoring(
|
||||
self.session.send_request(
|
||||
request=request, # type: ignore[arg-type]
|
||||
result_type=ToolTaskResponseUnion,
|
||||
)
|
||||
)
|
||||
raw_result = wrapped_result.root
|
||||
|
||||
if isinstance(raw_result, mcp_types.CreateTaskResult):
|
||||
# Task was accepted - extract task info from CreateTaskResult
|
||||
server_task_id = raw_result.task.task_id
|
||||
self._submitted_task_ids.add(server_task_id)
|
||||
|
||||
task_obj = ToolTask(
|
||||
self,
|
||||
server_task_id,
|
||||
tool_name=name,
|
||||
immediate_result=None,
|
||||
raise_on_error=raise_on_error,
|
||||
)
|
||||
self._task_registry[server_task_id] = weakref.ref(task_obj)
|
||||
return task_obj
|
||||
else:
|
||||
# Graceful degradation - server returned CallToolResult
|
||||
parsed_result = await self._parse_call_tool_result(name, raw_result)
|
||||
synthetic_task_id = task_id or str(uuid.uuid4())
|
||||
return ToolTask(
|
||||
self,
|
||||
synthetic_task_id,
|
||||
tool_name=name,
|
||||
immediate_result=parsed_result,
|
||||
raise_on_error=raise_on_error,
|
||||
)
|
||||
|
||||
|
||||
async def _parse_call_tool_result(
|
||||
name: str,
|
||||
|
|
|
|||
|
|
@ -1,626 +0,0 @@
|
|||
"""SEP-1686 client Task classes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import asyncio
|
||||
import inspect
|
||||
import time
|
||||
import weakref
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Generic, TypeVar
|
||||
|
||||
import mcp_types
|
||||
from mcp_types import GetTaskResult, TaskStatusNotification
|
||||
|
||||
import fastmcp
|
||||
from fastmcp.client.messages import Message, MessageHandler
|
||||
from fastmcp.exceptions import ToolError
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Floor for the fallback poll interval in Task.wait() (seconds). When the server
|
||||
# does not advertise a pollInterval, each wait() call starts its backoff ramp
|
||||
# here so fast tasks resolve quickly even if a status notification is missed.
|
||||
# When the server does advertise one, this is only a safety floor that keeps a
|
||||
# server sending `pollInterval: 0` from spinning the client in a tight loop.
|
||||
MIN_POLL_INTERVAL = 0.02
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.client.client import CallToolResult, Client
|
||||
|
||||
|
||||
class TaskNotificationHandler(MessageHandler):
|
||||
"""MessageHandler that routes task status notifications to Task objects."""
|
||||
|
||||
def __init__(self, client: Client):
|
||||
super().__init__()
|
||||
self._client_ref: weakref.ref[Client] = weakref.ref(client)
|
||||
|
||||
async def dispatch(self, message: Message) -> None:
|
||||
"""Dispatch messages, including task status notifications."""
|
||||
# SDK v2 delivers notifications unwrapped (no `.root` wrapper).
|
||||
if isinstance(message, TaskStatusNotification):
|
||||
client = self._client_ref()
|
||||
if client:
|
||||
client._handle_task_status_notification(message)
|
||||
|
||||
await super().dispatch(message)
|
||||
|
||||
|
||||
TaskResultT = TypeVar("TaskResultT")
|
||||
|
||||
|
||||
class Task(abc.ABC, Generic[TaskResultT]):
|
||||
"""
|
||||
Abstract base class for MCP background tasks (SEP-1686).
|
||||
|
||||
Provides a uniform API whether the server accepts background execution
|
||||
or executes synchronously (graceful degradation per SEP-1686).
|
||||
|
||||
Subclasses:
|
||||
- ToolTask: For tool calls (result type: CallToolResult)
|
||||
- PromptTask: For prompts (future, result type: GetPromptResult)
|
||||
- ResourceTask: For resources (future, result type: ReadResourceResult)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: Client,
|
||||
task_id: str,
|
||||
immediate_result: TaskResultT | None = None,
|
||||
):
|
||||
"""
|
||||
Create a Task wrapper.
|
||||
|
||||
Args:
|
||||
client: The FastMCP client
|
||||
task_id: The task identifier
|
||||
immediate_result: If server executed synchronously, the immediate result
|
||||
"""
|
||||
self._client = client
|
||||
self._task_id = task_id
|
||||
self._immediate_result = immediate_result
|
||||
self._is_immediate = immediate_result is not None
|
||||
|
||||
# Notification-based optimization (SEP-1686 notifications/tasks/status)
|
||||
self._status_cache: GetTaskResult | None = None
|
||||
self._status_event: asyncio.Event | None = None # Lazy init
|
||||
self._status_callbacks: list[
|
||||
Callable[[GetTaskResult], None | Awaitable[None]]
|
||||
] = []
|
||||
self._cached_result: TaskResultT | None = None
|
||||
|
||||
def _check_client_connected(self) -> None:
|
||||
"""Validate that client context is still active.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If accessed outside client context (unless immediate)
|
||||
"""
|
||||
if self._is_immediate:
|
||||
return # Already resolved, no client needed
|
||||
|
||||
try:
|
||||
_ = self._client.session
|
||||
except RuntimeError as e:
|
||||
raise RuntimeError(
|
||||
"Cannot access task results outside client context. "
|
||||
"Task futures must be used within 'async with client:' block."
|
||||
) from e
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
"""Get the task ID."""
|
||||
return self._task_id
|
||||
|
||||
@property
|
||||
def returned_immediately(self) -> bool:
|
||||
"""Check if server executed the task immediately.
|
||||
|
||||
Returns:
|
||||
True if server executed synchronously (graceful degradation or no task support)
|
||||
False if server accepted background execution
|
||||
"""
|
||||
return self._is_immediate
|
||||
|
||||
def _handle_status_notification(self, status: GetTaskResult) -> None:
|
||||
"""Process incoming notifications/tasks/status (internal).
|
||||
|
||||
Called by Client when a notification is received for this task.
|
||||
Updates cache, triggers events, and invokes user callbacks.
|
||||
|
||||
Args:
|
||||
status: Task status from notification
|
||||
"""
|
||||
# Update cache for next status() call
|
||||
self._status_cache = status
|
||||
|
||||
# Wake up any wait() calls
|
||||
if self._status_event is not None:
|
||||
self._status_event.set()
|
||||
|
||||
# Invoke user callbacks
|
||||
for callback in self._status_callbacks:
|
||||
try:
|
||||
result = callback(status)
|
||||
if inspect.isawaitable(result):
|
||||
# Fire and forget async callbacks
|
||||
asyncio.create_task(result) # type: ignore[arg-type] # noqa: RUF006 # ty:ignore[invalid-argument-type]
|
||||
except Exception as e:
|
||||
logger.warning(f"Task callback error: {e}", exc_info=True)
|
||||
|
||||
def on_status_change(
|
||||
self,
|
||||
callback: Callable[[GetTaskResult], None | Awaitable[None]],
|
||||
) -> None:
|
||||
"""Register callback for status change notifications.
|
||||
|
||||
The callback will be invoked when a notifications/tasks/status is received
|
||||
for this task (optional server feature per SEP-1686 lines 436-444).
|
||||
|
||||
Supports both sync and async callbacks (auto-detected).
|
||||
|
||||
Args:
|
||||
callback: Function to call with GetTaskResult when status changes.
|
||||
Can return None (sync) or Awaitable[None] (async).
|
||||
|
||||
Example:
|
||||
>>> task = await client.call_tool("slow_operation", {}, task=True)
|
||||
>>>
|
||||
>>> def on_update(status: GetTaskResult):
|
||||
... print(f"Task {status.task_id} is now {status.status}")
|
||||
>>>
|
||||
>>> task.on_status_change(on_update)
|
||||
>>> result = await task # Callback fires when status changes
|
||||
"""
|
||||
self._status_callbacks.append(callback)
|
||||
|
||||
async def status(self) -> GetTaskResult:
|
||||
"""Get current task status.
|
||||
|
||||
If server executed immediately, returns synthetic completed status.
|
||||
Otherwise queries the server for current status.
|
||||
"""
|
||||
self._check_client_connected()
|
||||
|
||||
if self._is_immediate:
|
||||
# Return synthetic completed status. SDK v2 types the task
|
||||
# timestamps as ISO 8601 strings.
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
return GetTaskResult(
|
||||
task_id=self._task_id,
|
||||
status="completed",
|
||||
created_at=now,
|
||||
last_updated_at=now,
|
||||
ttl=None,
|
||||
poll_interval=1000,
|
||||
)
|
||||
|
||||
# Return cached status if available (from notification)
|
||||
if self._status_cache is not None:
|
||||
cached = self._status_cache
|
||||
# Don't clear cache - keep it for next call
|
||||
return cached
|
||||
|
||||
# Query server and cache the result
|
||||
self._status_cache = await self._client.get_task_status(self._task_id)
|
||||
return self._status_cache
|
||||
|
||||
@abc.abstractmethod
|
||||
async def result(self) -> TaskResultT:
|
||||
"""Wait for and return the task result.
|
||||
|
||||
Must be implemented by subclasses to return the appropriate result type.
|
||||
"""
|
||||
...
|
||||
|
||||
async def wait(
|
||||
self, *, state: str | None = None, timeout: float = 300.0
|
||||
) -> GetTaskResult:
|
||||
"""Wait for task to reach a specific state or complete.
|
||||
|
||||
Uses event-based waiting when notifications are available (fast),
|
||||
with fallback to polling (reliable). Optimally wakes up immediately
|
||||
on status changes when server sends notifications/tasks/status.
|
||||
|
||||
The fallback poll cadence has two modes. If the server advertises a
|
||||
`pollInterval`, that interval is honored exactly (subject only to a
|
||||
20ms safety floor), because it is a deliberate statement about how
|
||||
much load the server wants to take. If it does not, the poll starts at
|
||||
20ms and doubles up to the `client_task_poll_interval` setting.
|
||||
|
||||
Args:
|
||||
state: Desired state ('working', 'input_required', 'completed', 'failed', 'cancelled').
|
||||
If None, waits until the task exits the 'working' state (completed, failed, cancelled, input_required, etc.)
|
||||
timeout: Maximum time to wait in seconds
|
||||
|
||||
Returns:
|
||||
GetTaskResult: Final task status
|
||||
|
||||
Raises:
|
||||
TimeoutError: If desired state not reached within timeout
|
||||
"""
|
||||
self._check_client_connected()
|
||||
|
||||
if self._is_immediate:
|
||||
# Already done
|
||||
return await self.status()
|
||||
|
||||
# Initialize event for notification wake-ups
|
||||
if self._status_event is None:
|
||||
self._status_event = asyncio.Event()
|
||||
|
||||
start = time.time()
|
||||
in_progress_states = {"working"}
|
||||
# Backoff state for the unadvertised-interval mode; resets per wait()
|
||||
# call. Notifications still short-circuit the wait via the status event,
|
||||
# so this only governs the fallback poll.
|
||||
backoff = MIN_POLL_INTERVAL
|
||||
|
||||
while True:
|
||||
# Check cached status first (updated by notifications)
|
||||
if self._status_cache:
|
||||
current = self._status_cache.status
|
||||
if state is None:
|
||||
if current not in in_progress_states:
|
||||
return self._status_cache
|
||||
elif current == state:
|
||||
return self._status_cache
|
||||
|
||||
# Check timeout
|
||||
elapsed = time.time() - start
|
||||
if elapsed >= timeout:
|
||||
raise TimeoutError(
|
||||
f"Task {self._task_id} did not reach {state or 'terminal state'} within {timeout}s"
|
||||
)
|
||||
|
||||
remaining = timeout - elapsed
|
||||
interval, backoff = self._next_poll_delay(backoff)
|
||||
|
||||
# Wait for notification event OR poll timeout
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._status_event.wait(), timeout=min(interval, remaining)
|
||||
)
|
||||
self._status_event.clear()
|
||||
except asyncio.TimeoutError:
|
||||
# Fallback: poll server (notification didn't arrive in time)
|
||||
self._status_cache = await self._client.get_task_status(self._task_id)
|
||||
|
||||
def _next_poll_delay(self, backoff: float) -> tuple[float, float]:
|
||||
"""Delay before the next fallback poll, plus the backoff for the round after.
|
||||
|
||||
Advertised interval -> honor it; no advertised interval -> ramp.
|
||||
|
||||
A server that advertises `pollInterval` (milliseconds) is making a
|
||||
deliberate statement about how much load it wants to take, so that
|
||||
interval is used verbatim as the delay with no backoff ramp. The only
|
||||
adjustment is `MIN_POLL_INTERVAL` as a safety floor, so a server sending
|
||||
a zero or negative interval cannot spin this client in a tight request
|
||||
loop.
|
||||
|
||||
When the server advertises nothing, there is no guidance to honor, so
|
||||
the poll starts at `MIN_POLL_INTERVAL` and doubles each round up to the
|
||||
`client_task_poll_interval` setting.
|
||||
"""
|
||||
cache = self._status_cache
|
||||
if cache is not None and cache.poll_interval is not None:
|
||||
return max(cache.poll_interval / 1000, MIN_POLL_INTERVAL), backoff
|
||||
|
||||
ceiling = fastmcp.settings.client_task_poll_interval
|
||||
return min(backoff, ceiling), min(backoff * 2, ceiling)
|
||||
|
||||
async def _wait_terminal(self, timeout: float = 300.0) -> GetTaskResult:
|
||||
"""Wait until task reaches a terminal state (completed, failed, cancelled).
|
||||
|
||||
Unlike wait(), this will not return on input_required — it continues
|
||||
waiting until the task fully resolves. Used internally by result().
|
||||
"""
|
||||
terminal_states = {"completed", "failed", "cancelled"}
|
||||
status = await self.wait(timeout=timeout)
|
||||
while status.status not in terminal_states:
|
||||
# Task is in a non-terminal state (e.g. input_required) — reset
|
||||
# cache so the next wait() call blocks instead of returning immediately.
|
||||
self._status_cache = None
|
||||
status = await self.wait(timeout=timeout)
|
||||
return status
|
||||
|
||||
async def cancel(self) -> None:
|
||||
"""Cancel this task, transitioning it to cancelled state.
|
||||
|
||||
Sends a tasks/cancel protocol request. The server will attempt to halt
|
||||
execution and move the task to cancelled state.
|
||||
|
||||
Note: If server executed immediately (graceful degradation), this is a no-op
|
||||
as there's no server-side task to cancel.
|
||||
"""
|
||||
if self._is_immediate:
|
||||
# No server-side task to cancel
|
||||
return
|
||||
self._check_client_connected()
|
||||
await self._client.cancel_task(self._task_id)
|
||||
# Invalidate cache to force fresh status fetch
|
||||
self._status_cache = None
|
||||
|
||||
def __await__(self):
|
||||
"""Allow 'await task' to get result."""
|
||||
return self.result().__await__()
|
||||
|
||||
|
||||
class ToolTask(Task["CallToolResult"]):
|
||||
"""
|
||||
Represents a tool call that may execute in background or immediately.
|
||||
|
||||
Provides a uniform API whether the server accepts background execution
|
||||
or executes synchronously (graceful degradation per SEP-1686).
|
||||
|
||||
Usage:
|
||||
task = await client.call_tool_as_task("analyze", args)
|
||||
|
||||
# Check status
|
||||
status = await task.status()
|
||||
|
||||
# Wait for completion
|
||||
await task.wait()
|
||||
|
||||
# Get result (waits if needed)
|
||||
result = await task.result() # Returns CallToolResult
|
||||
|
||||
# Or just await the task directly
|
||||
result = await task
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: Client,
|
||||
task_id: str,
|
||||
tool_name: str,
|
||||
immediate_result: CallToolResult | None = None,
|
||||
raise_on_error: bool = True,
|
||||
):
|
||||
"""
|
||||
Create a ToolTask wrapper.
|
||||
|
||||
Args:
|
||||
client: The FastMCP client
|
||||
task_id: The task identifier
|
||||
tool_name: Name of the tool being executed
|
||||
immediate_result: If server executed synchronously, the immediate result
|
||||
raise_on_error: Whether task.result() should raise ToolError on errors
|
||||
"""
|
||||
super().__init__(client, task_id, immediate_result)
|
||||
self._tool_name = tool_name
|
||||
self._raise_on_error = raise_on_error
|
||||
|
||||
async def result(self) -> CallToolResult:
|
||||
"""Wait for and return the tool result.
|
||||
|
||||
If server executed immediately, returns the immediate result.
|
||||
Otherwise waits for background task to complete and retrieves result.
|
||||
|
||||
Returns:
|
||||
CallToolResult: The parsed tool result (same as call_tool returns)
|
||||
"""
|
||||
# Check cache first
|
||||
if self._cached_result is not None:
|
||||
return self._cached_result
|
||||
|
||||
if self._is_immediate:
|
||||
assert self._immediate_result is not None # Type narrowing
|
||||
result = self._immediate_result
|
||||
if result.is_error and self._raise_on_error:
|
||||
if result.content and isinstance(
|
||||
result.content[0], mcp_types.TextContent
|
||||
):
|
||||
msg = result.content[0].text
|
||||
else:
|
||||
msg = f"Tool '{self._tool_name}' returned an error"
|
||||
raise ToolError(msg)
|
||||
else:
|
||||
# Check client connected
|
||||
self._check_client_connected()
|
||||
|
||||
# Wait for completion using event-based wait (respects notifications)
|
||||
await self._wait_terminal()
|
||||
|
||||
# Get the raw result (dict or CallToolResult)
|
||||
raw_result = await self._client.get_task_result(self._task_id)
|
||||
|
||||
# Convert to CallToolResult if needed and parse
|
||||
if isinstance(raw_result, dict):
|
||||
# Raw dict from get_task_result - parse as CallToolResult
|
||||
mcp_result = mcp_types.CallToolResult.model_validate(raw_result)
|
||||
result = await self._client._parse_call_tool_result(
|
||||
self._tool_name,
|
||||
mcp_result,
|
||||
raise_on_error=self._raise_on_error,
|
||||
)
|
||||
elif isinstance(raw_result, mcp_types.CallToolResult):
|
||||
# Already a CallToolResult from MCP protocol - parse it
|
||||
result = await self._client._parse_call_tool_result(
|
||||
self._tool_name,
|
||||
raw_result,
|
||||
raise_on_error=self._raise_on_error,
|
||||
)
|
||||
else:
|
||||
# Legacy ToolResult format - convert to MCP type
|
||||
if hasattr(raw_result, "content") and hasattr(
|
||||
raw_result, "structured_content"
|
||||
):
|
||||
mcp_result = mcp_types.CallToolResult(
|
||||
content=raw_result.content,
|
||||
structured_content=raw_result.structured_content,
|
||||
_meta=raw_result.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field
|
||||
)
|
||||
result = await self._client._parse_call_tool_result(
|
||||
self._tool_name,
|
||||
mcp_result,
|
||||
raise_on_error=self._raise_on_error,
|
||||
)
|
||||
else:
|
||||
# Unknown type - just return it
|
||||
result = raw_result
|
||||
|
||||
# Cache before returning
|
||||
self._cached_result = result
|
||||
return result
|
||||
|
||||
|
||||
class PromptTask(Task[mcp_types.GetPromptResult]):
|
||||
"""
|
||||
Represents a prompt call that may execute in background or immediately.
|
||||
|
||||
Provides a uniform API whether the server accepts background execution
|
||||
or executes synchronously (graceful degradation per SEP-1686).
|
||||
|
||||
Usage:
|
||||
task = await client.get_prompt_as_task("analyze", args)
|
||||
result = await task # Returns GetPromptResult
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: Client,
|
||||
task_id: str,
|
||||
prompt_name: str,
|
||||
immediate_result: mcp_types.GetPromptResult | None = None,
|
||||
):
|
||||
"""
|
||||
Create a PromptTask wrapper.
|
||||
|
||||
Args:
|
||||
client: The FastMCP client
|
||||
task_id: The task identifier
|
||||
prompt_name: Name of the prompt being executed
|
||||
immediate_result: If server executed synchronously, the immediate result
|
||||
"""
|
||||
super().__init__(client, task_id, immediate_result)
|
||||
self._prompt_name = prompt_name
|
||||
|
||||
async def result(self) -> mcp_types.GetPromptResult:
|
||||
"""Wait for and return the prompt result.
|
||||
|
||||
If server executed immediately, returns the immediate result.
|
||||
Otherwise waits for background task to complete and retrieves result.
|
||||
|
||||
Returns:
|
||||
GetPromptResult: The prompt result with messages and description
|
||||
"""
|
||||
# Check cache first
|
||||
if self._cached_result is not None:
|
||||
return self._cached_result
|
||||
|
||||
if self._is_immediate:
|
||||
assert self._immediate_result is not None
|
||||
result = self._immediate_result
|
||||
else:
|
||||
# Check client connected
|
||||
self._check_client_connected()
|
||||
|
||||
# Wait for completion using event-based wait (respects notifications)
|
||||
await self._wait_terminal()
|
||||
|
||||
# Get the raw MCP result
|
||||
mcp_result = await self._client.get_task_result(self._task_id)
|
||||
|
||||
# Parse as GetPromptResult
|
||||
result = mcp_types.GetPromptResult.model_validate(mcp_result)
|
||||
|
||||
# Cache before returning
|
||||
self._cached_result = result
|
||||
return result
|
||||
|
||||
|
||||
class ResourceTask(
|
||||
Task[list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]]
|
||||
):
|
||||
"""
|
||||
Represents a resource read that may execute in background or immediately.
|
||||
|
||||
Provides a uniform API whether the server accepts background execution
|
||||
or executes synchronously (graceful degradation per SEP-1686).
|
||||
|
||||
Usage:
|
||||
task = await client.read_resource_as_task("file://data.txt")
|
||||
contents = await task # Returns list[ReadResourceContents]
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: Client,
|
||||
task_id: str,
|
||||
uri: str,
|
||||
immediate_result: list[
|
||||
mcp_types.TextResourceContents | mcp_types.BlobResourceContents
|
||||
]
|
||||
| None = None,
|
||||
):
|
||||
"""
|
||||
Create a ResourceTask wrapper.
|
||||
|
||||
Args:
|
||||
client: The FastMCP client
|
||||
task_id: The task identifier
|
||||
uri: URI of the resource being read
|
||||
immediate_result: If server executed synchronously, the immediate result
|
||||
"""
|
||||
super().__init__(client, task_id, immediate_result)
|
||||
self._uri = uri
|
||||
|
||||
async def result(
|
||||
self,
|
||||
) -> list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]:
|
||||
"""Wait for and return the resource contents.
|
||||
|
||||
If server executed immediately, returns the immediate result.
|
||||
Otherwise waits for background task to complete and retrieves result.
|
||||
|
||||
Returns:
|
||||
list[ReadResourceContents]: The resource contents
|
||||
"""
|
||||
# Check cache first
|
||||
if self._cached_result is not None:
|
||||
return self._cached_result
|
||||
|
||||
if self._is_immediate:
|
||||
assert self._immediate_result is not None
|
||||
result = self._immediate_result
|
||||
else:
|
||||
# Check client connected
|
||||
self._check_client_connected()
|
||||
|
||||
# Wait for completion using event-based wait (respects notifications)
|
||||
await self._wait_terminal()
|
||||
|
||||
# Get the raw MCP result
|
||||
mcp_result = await self._client.get_task_result(self._task_id)
|
||||
|
||||
# Parse as ReadResourceResult or extract contents
|
||||
if isinstance(mcp_result, mcp_types.ReadResourceResult):
|
||||
# Already parsed by TasksResponse - extract contents
|
||||
result = list(mcp_result.contents)
|
||||
elif isinstance(mcp_result, dict) and "contents" in mcp_result:
|
||||
# Dict format - parse each content item
|
||||
parsed_contents = []
|
||||
for item in mcp_result["contents"]:
|
||||
if isinstance(item, dict):
|
||||
if "blob" in item:
|
||||
parsed_contents.append(
|
||||
mcp_types.BlobResourceContents.model_validate(item)
|
||||
)
|
||||
else:
|
||||
parsed_contents.append(
|
||||
mcp_types.TextResourceContents.model_validate(item)
|
||||
)
|
||||
else:
|
||||
parsed_contents.append(item)
|
||||
result = parsed_contents
|
||||
else:
|
||||
# Fallback - might be the list directly
|
||||
result = mcp_result if isinstance(mcp_result, list) else [mcp_result]
|
||||
|
||||
# Cache before returning
|
||||
self._cached_result = result
|
||||
return result
|
||||
|
|
@ -8,8 +8,8 @@ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|||
if TYPE_CHECKING:
|
||||
from fastmcp.prompts.function_prompt import PromptMeta
|
||||
from fastmcp.resources.function_resource import ResourceMeta
|
||||
from fastmcp.server.tasks.config import TaskConfig
|
||||
from fastmcp.tools.function_tool import ToolMeta
|
||||
from fastmcp.utilities.tasks import TaskConfig
|
||||
|
||||
FastMCPMeta = ToolMeta | ResourceMeta | PromptMeta
|
||||
|
||||
|
|
|
|||
|
|
@ -4,20 +4,21 @@ This module re-exports dependency injection symbols to provide a clean,
|
|||
centralized import location for all dependency-related functionality.
|
||||
|
||||
DI features (Depends, CurrentContext, CurrentFastMCP) work without pydocket
|
||||
using the uncalled-for DI engine. Only task-related dependencies (CurrentDocket,
|
||||
CurrentWorker) and background task execution require fastmcp[tasks].
|
||||
using the uncalled-for DI engine. The docket-specific dependencies
|
||||
(``CurrentDocket``, ``CurrentWorker``) live in the ``fastmcp-tasks`` package
|
||||
(``fastmcp_tasks.dependencies``).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from uncalled_for import Dependency, Depends, Shared
|
||||
|
||||
from fastmcp.server.dependencies import (
|
||||
CurrentAccessToken,
|
||||
CurrentContext,
|
||||
CurrentDocket,
|
||||
CurrentFastMCP,
|
||||
CurrentHeaders,
|
||||
CurrentRequest,
|
||||
CurrentWorker,
|
||||
Progress,
|
||||
ProgressLike,
|
||||
TokenClaim,
|
||||
|
|
@ -26,11 +27,9 @@ from fastmcp.server.dependencies import (
|
|||
__all__ = [
|
||||
"CurrentAccessToken",
|
||||
"CurrentContext",
|
||||
"CurrentDocket",
|
||||
"CurrentFastMCP",
|
||||
"CurrentHeaders",
|
||||
"CurrentRequest",
|
||||
"CurrentWorker",
|
||||
"Dependency",
|
||||
"Depends",
|
||||
"Progress",
|
||||
|
|
@ -38,3 +37,17 @@ __all__ = [
|
|||
"Shared",
|
||||
"TokenClaim",
|
||||
]
|
||||
|
||||
# Docket-specific dependencies moved to the fastmcp-tasks package. Point users
|
||||
# there instead of raising a bare AttributeError.
|
||||
_MOVED_TO_TASKS = {"CurrentDocket", "CurrentWorker"}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _MOVED_TO_TASKS:
|
||||
raise ImportError(
|
||||
f"{name!r} moved to the fastmcp-tasks package. Install it with "
|
||||
f"`pip install 'fastmcp[tasks]'` and import from "
|
||||
f"`fastmcp_tasks.dependencies`."
|
||||
)
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
|
|
|||
|
|
@ -3,17 +3,13 @@
|
|||
from __future__ import annotations as _annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal
|
||||
|
||||
import pydantic
|
||||
import pydantic_core
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docket import Docket
|
||||
from docket.execution import Execution
|
||||
|
||||
from fastmcp.prompts.function_prompt import FunctionPrompt
|
||||
import mcp_types
|
||||
from mcp import GetPromptResult
|
||||
from mcp_types import (
|
||||
AudioContent,
|
||||
|
|
@ -31,7 +27,6 @@ from pydantic.json_schema import SkipJsonSchema
|
|||
from fastmcp.utilities.authorization import AuthCheck
|
||||
from fastmcp.utilities.components import FastMCPComponent
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.tasks import TaskConfig, TaskMeta
|
||||
from fastmcp.utilities.types import (
|
||||
FastMCPBaseModel,
|
||||
)
|
||||
|
|
@ -242,7 +237,6 @@ class Prompt(FastMCPComponent):
|
|||
icons: list[Icon] | None = None,
|
||||
tags: set[str] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
auth: AuthCheck | list[AuthCheck] | None = None,
|
||||
) -> FunctionPrompt:
|
||||
"""Create a Prompt from a function.
|
||||
|
|
@ -263,7 +257,6 @@ class Prompt(FastMCPComponent):
|
|||
icons=icons,
|
||||
tags=tags,
|
||||
meta=meta,
|
||||
task=task,
|
||||
auth=auth,
|
||||
)
|
||||
|
||||
|
|
@ -316,89 +309,19 @@ class Prompt(FastMCPComponent):
|
|||
f"got {type(raw_value).__name__}"
|
||||
)
|
||||
|
||||
@overload
|
||||
async def _render(
|
||||
self,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
task_meta: None = None,
|
||||
) -> PromptResult: ...
|
||||
) -> PromptResult:
|
||||
"""Server entry point for prompt renders.
|
||||
|
||||
@overload
|
||||
async def _render(
|
||||
self,
|
||||
arguments: dict[str, Any] | None,
|
||||
task_meta: TaskMeta,
|
||||
) -> mcp_types.CreateTaskResult: ...
|
||||
|
||||
async def _render(
|
||||
self,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
task_meta: TaskMeta | None = None,
|
||||
) -> PromptResult | mcp_types.CreateTaskResult:
|
||||
"""Server entry point that handles task routing.
|
||||
|
||||
This allows ANY Prompt subclass to support background execution by setting
|
||||
task_config.mode to "supported" or "required". The server calls this
|
||||
method instead of render() directly.
|
||||
|
||||
Args:
|
||||
arguments: Prompt arguments
|
||||
task_meta: If provided, execute as background task and return
|
||||
CreateTaskResult. If None (default), execute synchronously and
|
||||
return PromptResult.
|
||||
|
||||
Returns:
|
||||
PromptResult when task_meta is None.
|
||||
CreateTaskResult when task_meta is provided.
|
||||
|
||||
Subclasses can override this to customize task routing behavior.
|
||||
For example, FastMCPProviderPrompt overrides to delegate to child
|
||||
middleware without submitting to Docket.
|
||||
The server calls this method instead of render() directly so that
|
||||
subclasses can customize dispatch. For example, FastMCPProviderPrompt
|
||||
overrides this to delegate to child-server middleware.
|
||||
"""
|
||||
from fastmcp.server.tasks.routing import check_background_task
|
||||
|
||||
task_result = await check_background_task(
|
||||
component=self,
|
||||
task_type="prompt",
|
||||
arguments=arguments,
|
||||
task_meta=task_meta,
|
||||
)
|
||||
if task_result:
|
||||
return task_result
|
||||
|
||||
# Synchronous execution
|
||||
result = await self.render(arguments)
|
||||
return self.convert_result(result)
|
||||
|
||||
def register_with_docket(self, docket: Docket) -> None:
|
||||
"""Register this prompt with docket for background execution."""
|
||||
if not self.task_config.supports_tasks():
|
||||
return
|
||||
docket.register(self.render, names=[self.key])
|
||||
|
||||
async def add_to_docket( # type: ignore[override]
|
||||
self,
|
||||
docket: Docket,
|
||||
arguments: dict[str, Any] | None,
|
||||
*,
|
||||
fn_key: str | None = None,
|
||||
task_key: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Execution:
|
||||
"""Schedule this prompt for background execution via docket.
|
||||
|
||||
Args:
|
||||
docket: The Docket instance
|
||||
arguments: Prompt arguments
|
||||
fn_key: Function lookup key in Docket registry (defaults to self.key)
|
||||
task_key: Redis storage key for the result
|
||||
**kwargs: Additional kwargs passed to docket.add()
|
||||
"""
|
||||
lookup_key = fn_key or self.key
|
||||
if task_key:
|
||||
kwargs["key"] = task_key
|
||||
return await docket.add(lookup_key, **kwargs)(arguments)
|
||||
|
||||
def get_span_attributes(self) -> dict[str, Any]:
|
||||
return super().get_span_attributes() | {
|
||||
"fastmcp.component.type": "prompt",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ from collections.abc import Callable
|
|||
from dataclasses import dataclass, field
|
||||
from types import MethodType
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Literal,
|
||||
Protocol,
|
||||
|
|
@ -33,13 +32,8 @@ from fastmcp.utilities.authorization import AuthCheck
|
|||
from fastmcp.utilities.docstring_parsing import ParsedDocstring, parse_docstring
|
||||
from fastmcp.utilities.json_schema import compress_schema
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.tasks import TaskConfig
|
||||
from fastmcp.utilities.types import get_cached_typeadapter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docket import Docket
|
||||
from docket.execution import Execution
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
|
@ -66,7 +60,6 @@ class PromptMeta:
|
|||
icons: list[Icon] | None = None
|
||||
tags: set[str] | None = None
|
||||
meta: dict[str, Any] | None = None
|
||||
task: bool | TaskConfig | None = None
|
||||
auth: AuthCheck | list[AuthCheck] | None = None
|
||||
enabled: bool = True
|
||||
|
||||
|
|
@ -90,7 +83,6 @@ class FunctionPrompt(Prompt):
|
|||
icons: list[Icon] | None = None,
|
||||
tags: set[str] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
auth: AuthCheck | list[AuthCheck] | None = None,
|
||||
) -> FunctionPrompt:
|
||||
"""Create a Prompt from a function.
|
||||
|
|
@ -110,7 +102,7 @@ class FunctionPrompt(Prompt):
|
|||
# Check mutual exclusion
|
||||
individual_params_provided = any(
|
||||
x is not None
|
||||
for x in [name, version, title, description, icons, tags, meta, task, auth]
|
||||
for x in [name, version, title, description, icons, tags, meta, auth]
|
||||
)
|
||||
|
||||
if metadata is not None and individual_params_provided:
|
||||
|
|
@ -129,7 +121,6 @@ class FunctionPrompt(Prompt):
|
|||
icons=icons,
|
||||
tags=tags,
|
||||
meta=meta,
|
||||
task=task,
|
||||
auth=auth,
|
||||
)
|
||||
|
||||
|
|
@ -152,16 +143,6 @@ class FunctionPrompt(Prompt):
|
|||
# docstring as the prompt description for callable class instances.
|
||||
outer_docstring = parse_docstring(fn)
|
||||
|
||||
# Normalize task to TaskConfig and validate
|
||||
task_value = metadata.task
|
||||
if task_value is None:
|
||||
task_config = TaskConfig(mode="forbidden")
|
||||
elif isinstance(task_value, bool):
|
||||
task_config = TaskConfig.from_bool(task_value)
|
||||
else:
|
||||
task_config = task_value
|
||||
task_config.validate_function(fn, func_name)
|
||||
|
||||
# if the fn is a callable class, we need to get the __call__ method from here out
|
||||
if not inspect.isroutine(fn) and not isinstance(fn, functools.partial):
|
||||
fn = fn.__call__
|
||||
|
|
@ -267,7 +248,6 @@ class FunctionPrompt(Prompt):
|
|||
tags=metadata.tags or set(),
|
||||
fn=wrapped_fn,
|
||||
meta=metadata.meta,
|
||||
task_config=task_config,
|
||||
auth=metadata.auth,
|
||||
)
|
||||
|
||||
|
|
@ -367,37 +347,6 @@ class FunctionPrompt(Prompt):
|
|||
logger.exception(f"Error rendering prompt {self.name}")
|
||||
raise PromptError(f"Error rendering prompt {self.name!r}: {e}") from e
|
||||
|
||||
def register_with_docket(self, docket: Docket) -> None:
|
||||
"""Register this prompt with docket for background execution."""
|
||||
if not self.task_config.supports_tasks():
|
||||
return
|
||||
docket.register(self.fn, names=[self.key])
|
||||
|
||||
async def add_to_docket(
|
||||
self,
|
||||
docket: Docket,
|
||||
arguments: dict[str, Any] | None,
|
||||
*,
|
||||
fn_key: str | None = None,
|
||||
task_key: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Execution:
|
||||
"""Schedule this prompt for background execution via docket.
|
||||
|
||||
FunctionPrompt splats the arguments dict since .fn expects **kwargs.
|
||||
|
||||
Args:
|
||||
docket: The Docket instance
|
||||
arguments: Prompt arguments
|
||||
fn_key: Function lookup key in Docket registry (defaults to self.key)
|
||||
task_key: Redis storage key for the result
|
||||
**kwargs: Additional kwargs passed to docket.add()
|
||||
"""
|
||||
lookup_key = fn_key or self.key
|
||||
if task_key:
|
||||
kwargs["key"] = task_key
|
||||
return await docket.add(lookup_key, **kwargs)(**(arguments or {}))
|
||||
|
||||
|
||||
@overload
|
||||
def prompt(fn: F) -> F: ...
|
||||
|
|
@ -411,7 +360,6 @@ def prompt(
|
|||
icons: list[Icon] | None = None,
|
||||
tags: set[str] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
auth: AuthCheck | list[AuthCheck] | None = None,
|
||||
) -> Callable[[F], F]: ...
|
||||
@overload
|
||||
|
|
@ -425,7 +373,6 @@ def prompt(
|
|||
icons: list[Icon] | None = None,
|
||||
tags: set[str] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
auth: AuthCheck | list[AuthCheck] | None = None,
|
||||
) -> Callable[[F], F]: ...
|
||||
|
||||
|
|
@ -440,7 +387,6 @@ def prompt(
|
|||
icons: list[Icon] | None = None,
|
||||
tags: set[str] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
auth: AuthCheck | list[AuthCheck] | None = None,
|
||||
) -> Any:
|
||||
"""Standalone decorator to mark a function as an MCP prompt.
|
||||
|
|
@ -463,7 +409,6 @@ def prompt(
|
|||
icons=icons,
|
||||
tags=tags,
|
||||
meta=meta,
|
||||
task=task,
|
||||
auth=auth,
|
||||
)
|
||||
target = fn.__func__ if isinstance(fn, staticmethod | MethodType) else fn
|
||||
|
|
|
|||
|
|
@ -5,14 +5,11 @@ from __future__ import annotations
|
|||
import base64
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, overload
|
||||
from typing import TYPE_CHECKING, Annotated, Any, ClassVar
|
||||
|
||||
import mcp_types
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docket import Docket
|
||||
from docket.execution import Execution
|
||||
|
||||
from fastmcp.resources.function_resource import FunctionResource
|
||||
|
||||
import pydantic
|
||||
|
|
@ -32,7 +29,6 @@ from typing_extensions import Self
|
|||
|
||||
from fastmcp.utilities.authorization import AuthCheck
|
||||
from fastmcp.utilities.components import FastMCPComponent
|
||||
from fastmcp.utilities.tasks import TaskConfig, TaskMeta
|
||||
|
||||
|
||||
class ResourceContent(pydantic.BaseModel):
|
||||
|
|
@ -339,7 +335,6 @@ class Resource(FastMCPComponent):
|
|||
tags: set[str] | None = None,
|
||||
annotations: Annotations | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
auth: AuthCheck | list[AuthCheck] | None = None,
|
||||
) -> FunctionResource:
|
||||
from fastmcp.resources.function_resource import (
|
||||
|
|
@ -358,7 +353,6 @@ class Resource(FastMCPComponent):
|
|||
tags=tags,
|
||||
annotations=annotations,
|
||||
meta=meta,
|
||||
task=task,
|
||||
auth=auth,
|
||||
)
|
||||
|
||||
|
|
@ -414,43 +408,14 @@ class Resource(FastMCPComponent):
|
|||
raw_value, mime_type=self.mime_type, meta=self.meta
|
||||
)
|
||||
|
||||
@overload
|
||||
async def _read(self, task_meta: None = None) -> ResourceResult: ...
|
||||
async def _read(self) -> ResourceResult:
|
||||
"""Server entry point for resource reads.
|
||||
|
||||
@overload
|
||||
async def _read(self, task_meta: TaskMeta) -> mcp_types.CreateTaskResult: ...
|
||||
|
||||
async def _read(
|
||||
self, task_meta: TaskMeta | None = None
|
||||
) -> ResourceResult | mcp_types.CreateTaskResult:
|
||||
"""Server entry point that handles task routing.
|
||||
|
||||
This allows ANY Resource subclass to support background execution by setting
|
||||
task_config.mode to "supported" or "required". The server calls this
|
||||
method instead of read() directly.
|
||||
|
||||
Args:
|
||||
task_meta: If provided, execute as a background task and return
|
||||
CreateTaskResult. If None (default), execute synchronously and
|
||||
return ResourceResult.
|
||||
|
||||
Returns:
|
||||
ResourceResult when task_meta is None.
|
||||
CreateTaskResult when task_meta is provided.
|
||||
|
||||
Subclasses can override this to customize task routing behavior.
|
||||
For example, FastMCPProviderResource overrides to delegate to child
|
||||
middleware without submitting to Docket.
|
||||
The server calls this method instead of ``read()`` directly so that
|
||||
subclasses can customize dispatch. For example,
|
||||
``FastMCPProviderResource`` overrides this to delegate to child-server
|
||||
middleware.
|
||||
"""
|
||||
from fastmcp.server.tasks.routing import check_background_task
|
||||
|
||||
task_result = await check_background_task(
|
||||
component=self, task_type="resource", arguments=None, task_meta=task_meta
|
||||
)
|
||||
if task_result:
|
||||
return task_result
|
||||
|
||||
# Synchronous execution - convert result to ResourceResult
|
||||
result = await self.read()
|
||||
return self.convert_result(result)
|
||||
|
||||
|
|
@ -482,33 +447,6 @@ class Resource(FastMCPComponent):
|
|||
base_key = self.make_key(str(self.uri))
|
||||
return f"{base_key}@{self.version or ''}"
|
||||
|
||||
def register_with_docket(self, docket: Docket) -> None:
|
||||
"""Register this resource with docket for background execution."""
|
||||
if not self.task_config.supports_tasks():
|
||||
return
|
||||
docket.register(self.read, names=[self.key])
|
||||
|
||||
async def add_to_docket( # type: ignore[override]
|
||||
self,
|
||||
docket: Docket,
|
||||
*,
|
||||
fn_key: str | None = None,
|
||||
task_key: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Execution:
|
||||
"""Schedule this resource for background execution via docket.
|
||||
|
||||
Args:
|
||||
docket: The Docket instance
|
||||
fn_key: Function lookup key in Docket registry (defaults to self.key)
|
||||
task_key: Redis storage key for the result
|
||||
**kwargs: Additional kwargs passed to docket.add()
|
||||
"""
|
||||
lookup_key = fn_key or self.key
|
||||
if task_key:
|
||||
kwargs["key"] = task_key
|
||||
return await docket.add(lookup_key, **kwargs)()
|
||||
|
||||
def get_span_attributes(self) -> dict[str, Any]:
|
||||
return super().get_span_attributes() | {
|
||||
"fastmcp.component.type": "resource",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from collections.abc import Callable
|
|||
from dataclasses import dataclass, field
|
||||
from types import MethodType
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Literal,
|
||||
Protocol,
|
||||
|
|
@ -33,11 +32,6 @@ from fastmcp.utilities.async_utils import (
|
|||
)
|
||||
from fastmcp.utilities.authorization import AuthCheck
|
||||
from fastmcp.utilities.mime import resolve_ui_mime_type
|
||||
from fastmcp.utilities.tasks import TaskConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docket import Docket
|
||||
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
|
@ -66,7 +60,6 @@ class ResourceMeta:
|
|||
mime_type: str | None = None
|
||||
annotations: Annotations | None = None
|
||||
meta: dict[str, Any] | None = None
|
||||
task: bool | TaskConfig | None = None
|
||||
auth: AuthCheck | list[AuthCheck] | None = None
|
||||
enabled: bool = True
|
||||
security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY
|
||||
|
|
@ -104,7 +97,6 @@ class FunctionResource(Resource):
|
|||
tags: set[str] | None = None,
|
||||
annotations: Annotations | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
auth: AuthCheck | list[AuthCheck] | None = None,
|
||||
) -> FunctionResource:
|
||||
"""Create a FunctionResource from a function.
|
||||
|
|
@ -131,7 +123,6 @@ class FunctionResource(Resource):
|
|||
tags,
|
||||
annotations,
|
||||
meta,
|
||||
task,
|
||||
auth,
|
||||
]
|
||||
)
|
||||
|
|
@ -159,7 +150,6 @@ class FunctionResource(Resource):
|
|||
mime_type=mime_type,
|
||||
annotations=annotations,
|
||||
meta=meta,
|
||||
task=task,
|
||||
auth=auth,
|
||||
)
|
||||
|
||||
|
|
@ -170,16 +160,6 @@ class FunctionResource(Resource):
|
|||
metadata.name or getattr(fn, "__name__", None) or fn.__class__.__name__
|
||||
)
|
||||
|
||||
# Normalize task to TaskConfig and validate
|
||||
task_value = metadata.task
|
||||
if task_value is None:
|
||||
task_config = TaskConfig(mode="forbidden")
|
||||
elif isinstance(task_value, bool):
|
||||
task_config = TaskConfig.from_bool(task_value)
|
||||
else:
|
||||
task_config = task_value
|
||||
task_config.validate_function(fn, func_name)
|
||||
|
||||
# if the fn is a callable class, we need to get the __call__ method from here out
|
||||
if not inspect.isroutine(fn) and not isinstance(fn, functools.partial):
|
||||
fn = fn.__call__
|
||||
|
|
@ -215,7 +195,6 @@ class FunctionResource(Resource):
|
|||
tags=metadata.tags or set(),
|
||||
annotations=metadata.annotations,
|
||||
meta=metadata.meta,
|
||||
task_config=task_config,
|
||||
auth=metadata.auth,
|
||||
)
|
||||
|
||||
|
|
@ -240,12 +219,6 @@ class FunctionResource(Resource):
|
|||
|
||||
return result
|
||||
|
||||
def register_with_docket(self, docket: Docket) -> None:
|
||||
"""Register this resource with docket for background execution."""
|
||||
if not self.task_config.supports_tasks():
|
||||
return
|
||||
docket.register(self.fn, names=[self.key])
|
||||
|
||||
|
||||
def resource(
|
||||
uri: str,
|
||||
|
|
@ -259,7 +232,6 @@ def resource(
|
|||
tags: set[str] | None = None,
|
||||
annotations: Annotations | dict[str, Any] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
auth: AuthCheck | list[AuthCheck] | None = None,
|
||||
security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY,
|
||||
) -> Callable[[F], F]:
|
||||
|
|
@ -289,7 +261,6 @@ def resource(
|
|||
mime_type=mime_type,
|
||||
annotations=annotations,
|
||||
meta=meta,
|
||||
task=task,
|
||||
auth=auth,
|
||||
security=security,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,22 +6,17 @@ import functools
|
|||
import inspect
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, overload
|
||||
from typing import Any, ClassVar
|
||||
from urllib.parse import parse_qs, quote, unquote
|
||||
|
||||
import mcp_types
|
||||
from mcp_types import Annotations, Icon
|
||||
from pydantic.json_schema import SkipJsonSchema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docket import Docket
|
||||
from docket.execution import Execution
|
||||
from mcp_types import ResourceTemplate as SDKResourceTemplate
|
||||
from pydantic import (
|
||||
Field,
|
||||
field_validator,
|
||||
validate_call,
|
||||
)
|
||||
from pydantic.json_schema import SkipJsonSchema
|
||||
|
||||
from fastmcp.resources.base import (
|
||||
Resource,
|
||||
|
|
@ -37,7 +32,6 @@ from fastmcp.utilities.authorization import AuthCheck
|
|||
from fastmcp.utilities.components import FastMCPComponent
|
||||
from fastmcp.utilities.json_schema import compress_schema
|
||||
from fastmcp.utilities.mime import resolve_ui_mime_type
|
||||
from fastmcp.utilities.tasks import TaskConfig, TaskMeta
|
||||
from fastmcp.utilities.types import get_cached_typeadapter
|
||||
|
||||
|
||||
|
|
@ -235,7 +229,6 @@ class ResourceTemplate(FastMCPComponent):
|
|||
tags: set[str] | None = None,
|
||||
annotations: Annotations | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
auth: AuthCheck | list[AuthCheck] | None = None,
|
||||
security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY,
|
||||
) -> FunctionResourceTemplate:
|
||||
|
|
@ -251,7 +244,6 @@ class ResourceTemplate(FastMCPComponent):
|
|||
tags=tags,
|
||||
annotations=annotations,
|
||||
meta=meta,
|
||||
task=task,
|
||||
auth=auth,
|
||||
security=security,
|
||||
)
|
||||
|
|
@ -290,50 +282,13 @@ class ResourceTemplate(FastMCPComponent):
|
|||
raw_value, mime_type=self.mime_type, meta=self.meta
|
||||
)
|
||||
|
||||
@overload
|
||||
async def _read(
|
||||
self, uri: str, params: dict[str, Any], task_meta: None = None
|
||||
) -> ResourceResult: ...
|
||||
async def _read(self, uri: str, params: dict[str, Any]) -> ResourceResult:
|
||||
"""Server entry point for template reads.
|
||||
|
||||
@overload
|
||||
async def _read(
|
||||
self, uri: str, params: dict[str, Any], task_meta: TaskMeta
|
||||
) -> mcp_types.CreateTaskResult: ...
|
||||
|
||||
async def _read(
|
||||
self, uri: str, params: dict[str, Any], task_meta: TaskMeta | None = None
|
||||
) -> ResourceResult | mcp_types.CreateTaskResult:
|
||||
"""Server entry point that handles task routing.
|
||||
|
||||
This allows ANY ResourceTemplate subclass to support background execution
|
||||
by setting task_config.mode to "supported" or "required". The server calls
|
||||
this method instead of create_resource()/read() directly.
|
||||
|
||||
Args:
|
||||
uri: The concrete URI being read
|
||||
params: Template parameters extracted from the URI
|
||||
task_meta: If provided, execute as a background task and return
|
||||
CreateTaskResult. If None (default), execute synchronously and
|
||||
return ResourceResult.
|
||||
|
||||
Returns:
|
||||
ResourceResult when task_meta is None.
|
||||
CreateTaskResult when task_meta is provided.
|
||||
|
||||
Subclasses can override this to customize task routing behavior.
|
||||
For example, FastMCPProviderResourceTemplate overrides to delegate to child
|
||||
middleware without submitting to Docket.
|
||||
The server calls this instead of create_resource()/read() directly so
|
||||
subclasses can customize dispatch (e.g. FastMCPProviderResourceTemplate
|
||||
delegates to child-server middleware).
|
||||
"""
|
||||
from fastmcp.server.tasks.routing import check_background_task
|
||||
|
||||
task_result = await check_background_task(
|
||||
component=self, task_type="template", arguments=params, task_meta=task_meta
|
||||
)
|
||||
if task_result:
|
||||
return task_result
|
||||
|
||||
# Synchronous execution - create resource and read directly
|
||||
# Call resource.read() not resource._read() to avoid task routing on ephemeral resource
|
||||
resource = await self.create_resource(uri, params)
|
||||
result = await resource.read()
|
||||
return self.convert_result(result)
|
||||
|
|
@ -387,35 +342,6 @@ class ResourceTemplate(FastMCPComponent):
|
|||
base_key = self.make_key(self.uri_template)
|
||||
return f"{base_key}@{self.version or ''}"
|
||||
|
||||
def register_with_docket(self, docket: Docket) -> None:
|
||||
"""Register this template with docket for background execution."""
|
||||
if not self.task_config.supports_tasks():
|
||||
return
|
||||
docket.register(self.read, names=[self.key])
|
||||
|
||||
async def add_to_docket( # type: ignore[override]
|
||||
self,
|
||||
docket: Docket,
|
||||
params: dict[str, Any],
|
||||
*,
|
||||
fn_key: str | None = None,
|
||||
task_key: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Execution:
|
||||
"""Schedule this template for background execution via docket.
|
||||
|
||||
Args:
|
||||
docket: The Docket instance
|
||||
params: Template parameters
|
||||
fn_key: Function lookup key in Docket registry (defaults to self.key)
|
||||
task_key: Redis storage key for the result
|
||||
**kwargs: Additional kwargs passed to docket.add()
|
||||
"""
|
||||
lookup_key = fn_key or self.key
|
||||
if task_key:
|
||||
kwargs["key"] = task_key
|
||||
return await docket.add(lookup_key, **kwargs)(params)
|
||||
|
||||
def get_span_attributes(self) -> dict[str, Any]:
|
||||
return super().get_span_attributes() | {
|
||||
"fastmcp.component.type": "resource_template",
|
||||
|
|
@ -428,44 +354,13 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|||
|
||||
fn: SkipJsonSchema[Callable[..., Any]]
|
||||
|
||||
@overload
|
||||
async def _read(
|
||||
self, uri: str, params: dict[str, Any], task_meta: None = None
|
||||
) -> ResourceResult: ...
|
||||
|
||||
@overload
|
||||
async def _read(
|
||||
self, uri: str, params: dict[str, Any], task_meta: TaskMeta
|
||||
) -> mcp_types.CreateTaskResult: ...
|
||||
|
||||
async def _read(
|
||||
self, uri: str, params: dict[str, Any], task_meta: TaskMeta | None = None
|
||||
) -> ResourceResult | mcp_types.CreateTaskResult:
|
||||
async def _read(self, uri: str, params: dict[str, Any]) -> ResourceResult:
|
||||
"""Optimized server entry point that skips ephemeral resource creation.
|
||||
|
||||
For FunctionResourceTemplate, we can call read() directly instead of
|
||||
creating a temporary resource, which is more efficient.
|
||||
|
||||
Args:
|
||||
uri: The concrete URI being read
|
||||
params: Template parameters extracted from the URI
|
||||
task_meta: If provided, execute as a background task and return
|
||||
CreateTaskResult. If None (default), execute synchronously and
|
||||
return ResourceResult.
|
||||
|
||||
Returns:
|
||||
ResourceResult when task_meta is None.
|
||||
CreateTaskResult when task_meta is provided.
|
||||
"""
|
||||
from fastmcp.server.tasks.routing import check_background_task
|
||||
|
||||
task_result = await check_background_task(
|
||||
component=self, task_type="template", arguments=params, task_meta=task_meta
|
||||
)
|
||||
if task_result:
|
||||
return task_result
|
||||
|
||||
# Synchronous execution - call read() directly, skip resource creation
|
||||
# Call read() directly, skip resource creation
|
||||
result = await self.read(arguments=params)
|
||||
return self.convert_result(result)
|
||||
|
||||
|
|
@ -488,7 +383,6 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|||
meta=self.meta,
|
||||
title=self.title,
|
||||
icons=self.icons,
|
||||
task=self.task_config,
|
||||
auth=self.auth,
|
||||
)
|
||||
|
||||
|
|
@ -531,37 +425,6 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|||
|
||||
return result
|
||||
|
||||
def register_with_docket(self, docket: Docket) -> None:
|
||||
"""Register this template with docket for background execution."""
|
||||
if not self.task_config.supports_tasks():
|
||||
return
|
||||
docket.register(self.fn, names=[self.key])
|
||||
|
||||
async def add_to_docket(
|
||||
self,
|
||||
docket: Docket,
|
||||
params: dict[str, Any],
|
||||
*,
|
||||
fn_key: str | None = None,
|
||||
task_key: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Execution:
|
||||
"""Schedule this template for background execution via docket.
|
||||
|
||||
FunctionResourceTemplate splats the params dict since .fn expects **kwargs.
|
||||
|
||||
Args:
|
||||
docket: The Docket instance
|
||||
params: Template parameters
|
||||
fn_key: Function lookup key in Docket registry (defaults to self.key)
|
||||
task_key: Redis storage key for the result
|
||||
**kwargs: Additional kwargs passed to docket.add()
|
||||
"""
|
||||
lookup_key = fn_key or self.key
|
||||
if task_key:
|
||||
kwargs["key"] = task_key
|
||||
return await docket.add(lookup_key, **kwargs)(**params)
|
||||
|
||||
@classmethod
|
||||
def from_function(
|
||||
cls,
|
||||
|
|
@ -576,7 +439,6 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|||
tags: set[str] | None = None,
|
||||
annotations: Annotations | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
auth: AuthCheck | list[AuthCheck] | None = None,
|
||||
security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY,
|
||||
) -> FunctionResourceTemplate:
|
||||
|
|
@ -673,15 +535,6 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|||
|
||||
description = description if description is not None else inspect.getdoc(fn)
|
||||
|
||||
# Normalize task to TaskConfig and validate
|
||||
if task is None:
|
||||
task_config = TaskConfig(mode="forbidden")
|
||||
elif isinstance(task, bool):
|
||||
task_config = TaskConfig.from_bool(task)
|
||||
else:
|
||||
task_config = task
|
||||
task_config.validate_function(fn, func_name)
|
||||
|
||||
# if the fn is a callable class, we need to get the __call__ method from here out
|
||||
if not inspect.isroutine(fn) and not isinstance(fn, functools.partial):
|
||||
fn = fn.__call__
|
||||
|
|
@ -716,7 +569,6 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|||
tags=tags or set(),
|
||||
annotations=annotations,
|
||||
meta=meta,
|
||||
task_config=task_config,
|
||||
auth=auth,
|
||||
security=security,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -128,6 +128,20 @@ def _warn_sampling_deprecated() -> None:
|
|||
|
||||
_current_context: ContextVar[Context | None] = ContextVar("context", default=None)
|
||||
|
||||
|
||||
#: Error raised when a tool calls ``ctx.elicit()`` inside a background task.
|
||||
#: Background tasks gather input with the guard/return pattern (return an
|
||||
#: ``InputRequiredResult``), which the end-and-reenter machinery drives across
|
||||
#: worker legs. Imperative elicitation would require blocking a worker on a
|
||||
#: client round-trip, which end-and-reenter deliberately does not do.
|
||||
_TASK_ELICIT_ERROR = (
|
||||
"Imperative ctx.elicit() is not supported inside a background task. Gather "
|
||||
"input with the guard pattern instead: return an InputRequiredResult from "
|
||||
"the tool (with input_requests), and read ctx.input_responses / "
|
||||
"ctx.request_state when the task re-runs after the client answers."
|
||||
)
|
||||
|
||||
|
||||
TransportType = Literal["stdio", "sse", "streamable-http"]
|
||||
_current_transport: ContextVar[TransportType | None] = ContextVar(
|
||||
"transport", default=None
|
||||
|
|
@ -251,6 +265,14 @@ class Context:
|
|||
self._origin_request_id: str | None = origin_request_id
|
||||
# Request-scoped state for non-serializable values (serializable=False)
|
||||
self._request_state: dict[str, Any] = {}
|
||||
# Multi-round-trip input carried in-task (SEP-2322 guard channel). A
|
||||
# foreground round recovers `input_responses`/`request_state` from the
|
||||
# wire request; a worker has no wire request, so the tasks extension's
|
||||
# in-task loop sets these between rounds and the properties below fall
|
||||
# back to them. The guard tool reads `ctx.input_responses` identically
|
||||
# in both modes — only the transport differs (task store vs wire params).
|
||||
self._task_input_responses: mcp_types.InputResponses | None = None
|
||||
self._task_request_state: str | None = None
|
||||
|
||||
@property
|
||||
def is_background_task(self) -> bool:
|
||||
|
|
@ -312,26 +334,10 @@ class Context:
|
|||
self._tokens.append(token)
|
||||
|
||||
# Set current server for dependency injection (use weakref to avoid reference cycles)
|
||||
from fastmcp.server.dependencies import (
|
||||
_current_docket,
|
||||
_current_server,
|
||||
_current_worker,
|
||||
is_docket_available,
|
||||
)
|
||||
from fastmcp.server.dependencies import _current_server, is_docket_available
|
||||
|
||||
self._server_token = _current_server.set(weakref.ref(self.fastmcp))
|
||||
|
||||
# Re-set docket/worker from the server instance so mounted children
|
||||
# inherit the parent's Docket via the ContextVar. Only servers that
|
||||
# own the Docket (the parent) have _docket set; children skip this,
|
||||
# leaving the parent's value in place.
|
||||
if is_docket_available():
|
||||
server = self.fastmcp
|
||||
if server._docket is not None:
|
||||
self._docket_token = _current_docket.set(server._docket)
|
||||
if server._worker is not None:
|
||||
self._worker_token = _current_worker.set(server._worker)
|
||||
|
||||
if not is_docket_available():
|
||||
# Without docket, the lifespan won't provide a SharedContext,
|
||||
# so create one scoped to this Context for Shared() dependencies.
|
||||
|
|
@ -342,18 +348,8 @@ class Context:
|
|||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
"""Exit the context manager and reset the most recent token."""
|
||||
from fastmcp.server.dependencies import (
|
||||
_current_docket,
|
||||
_current_server,
|
||||
_current_worker,
|
||||
)
|
||||
from fastmcp.server.dependencies import _current_server
|
||||
|
||||
if hasattr(self, "_worker_token"):
|
||||
_current_worker.reset(self._worker_token)
|
||||
del self._worker_token
|
||||
if hasattr(self, "_docket_token"):
|
||||
_current_docket.reset(self._docket_token)
|
||||
del self._docket_token
|
||||
if hasattr(self, "_shared_context"):
|
||||
await self._shared_context.__aexit__(exc_type, exc_val, exc_tb)
|
||||
del self._shared_context
|
||||
|
|
@ -393,6 +389,25 @@ class Context:
|
|||
"""
|
||||
return fastmcp_request_ctx.get()
|
||||
|
||||
def client_extension_settings(self, identifier: str) -> dict[str, Any] | None:
|
||||
"""This request's per-request opt-in settings for an MCP extension.
|
||||
|
||||
SEP-2133 extensions negotiate per request: the client repeats its
|
||||
extension capabilities in each request's ``_meta`` under
|
||||
``io.modelcontextprotocol/clientCapabilities`` → ``extensions`` →
|
||||
``identifier``. Returns the declared settings dict (possibly empty) when
|
||||
the extension was opted in for this request, or ``None`` when it was
|
||||
not (or there is no active request). This bridges an extension's
|
||||
``tools/call`` interceptor — which receives a FastMCP ``Context`` — to
|
||||
the request's declared client capabilities.
|
||||
"""
|
||||
rc = self.request_context
|
||||
if rc is None:
|
||||
return None
|
||||
from fastmcp.server.extensions import _extract_client_extension_settings
|
||||
|
||||
return _extract_client_extension_settings(rc.meta, identifier)
|
||||
|
||||
def _input_response_params(
|
||||
self,
|
||||
) -> mcp_types.InputResponseRequestParams | None:
|
||||
|
|
@ -426,9 +441,14 @@ class Context:
|
|||
keys match the `input_requests` map the tool minted; each value is the
|
||||
client's result for that request (an `ElicitResult`, `CreateMessageResult`,
|
||||
or `ListRootsResult`).
|
||||
|
||||
In a background task there is no wire request, so this falls back to the
|
||||
responses the in-task guard loop delivered (see the tasks extension).
|
||||
"""
|
||||
params = self._input_response_params()
|
||||
return params.input_responses if params else None
|
||||
if params is not None and params.input_responses is not None:
|
||||
return params.input_responses
|
||||
return self._task_input_responses
|
||||
|
||||
@property
|
||||
def request_state(self) -> str | None:
|
||||
|
|
@ -440,9 +460,14 @@ class Context:
|
|||
before the tool runs, so tampering is rejected before this is read).
|
||||
`None` on the initial round. Use it to carry a small amount of computed
|
||||
state across rounds without re-deriving it.
|
||||
|
||||
In a background task there is no wire request, so this falls back to the
|
||||
state the in-task guard loop re-injected (see the tasks extension).
|
||||
"""
|
||||
params = self._input_response_params()
|
||||
return params.request_state if params else None
|
||||
if params is not None and params.request_state is not None:
|
||||
return params.request_state
|
||||
return self._task_request_state
|
||||
|
||||
@property
|
||||
def lifespan_context(self) -> dict[str, Any]:
|
||||
|
|
@ -768,6 +793,15 @@ class Context:
|
|||
elif self._session is not None:
|
||||
session = self._session
|
||||
else:
|
||||
# Background task: no live session, but the submitting request's
|
||||
# stable session id was captured in the task snapshot. Use it so
|
||||
# session-scoped state (session_id / get_state / set_state) keeps
|
||||
# working in a worker, keyed to the same client that submitted.
|
||||
from fastmcp.server.dependencies import _background_task_session_id
|
||||
|
||||
task_session_id = _background_task_session_id.get()
|
||||
if task_session_id is not None:
|
||||
return task_session_id
|
||||
raise RuntimeError(
|
||||
"session_id is not available because no session exists. "
|
||||
"This typically means you're outside a request context."
|
||||
|
|
@ -1334,9 +1368,10 @@ class Context:
|
|||
``value`` field. Same scope rules as ``response_title``.
|
||||
|
||||
Note:
|
||||
This method works transparently in both request and background task
|
||||
contexts. In background task mode (SEP-1686), it will set the task
|
||||
status to "input_required" and wait for the client to provide input.
|
||||
Imperative elicitation is not available inside a background task
|
||||
(calling it there raises a ``ToolError``). A task gathers input with
|
||||
the guard pattern: return an ``InputRequiredResult`` and read
|
||||
``ctx.input_responses`` / ``ctx.request_state`` when the task re-runs.
|
||||
"""
|
||||
if response_type is None and fastmcp.settings.deprecation_warnings:
|
||||
warnings.warn(
|
||||
|
|
@ -1356,24 +1391,22 @@ class Context:
|
|||
)
|
||||
|
||||
if self.is_background_task:
|
||||
# Background task mode: use task-aware elicitation
|
||||
result = await self._elicit_for_task(
|
||||
message=message,
|
||||
schema=config.schema,
|
||||
)
|
||||
else:
|
||||
# Foreground push path: server-initiated elicitation needs a
|
||||
# back-channel, which the 2026-07-28 era removed (SEP-2577). Raise a
|
||||
# clear era-aware error before hitting the wire instead of the SDK's
|
||||
# opaque "Method not found". Handshake-era behavior is unchanged.
|
||||
if self._is_modern_protocol():
|
||||
raise ToolError(_ELICIT_MODERN_ERROR)
|
||||
# Standard request mode: use session.elicit directly
|
||||
result = await self.session.elicit(
|
||||
message=message,
|
||||
requested_schema=config.schema,
|
||||
related_request_id=self.request_id,
|
||||
)
|
||||
# Background tasks gather input with the guard/return pattern, not
|
||||
# imperative elicitation — the worker never blocks on a client
|
||||
# round-trip. Fail fast with the guidance to use InputRequiredResult.
|
||||
raise ToolError(_TASK_ELICIT_ERROR)
|
||||
# Foreground push path: server-initiated elicitation needs a back-channel,
|
||||
# which the 2026-07-28 era removed (SEP-2577). Raise a clear era-aware
|
||||
# error before hitting the wire instead of the SDK's opaque "Method not
|
||||
# found". Handshake-era behavior is unchanged.
|
||||
if self._is_modern_protocol():
|
||||
raise ToolError(_ELICIT_MODERN_ERROR)
|
||||
# Standard request mode: use session.elicit directly
|
||||
result = await self.session.elicit(
|
||||
message=message,
|
||||
requested_schema=config.schema,
|
||||
related_request_id=self.request_id,
|
||||
)
|
||||
|
||||
if result.action == "accept":
|
||||
return handle_elicit_accept(config, result.content)
|
||||
|
|
@ -1384,46 +1417,6 @@ class Context:
|
|||
else:
|
||||
raise ValueError(f"Unexpected elicitation action: {result.action}")
|
||||
|
||||
async def _elicit_for_task(
|
||||
self,
|
||||
message: str,
|
||||
schema: dict[str, Any],
|
||||
) -> mcp_types.ElicitResult:
|
||||
"""Send an elicitation request from a background task (SEP-1686).
|
||||
|
||||
This method handles elicitation when running in a Docket worker context,
|
||||
where there's no active MCP request. It:
|
||||
1. Sets the task status to "input_required"
|
||||
2. Sends the elicitation request with task metadata
|
||||
3. Waits for the client to provide input via tasks/sendInput
|
||||
4. Returns the result and resumes task execution
|
||||
|
||||
Args:
|
||||
message: The message to display to the user
|
||||
schema: The JSON schema for the expected response
|
||||
|
||||
Returns:
|
||||
ElicitResult with the user's response
|
||||
|
||||
Raises:
|
||||
RuntimeError: If not running in a background task context
|
||||
"""
|
||||
if not self.is_background_task:
|
||||
raise RuntimeError(
|
||||
"_elicit_for_task called but not in a background task context"
|
||||
)
|
||||
|
||||
# Import here to avoid circular imports and optional dependency issues
|
||||
from fastmcp.server.tasks.elicitation import elicit_for_task
|
||||
|
||||
return await elicit_for_task(
|
||||
task_id=self._task_id, # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
|
||||
session=self._session,
|
||||
message=message,
|
||||
schema=schema,
|
||||
fastmcp=self.fastmcp,
|
||||
)
|
||||
|
||||
def _make_state_key(self, key: str) -> str:
|
||||
"""Create session-prefixed key for state storage."""
|
||||
return f"{self.session_id}:{key}"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
"""Dependency injection for FastMCP.
|
||||
|
||||
DI features (Depends, CurrentContext, CurrentFastMCP) work without pydocket
|
||||
using the uncalled-for DI engine. Only task-related dependencies (CurrentDocket,
|
||||
CurrentWorker) and background task execution require fastmcp[tasks].
|
||||
using the uncalled-for DI engine. The docket-specific dependencies
|
||||
(``CurrentDocket``, ``CurrentWorker``) and background task execution live in the
|
||||
``fastmcp-tasks`` package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -10,11 +11,10 @@ from __future__ import annotations
|
|||
import importlib.metadata
|
||||
import inspect
|
||||
import weakref
|
||||
from collections.abc import AsyncGenerator, Callable, Generator, Mapping
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable, Generator, Mapping
|
||||
from contextlib import AsyncExitStack, asynccontextmanager, contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
from types import TracebackType
|
||||
from typing import TYPE_CHECKING, Any, Protocol, cast, get_type_hints, runtime_checkable
|
||||
|
|
@ -44,9 +44,6 @@ from fastmcp.utilities.logging import get_logger
|
|||
from fastmcp.utilities.types import find_kwarg_by_type, is_class_member_of_type
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docket import Docket
|
||||
from docket.worker import Worker
|
||||
|
||||
from fastmcp.server.context import Context
|
||||
from fastmcp.server.server import FastMCP
|
||||
from fastmcp.server.sessions import Session
|
||||
|
|
@ -147,15 +144,11 @@ __all__ = [
|
|||
"AccessToken",
|
||||
"CurrentAccessToken",
|
||||
"CurrentContext",
|
||||
"CurrentDocket",
|
||||
"CurrentFastMCP",
|
||||
"CurrentHeaders",
|
||||
"CurrentRequest",
|
||||
"CurrentWorker",
|
||||
"FastMCPRequestContext",
|
||||
"Progress",
|
||||
"TaskContextInfo",
|
||||
"TaskContextSnapshot",
|
||||
"TokenClaim",
|
||||
"bind_request_context",
|
||||
"extract_version_spec",
|
||||
|
|
@ -166,37 +159,78 @@ __all__ = [
|
|||
"get_http_request",
|
||||
"get_server",
|
||||
"get_session",
|
||||
"get_task_context",
|
||||
"get_task_session",
|
||||
"is_docket_available",
|
||||
"register_task_server",
|
||||
"register_task_session",
|
||||
"require_docket",
|
||||
"resolve_dependencies",
|
||||
"transform_context_annotations",
|
||||
"without_injected_parameters",
|
||||
]
|
||||
|
||||
|
||||
# Task context lives in fastmcp.server.tasks.context; public symbols are
|
||||
# re-exported here so existing imports from dependencies continue to work.
|
||||
from fastmcp.server.tasks.context import ( # noqa: E402
|
||||
TaskContextInfo,
|
||||
TaskContextSnapshot,
|
||||
_recall_snapshot,
|
||||
get_task_context,
|
||||
get_task_server,
|
||||
get_task_session,
|
||||
register_task_server,
|
||||
register_task_session,
|
||||
)
|
||||
|
||||
_current_server: ContextVar[weakref.ref[FastMCP] | None] = ContextVar(
|
||||
"server", default=None
|
||||
)
|
||||
|
||||
_current_docket: ContextVar[Docket | None] = ContextVar("docket", default=None)
|
||||
_current_worker: ContextVar[Worker | None] = ContextVar("worker", default=None)
|
||||
|
||||
#: Hook installed by the tasks extension (``fastmcp-tasks``) so a ``ctx: Context``
|
||||
#: parameter resolves inside a background-task worker, where there is no
|
||||
#: foreground request context. Core ships no task engine; the extension
|
||||
#: registers a factory here that builds and enters a worker ``Context`` (reading
|
||||
#: the task snapshot restored by the worker). ``_CurrentContext`` falls back to
|
||||
#: it when no foreground context is active. ``None`` means no tasks extension,
|
||||
#: so worker context injection is unavailable and the usual "no active context"
|
||||
#: error applies.
|
||||
_background_context_factory: Callable[[], Awaitable[Context | None]] | None = None
|
||||
|
||||
|
||||
def set_background_context_factory(
|
||||
factory: Callable[[], Awaitable[Context | None]] | None,
|
||||
) -> None:
|
||||
"""Install (or clear) the background-task ``Context`` factory.
|
||||
|
||||
The factory returns an already-entered ``Context`` (so ``_current_context``
|
||||
is set for cleanup) when called inside a worker, or ``None`` when there is
|
||||
no task context. Passing ``None`` restores core's no-worker-fallback
|
||||
behavior.
|
||||
"""
|
||||
global _background_context_factory
|
||||
_background_context_factory = factory
|
||||
|
||||
|
||||
#: Hook installed by the tasks extension so ``get_server()`` (and thus
|
||||
#: ``CurrentFastMCP()``) resolves to the server a mounted task's tool lives on
|
||||
#: rather than the root that started the worker (#3571). Returns that server
|
||||
#: inside a worker, or ``None`` outside one. Core has no task engine, so this is
|
||||
#: ``None`` unless the extension is active.
|
||||
_worker_server_resolver: Callable[[], FastMCP | None] | None = None
|
||||
|
||||
|
||||
def set_worker_server_resolver(
|
||||
resolver: Callable[[], FastMCP | None] | None,
|
||||
) -> None:
|
||||
"""Install (or clear) the worker-server resolver used by ``get_server()``."""
|
||||
global _worker_server_resolver
|
||||
_worker_server_resolver = resolver
|
||||
|
||||
|
||||
#: Headers a background task carries from its originating request. A worker has
|
||||
#: no live HTTP request — especially a Redis-backed worker in a separate process
|
||||
#: — so ``get_http_request()`` correctly raises there. The tasks extension sets
|
||||
#: this from the task snapshot so ``get_http_headers()`` still returns the
|
||||
#: submitting request's headers without fabricating a fake ``Request`` (which
|
||||
#: would make ``get_http_request()``/``CurrentRequest()`` wrongly succeed).
|
||||
_background_task_headers: ContextVar[dict[str, str] | None] = ContextVar(
|
||||
"fastmcp_background_task_headers", default=None
|
||||
)
|
||||
|
||||
|
||||
#: The originating request's stable session id, carried into a background task.
|
||||
#: A worker has no live session, so ``Context.session_id`` (and the session-scoped
|
||||
#: ``get_state``/``set_state`` built on it) would otherwise raise. The tasks
|
||||
#: extension sets this from the task snapshot so session-scoped state keyed by the
|
||||
#: submitting client survives into the worker.
|
||||
_background_task_session_id: ContextVar[str | None] = ContextVar(
|
||||
"fastmcp_background_task_session_id", default=None
|
||||
)
|
||||
|
||||
|
||||
# --- Docket availability check ---
|
||||
|
|
@ -236,43 +270,6 @@ def is_docket_available() -> bool:
|
|||
return _DOCKET_AVAILABLE
|
||||
|
||||
|
||||
def require_docket(feature: str) -> None:
|
||||
"""Raise ImportError with install instructions if docket not available.
|
||||
|
||||
Args:
|
||||
feature: Description of what requires docket (e.g., "`task=True`",
|
||||
"CurrentDocket()"). Will be included in the error message.
|
||||
"""
|
||||
if is_docket_available():
|
||||
return
|
||||
|
||||
try:
|
||||
installed = importlib.metadata.version("pydocket")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
installed = None
|
||||
|
||||
if installed is None:
|
||||
detail = (
|
||||
"FastMCP background tasks require the `tasks` extra. "
|
||||
"Install with: pip install 'fastmcp[tasks]'."
|
||||
)
|
||||
else:
|
||||
detail = (
|
||||
f"FastMCP background tasks require pydocket>={_MIN_DOCKET_VERSION}, "
|
||||
f"but pydocket {installed} is installed (likely pulled in by another "
|
||||
f"package). Upgrade with: pip install -U 'pydocket>={_MIN_DOCKET_VERSION}'."
|
||||
)
|
||||
|
||||
raise ImportError(f"{detail} (Triggered by {feature})")
|
||||
|
||||
|
||||
# Import Progress separately — it's docket-specific, not part of uncalled-for
|
||||
try:
|
||||
from docket.dependencies import Progress as DocketProgress
|
||||
except ImportError:
|
||||
DocketProgress = None # type: ignore[assignment] # ty:ignore[invalid-assignment]
|
||||
|
||||
|
||||
# --- Context utilities ---
|
||||
|
||||
|
||||
|
|
@ -455,9 +452,9 @@ def get_context() -> Context:
|
|||
def get_server() -> FastMCP:
|
||||
"""Get the current FastMCP server instance directly.
|
||||
|
||||
In a background-task worker, checks the task-server map first so that
|
||||
mounted-child tasks resolve to the child server (not the parent that
|
||||
started the worker).
|
||||
In a background-task worker the tasks extension's resolver is consulted
|
||||
first, so a mounted-child task resolves to the child server rather than the
|
||||
root that started the worker (#3571).
|
||||
|
||||
Returns:
|
||||
The active FastMCP server
|
||||
|
|
@ -465,13 +462,11 @@ def get_server() -> FastMCP:
|
|||
Raises:
|
||||
RuntimeError: If no server in context
|
||||
"""
|
||||
# In a task context, prefer the task-specific server mapping.
|
||||
# This handles mounted-child tasks where _current_server is the parent.
|
||||
task_info = get_task_context()
|
||||
if task_info is not None:
|
||||
task_server = get_task_server(task_info.task_id)
|
||||
if task_server is not None:
|
||||
return task_server
|
||||
resolver = _worker_server_resolver
|
||||
if resolver is not None:
|
||||
worker_server = resolver()
|
||||
if worker_server is not None:
|
||||
return worker_server
|
||||
|
||||
server_ref = _current_server.get()
|
||||
if server_ref is None:
|
||||
|
|
@ -521,8 +516,6 @@ def get_http_request() -> Request:
|
|||
"""Get the current HTTP request.
|
||||
|
||||
Tries MCP SDK's request_ctx first, then falls back to FastMCP's HTTP context.
|
||||
In background tasks, returns a synthetic request populated with the
|
||||
snapshotted headers from the originating HTTP request.
|
||||
"""
|
||||
# Try FastMCP's request context first (set during normal MCP request handling)
|
||||
request = None
|
||||
|
|
@ -535,33 +528,6 @@ def get_http_request() -> Request:
|
|||
if request is None:
|
||||
request = _current_http_request.get()
|
||||
|
||||
# In Docket workers, restore a minimal request from the snapshotted
|
||||
# headers. The snapshot is preloaded by restore_task_snapshot before
|
||||
# user code runs, so this is a pure ContextVar read.
|
||||
if request is None:
|
||||
task_info = get_task_context()
|
||||
snapshot = _recall_snapshot(task_info.task_id) if task_info else None
|
||||
task_headers = snapshot.http_headers if snapshot else None
|
||||
if task_headers:
|
||||
request = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"http_version": "1.1",
|
||||
"method": "POST",
|
||||
"scheme": "http",
|
||||
"path": "/",
|
||||
"raw_path": b"/",
|
||||
"query_string": b"",
|
||||
"headers": [
|
||||
(name.encode("latin-1"), value.encode("latin-1"))
|
||||
for name, value in task_headers.items()
|
||||
],
|
||||
"client": None,
|
||||
"server": None,
|
||||
"root_path": "",
|
||||
}
|
||||
)
|
||||
|
||||
if request is None:
|
||||
raise RuntimeError("No active HTTP request found.")
|
||||
return request
|
||||
|
|
@ -614,14 +580,21 @@ def get_http_headers(
|
|||
headers: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
request = get_http_request()
|
||||
for name, value in request.headers.items():
|
||||
lower_name = name.lower()
|
||||
if lower_name not in exclude_headers:
|
||||
headers[lower_name] = str(value)
|
||||
return headers
|
||||
source: Any = get_http_request().headers.items()
|
||||
except RuntimeError:
|
||||
return {}
|
||||
# No live request: inside a background-task worker, fall back to the
|
||||
# headers the task carried from its originating request (set by the
|
||||
# tasks extension from the snapshot). Empty elsewhere.
|
||||
task_headers = _background_task_headers.get()
|
||||
if task_headers is None:
|
||||
return {}
|
||||
source = task_headers.items()
|
||||
|
||||
for name, value in source:
|
||||
lower_name = name.lower()
|
||||
if lower_name not in exclude_headers:
|
||||
headers[lower_name] = str(value)
|
||||
return headers
|
||||
|
||||
|
||||
def get_access_token() -> AccessToken | None:
|
||||
|
|
@ -630,8 +603,7 @@ def get_access_token() -> AccessToken | None:
|
|||
This function first tries to get the token from the current HTTP request's scope,
|
||||
which is more reliable for long-lived connections where the SDK's auth_context_var
|
||||
may become stale after token refresh. Falls back to the SDK's context var if no
|
||||
request is available. In background tasks (Docket workers), falls back to the
|
||||
token snapshot stored in Redis at task submission time.
|
||||
request is available.
|
||||
|
||||
Returns:
|
||||
The access token if an authenticated user is available, None otherwise.
|
||||
|
|
@ -654,19 +626,6 @@ def get_access_token() -> AccessToken | None:
|
|||
if access_token is None:
|
||||
access_token = _sdk_get_access_token()
|
||||
|
||||
# Fall back to background task snapshot (#3095). In Docket workers,
|
||||
# neither the HTTP request nor the SDK context var is available; the
|
||||
# snapshot is preloaded by restore_task_snapshot before user code runs.
|
||||
if access_token is None:
|
||||
task_info = get_task_context()
|
||||
snapshot = _recall_snapshot(task_info.task_id) if task_info else None
|
||||
if snapshot is not None and snapshot.access_token_json is not None:
|
||||
task_token = AccessToken.model_validate_json(snapshot.access_token_json)
|
||||
if task_token.expires_at is not None:
|
||||
if task_token.expires_at < int(datetime.now(timezone.utc).timestamp()):
|
||||
return None
|
||||
return task_token
|
||||
|
||||
if access_token is None or isinstance(access_token, AccessToken):
|
||||
return access_token
|
||||
|
||||
|
|
@ -908,53 +867,35 @@ async def resolve_dependencies(
|
|||
class _CurrentContext(Dependency["Context"]):
|
||||
"""Async context manager for Context dependency.
|
||||
|
||||
In foreground (request) mode: returns the active context from _current_context.
|
||||
In background (Docket worker) mode: creates a task-aware Context with task_id
|
||||
and loads the unified task snapshot from Redis.
|
||||
Returns the active context from _current_context (normal MCP request).
|
||||
|
||||
The shared default instance is a stateless factory. All per-invocation
|
||||
state lives on the returned Context or in task-local ContextVars, so
|
||||
concurrent tasks never share mutable state.
|
||||
state lives on the returned Context, so concurrent calls never share
|
||||
mutable state.
|
||||
"""
|
||||
|
||||
async def __aenter__(self) -> Context:
|
||||
from fastmcp.server.context import Context, _current_context
|
||||
from fastmcp.server.context import _current_context
|
||||
|
||||
# Try foreground context first (normal MCP request)
|
||||
context = _current_context.get()
|
||||
if context is not None:
|
||||
return context
|
||||
|
||||
# Check if we're in a Docket worker context
|
||||
task_info = get_task_context()
|
||||
if task_info is not None:
|
||||
server = get_server()
|
||||
|
||||
# The snapshot is preloaded by restore_task_snapshot (worker-level
|
||||
# Docket dependency) before any task code runs, so this is a pure
|
||||
# ContextVar read — no Redis I/O here.
|
||||
snapshot = _recall_snapshot(task_info.task_id)
|
||||
origin_request_id = snapshot.origin_request_id if snapshot else None
|
||||
|
||||
# Session ID is stored in the snapshot for notification delivery
|
||||
snapshot_session_id = snapshot.session_id if snapshot else None
|
||||
session = (
|
||||
get_task_session(snapshot_session_id) if snapshot_session_id else None
|
||||
)
|
||||
|
||||
ctx = Context(
|
||||
fastmcp=server,
|
||||
session=session,
|
||||
task_id=task_info.task_id,
|
||||
origin_request_id=origin_request_id,
|
||||
)
|
||||
await ctx.__aenter__()
|
||||
return ctx
|
||||
# In a background-task worker there is no foreground context; the tasks
|
||||
# extension installs a factory that builds and enters a worker Context
|
||||
# from the restored task snapshot. Core has no task engine of its own,
|
||||
# so this is None unless the extension is active.
|
||||
factory = _background_context_factory
|
||||
if factory is not None:
|
||||
background = await factory()
|
||||
if background is not None:
|
||||
return background
|
||||
|
||||
raise RuntimeError(
|
||||
"No active context found. This can happen if:\n"
|
||||
" - Called outside an MCP request handler\n"
|
||||
" - Called in a background task before session was registered\n"
|
||||
" - Called in a background task before the context was established\n"
|
||||
"Check `context.request_context` for None before accessing."
|
||||
)
|
||||
|
||||
|
|
@ -1031,118 +972,6 @@ def OptionalCurrentContext() -> Context | None:
|
|||
return cast("Context | None", _OptionalCurrentContext())
|
||||
|
||||
|
||||
class _CurrentDocket(Dependency["Docket"]):
|
||||
"""Async context manager for Docket dependency."""
|
||||
|
||||
async def __aenter__(self) -> Docket:
|
||||
require_docket("CurrentDocket()")
|
||||
# Check server instance first, fall back to ContextVar for mounted children
|
||||
# whose parent owns the Docket
|
||||
try:
|
||||
docket = get_server()._docket
|
||||
except RuntimeError:
|
||||
docket = None
|
||||
if docket is None:
|
||||
docket = _current_docket.get()
|
||||
if docket is None:
|
||||
raise RuntimeError(
|
||||
"No Docket instance found. Docket is only initialized when there are "
|
||||
"task-enabled components (task=True). Add task=True to a component "
|
||||
"to enable Docket infrastructure."
|
||||
)
|
||||
return docket
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_value: BaseException | None,
|
||||
traceback: TracebackType | None,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def CurrentDocket() -> Docket:
|
||||
"""Get the current Docket instance managed by FastMCP.
|
||||
|
||||
This dependency provides access to the Docket instance that FastMCP
|
||||
automatically creates for background task scheduling.
|
||||
|
||||
Returns:
|
||||
A dependency that resolves to the active Docket instance
|
||||
|
||||
Raises:
|
||||
RuntimeError: If not within a FastMCP server context
|
||||
ImportError: If fastmcp[tasks] not installed
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp.dependencies import CurrentDocket
|
||||
|
||||
@mcp.tool()
|
||||
async def schedule_task(docket: Docket = CurrentDocket()) -> str:
|
||||
await docket.add(some_function)(arg1, arg2)
|
||||
return "Scheduled"
|
||||
```
|
||||
"""
|
||||
require_docket("CurrentDocket()")
|
||||
return cast("Docket", _CurrentDocket())
|
||||
|
||||
|
||||
class _CurrentWorker(Dependency["Worker"]):
|
||||
"""Async context manager for Worker dependency."""
|
||||
|
||||
async def __aenter__(self) -> Worker:
|
||||
require_docket("CurrentWorker()")
|
||||
# Check server instance first, fall back to ContextVar for mounted children
|
||||
try:
|
||||
worker = get_server()._worker
|
||||
except RuntimeError:
|
||||
worker = None
|
||||
if worker is None:
|
||||
worker = _current_worker.get()
|
||||
if worker is None:
|
||||
raise RuntimeError(
|
||||
"No Worker instance found. Worker is only initialized when there are "
|
||||
"task-enabled components (task=True). Add task=True to a component "
|
||||
"to enable Docket infrastructure."
|
||||
)
|
||||
return worker
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_value: BaseException | None,
|
||||
traceback: TracebackType | None,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def CurrentWorker() -> Worker:
|
||||
"""Get the current Docket Worker instance managed by FastMCP.
|
||||
|
||||
This dependency provides access to the Worker instance that FastMCP
|
||||
automatically creates for background task processing.
|
||||
|
||||
Returns:
|
||||
A dependency that resolves to the active Worker instance
|
||||
|
||||
Raises:
|
||||
RuntimeError: If not within a FastMCP server context
|
||||
ImportError: If fastmcp[tasks] not installed
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp.dependencies import CurrentWorker
|
||||
|
||||
@mcp.tool()
|
||||
async def check_worker_status(worker: Worker = CurrentWorker()) -> str:
|
||||
return f"Worker: {worker.name}"
|
||||
```
|
||||
"""
|
||||
require_docket("CurrentWorker()")
|
||||
return cast("Worker", _CurrentWorker())
|
||||
|
||||
|
||||
class _CurrentFastMCP(Dependency["FastMCP"]):
|
||||
"""Async context manager for FastMCP server dependency."""
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,6 @@ from pydantic import BaseModel
|
|||
from fastmcp.server.dependencies import _lift_meta, bind_request_context
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import mcp_types
|
||||
|
||||
from fastmcp.server.context import Context
|
||||
from fastmcp.server.server import FastMCP
|
||||
from fastmcp.tools.base import ToolResult
|
||||
|
|
@ -58,8 +56,10 @@ __all__ = [
|
|||
]
|
||||
|
||||
# What an extension's tools/call interceptor observes and may produce: the tool
|
||||
# result, or the claimed CreateTaskResult shape when the call is run as a task.
|
||||
ToolCallOutcome: TypeAlias = "ToolResult | mcp_types.CreateTaskResult"
|
||||
# result, or an extension-defined wire result model (a `BaseModel` the runner
|
||||
# serializes) when the call is short-circuited — e.g. the tasks extension's
|
||||
# CreateTaskResult. Core does not interpret the extension's result shape.
|
||||
ToolCallOutcome: TypeAlias = "ToolResult | BaseModel"
|
||||
|
||||
# A method handler receives the SDK request context plus validated params and
|
||||
# returns a bare result model (the runner serializes it).
|
||||
|
|
|
|||
|
|
@ -475,11 +475,10 @@ class LowLevelServer(_Server[LifespanResultT]):
|
|||
*,
|
||||
protocol_version: str | None = None,
|
||||
) -> mcp_types.ServerCapabilities:
|
||||
"""Override to set capabilities.tasks as a first-class field per SEP-1686
|
||||
and advertise the MCP Apps UI extension.
|
||||
"""Override to advertise registered extensions and the MCP Apps UI extension.
|
||||
|
||||
``ServerCapabilities.tasks`` and ``ServerCapabilities.extensions`` are
|
||||
real declared fields in v2, so we update them directly. The
|
||||
``ServerCapabilities.extensions`` is a real declared field in v2, so we
|
||||
update it directly. The
|
||||
`FastMCP(experimental_capabilities=...)` merge also lives here rather
|
||||
than in `create_initialization_options`: the modern `server/discover`
|
||||
handler calls this directly, without going through
|
||||
|
|
@ -487,8 +486,6 @@ class LowLevelServer(_Server[LifespanResultT]):
|
|||
the handshake-era `initialize` response and silently dropped
|
||||
constructor-configured experimental capabilities from `discover`.
|
||||
"""
|
||||
from fastmcp.server.tasks.capabilities import get_task_capabilities
|
||||
|
||||
merged_experimental = {
|
||||
**self.fastmcp.experimental_capabilities,
|
||||
**(experimental_capabilities or {}),
|
||||
|
|
@ -513,7 +510,6 @@ class LowLevelServer(_Server[LifespanResultT]):
|
|||
}
|
||||
return capabilities.model_copy(
|
||||
update={
|
||||
"tasks": get_task_capabilities(),
|
||||
"extensions": {
|
||||
**existing_extensions,
|
||||
UI_EXTENSION_ID: {},
|
||||
|
|
|
|||
|
|
@ -445,6 +445,15 @@ class ResponseCachingMiddleware(Middleware):
|
|||
if isinstance(tool_result, InputRequiredToolResult):
|
||||
return tool_result
|
||||
|
||||
# A task-augmented call returns a CreateTaskResult (the tasks extension)
|
||||
# up through this middleware — an acknowledgement that the work was
|
||||
# enqueued, not a cacheable answer, and without a ToolResult's
|
||||
# content/structured_content. Pass any non-ToolResult straight through
|
||||
# rather than crash wrapping it (the crash would fire after the task is
|
||||
# already enqueued, so a client retry could duplicate side effects).
|
||||
if not isinstance(tool_result, ToolResult):
|
||||
return tool_result
|
||||
|
||||
cacheable_tool_result: CacheableToolResult = CacheableToolResult.wrap(
|
||||
value=tool_result
|
||||
)
|
||||
|
|
|
|||
|
|
@ -118,6 +118,12 @@ class ResponseLimitingMiddleware(Middleware):
|
|||
if isinstance(result, InputRequiredToolResult):
|
||||
return result
|
||||
|
||||
# A task-augmented call returns a CreateTaskResult (the tasks extension)
|
||||
# up through this middleware — a small acknowledgement with no tool
|
||||
# content to measure or truncate. Pass any non-ToolResult through.
|
||||
if not isinstance(result, ToolResult):
|
||||
return result
|
||||
|
||||
# Check if we should limit this tool
|
||||
if self.tools is not None and context.message.name not in self.tools:
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -1,18 +1,16 @@
|
|||
"""Lifespan and Docket task infrastructure for FastMCP Server."""
|
||||
"""Lifespan infrastructure for FastMCP Server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import weakref
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import AsyncExitStack, asynccontextmanager, suppress
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import anyio
|
||||
from uncalled_for import SharedContext
|
||||
|
||||
import fastmcp
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -25,169 +23,64 @@ logger = get_logger(__name__)
|
|||
|
||||
# Set True by `FastMCPProvider.lifespan` immediately before it enters the
|
||||
# wrapped (mounted) server's `_lifespan_manager`, and reset on exit. The
|
||||
# mounted server's `_docket_lifespan` reads this and becomes a no-op so that
|
||||
# Docket / Worker / SharedContext are not re-initialized — there's one set
|
||||
# per runtime tree, owned by the root.
|
||||
# mounted server's `_shared_context_lifespan` reads this and becomes a no-op so
|
||||
# that SharedContext and the server ContextVar are not re-initialized — there's
|
||||
# one set per runtime tree, owned by the root. Extension lifespans (e.g. the
|
||||
# tasks extension's Docket/Worker) defer to the root the same way.
|
||||
#
|
||||
# Independent servers entered as siblings (e.g. via `AsyncExitStack` in the
|
||||
# same async context) are NOT in a parent/child relationship; the flag is not
|
||||
# set in that case, so each independently establishes its own Docket and
|
||||
# server context.
|
||||
# set in that case, so each independently establishes its own server context.
|
||||
_lifespan_root_active: ContextVar[bool] = ContextVar(
|
||||
"fastmcp_lifespan_root_active", default=False
|
||||
)
|
||||
|
||||
|
||||
class LifespanMixin:
|
||||
"""Mixin providing lifespan and Docket task infrastructure for FastMCP."""
|
||||
"""Mixin providing lifespan infrastructure for FastMCP."""
|
||||
|
||||
@property
|
||||
def docket(self: FastMCP) -> Docket | None:
|
||||
"""The Docket instance owned by this server.
|
||||
"""The Docket instance owned by this server, if the tasks extension is active.
|
||||
|
||||
Returns the Docket that this server initialized as the root of a
|
||||
runtime tree. Mounted children do not own their own Docket — they
|
||||
share the root's via ``_current_docket`` ContextVar inheritance —
|
||||
so accessing ``.docket`` on a mounted child returns None even while
|
||||
its tasks run on the root's Docket. For "the Docket in scope right
|
||||
now," prefer reading ``_current_docket`` directly or use the
|
||||
``CurrentDocket`` dependency injection.
|
||||
Returns the Docket that the tasks extension initialized as the root of a
|
||||
runtime tree, or None when no task backend is running. Mounted children do
|
||||
not own their own Docket — they share the root's via ``_current_docket``
|
||||
ContextVar inheritance — so accessing ``.docket`` on a mounted child
|
||||
returns None even while its tasks run on the root's Docket.
|
||||
"""
|
||||
return self._docket
|
||||
|
||||
@asynccontextmanager
|
||||
async def _docket_lifespan(self: FastMCP) -> AsyncIterator[None]:
|
||||
"""Manage Docket instance and Worker for background task execution.
|
||||
async def _shared_context_lifespan(self: FastMCP) -> AsyncIterator[None]:
|
||||
"""Set up the process-level ``SharedContext`` and server ContextVar.
|
||||
|
||||
Docket is process-level, not server-level: only the first server in a
|
||||
runtime tree starts Docket and the Worker. Mounted children entered
|
||||
via ``FastMCPProvider.lifespan`` see ``_lifespan_root_active=True``
|
||||
(set by the provider before delegating to ``_lifespan_manager``) and
|
||||
become no-ops, sharing the root's Docket via ``_current_docket``.
|
||||
``SharedContext`` backs app-scoped ``Shared()`` dependencies and is
|
||||
process-level, not server-level: only the first server in a runtime tree
|
||||
establishes it. Mounted children entered via ``FastMCPProvider.lifespan``
|
||||
see ``_lifespan_root_active=True`` (set by the provider before delegating
|
||||
to ``_lifespan_manager``) and become no-ops, sharing the root's context
|
||||
via ContextVars.
|
||||
|
||||
Independent servers entered as siblings — for example two unrelated
|
||||
``FastMCP`` instances each entered through ``AsyncExitStack`` in the
|
||||
same async context — are not in a parent/child relationship; no
|
||||
provider has set the flag for them, so each runs the full root setup.
|
||||
|
||||
Docket infrastructure is only initialized at the root if:
|
||||
1. pydocket is installed (fastmcp[tasks] extra)
|
||||
2. There are task-enabled components (task_config.mode != 'forbidden')
|
||||
|
||||
Users with pydocket installed but no task-enabled components won't spin
|
||||
up Docket / Worker infrastructure even at the root.
|
||||
``FastMCP`` instances each entered through ``AsyncExitStack`` in the same
|
||||
async context — are not in a parent/child relationship; no provider has
|
||||
set the flag for them, so each runs the full root setup.
|
||||
"""
|
||||
# Nested entry: a parent in this runtime tree already owns Docket and
|
||||
# SharedContext (the FastMCPProvider that mounted us set the flag).
|
||||
# Stay out of their way and inherit via ContextVars.
|
||||
if _lifespan_root_active.get():
|
||||
yield
|
||||
return
|
||||
|
||||
async with self._docket_lifespan_root():
|
||||
yield
|
||||
|
||||
@asynccontextmanager
|
||||
async def _docket_lifespan_root(self: FastMCP) -> AsyncIterator[None]:
|
||||
"""Root-only Docket lifecycle. See _docket_lifespan for the dispatch."""
|
||||
from fastmcp.server.dependencies import _current_server, is_docket_available
|
||||
from fastmcp.server.dependencies import _current_server
|
||||
|
||||
# Set FastMCP server in ContextVar so CurrentFastMCP can access it
|
||||
# (use weakref to avoid reference cycles)
|
||||
server_token = _current_server.set(weakref.ref(self))
|
||||
|
||||
try:
|
||||
# If docket is not available, skip task infrastructure but still
|
||||
# set up SharedContext so Shared() dependencies work.
|
||||
if not is_docket_available():
|
||||
async with SharedContext():
|
||||
self._capture_shared_context()
|
||||
yield
|
||||
return
|
||||
|
||||
# Collect task-enabled components at startup with all transforms applied.
|
||||
# Components must be available now to be registered with Docket workers;
|
||||
# dynamically added components after startup won't be registered.
|
||||
try:
|
||||
task_components = list(await self.get_tasks())
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get tasks: {e}")
|
||||
if fastmcp.settings.mounted_components_raise_on_load_error:
|
||||
raise
|
||||
task_components = []
|
||||
|
||||
# If no task-enabled components, skip Docket infrastructure but still
|
||||
# set up SharedContext so Shared() dependencies work.
|
||||
if not task_components:
|
||||
async with SharedContext():
|
||||
self._capture_shared_context()
|
||||
yield
|
||||
return
|
||||
|
||||
# Docket is available AND there are task-enabled components
|
||||
from docket import Depends, Docket, Worker
|
||||
|
||||
from fastmcp import settings
|
||||
from fastmcp.server.dependencies import (
|
||||
_current_docket,
|
||||
_current_worker,
|
||||
)
|
||||
from fastmcp.server.tasks.context import restore_task_snapshot
|
||||
|
||||
# Create Docket instance using configured name and URL
|
||||
async with (
|
||||
SharedContext(),
|
||||
Docket(
|
||||
name=settings.docket.name,
|
||||
url=settings.docket.url,
|
||||
) as docket,
|
||||
):
|
||||
async with SharedContext():
|
||||
self._capture_shared_context()
|
||||
self._docket = docket
|
||||
|
||||
# Register task-enabled components with Docket
|
||||
for component in task_components:
|
||||
component.register_with_docket(docket)
|
||||
|
||||
docket_token = _current_docket.set(docket)
|
||||
try:
|
||||
# Build worker kwargs from settings
|
||||
worker_kwargs: dict[str, Any] = {
|
||||
"concurrency": settings.docket.concurrency,
|
||||
"redelivery_timeout": settings.docket.redelivery_timeout,
|
||||
"reconnection_delay": settings.docket.reconnection_delay,
|
||||
"minimum_check_interval": settings.docket.minimum_check_interval,
|
||||
}
|
||||
if settings.docket.worker_name:
|
||||
worker_kwargs["name"] = settings.docket.worker_name
|
||||
|
||||
# Create and start Worker. The restore_task_snapshot
|
||||
# worker-level dependency runs before every task so the
|
||||
# per-task snapshot ContextVar is populated before user
|
||||
# code or task-scoped dependencies observe it.
|
||||
async with Worker(
|
||||
docket,
|
||||
dependencies=[Depends(restore_task_snapshot)],
|
||||
**worker_kwargs,
|
||||
) as worker:
|
||||
self._worker = worker
|
||||
worker_token = _current_worker.set(worker)
|
||||
try:
|
||||
worker_task = asyncio.create_task(worker.run_forever())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
worker_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await worker_task
|
||||
finally:
|
||||
_current_worker.reset(worker_token)
|
||||
self._worker = None
|
||||
finally:
|
||||
_current_docket.reset(docket_token)
|
||||
self._docket = None
|
||||
yield
|
||||
finally:
|
||||
# Reset server ContextVar
|
||||
_current_server.reset(server_token)
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
@ -196,11 +89,10 @@ class LifespanMixin:
|
|||
|
||||
Extension lifespans are entered once per runtime tree, at the root. A
|
||||
mounted child sees ``_lifespan_root_active`` set by its
|
||||
``FastMCPProvider`` and defers to the root, exactly as
|
||||
``_docket_lifespan`` does for the shared Docket: an extension whose
|
||||
lifespan starts shared infrastructure (a task-queue backend and worker,
|
||||
say) is therefore owned by the tree root, and mounted children reach it
|
||||
through the same context rather than starting a second copy.
|
||||
``FastMCPProvider`` and defers to the root: an extension whose lifespan
|
||||
starts shared infrastructure (a task-queue backend and worker, say) is
|
||||
therefore owned by the tree root, and mounted children reach it through
|
||||
the same context rather than starting a second copy.
|
||||
|
||||
Extensions are entered in registration order; the ``AsyncExitStack``
|
||||
exits them in reverse on teardown.
|
||||
|
|
@ -214,6 +106,46 @@ class LifespanMixin:
|
|||
await stack.enter_async_context(extension.lifespan())
|
||||
yield
|
||||
|
||||
async def _validate_task_extension_registered(self: FastMCP) -> None:
|
||||
"""Fail loudly if a task-enabled tool has no tasks extension registered.
|
||||
|
||||
`task=True` on a tool is only an intent declaration; the engine that runs
|
||||
it lives in the `fastmcp-tasks` package and is installed by registering a
|
||||
`ServerExtension` whose identifier is `TASKS_EXTENSION_ID`
|
||||
(`mcp.add_extension(...)`). A task-configured tool serving without that
|
||||
extension would silently never run as a task — a correctness bug — so we
|
||||
raise at serve time instead.
|
||||
"""
|
||||
from fastmcp.utilities.tasks import TASKS_EXTENSION_ID
|
||||
|
||||
# A mounted child defers to the root, which owns the extension and whose
|
||||
# aggregated get_tasks() already covers this child's task tools — the
|
||||
# same root-deferral the extension lifespan uses. Validating here would
|
||||
# fail a child that legitimately relies on the root's registration.
|
||||
if _lifespan_root_active.get():
|
||||
return
|
||||
|
||||
if TASKS_EXTENSION_ID in self._extensions:
|
||||
return
|
||||
|
||||
candidates = list(await self.get_tasks())
|
||||
|
||||
# ``get_tasks()`` applies server-level transforms, which can inject
|
||||
# non-task tools (e.g. ResourcesAsTools' synthetic list/read tools) into
|
||||
# the result, so re-filter by the actual task config here — mirroring the
|
||||
# guard the old per-component docket registration applied.
|
||||
task_components = [c for c in candidates if c.task_config.supports_tasks()]
|
||||
if not task_components:
|
||||
return
|
||||
|
||||
names = ", ".join(sorted(c.name for c in task_components))
|
||||
raise RuntimeError(
|
||||
f"Task-enabled tools ({names}) require the tasks extension, but no "
|
||||
f"extension with identifier {TASKS_EXTENSION_ID!r} is registered. "
|
||||
"Install it with `pip install 'fastmcp[tasks]'` and register it via "
|
||||
"`mcp.add_extension(TasksExtension(...))`."
|
||||
)
|
||||
|
||||
def _capture_shared_context(self: FastMCP) -> None:
|
||||
"""Snapshot the live ``SharedContext`` ContextVar values.
|
||||
|
||||
|
|
@ -261,7 +193,7 @@ class LifespanMixin:
|
|||
stack = AsyncExitStack()
|
||||
try:
|
||||
user_lifespan_result = await stack.enter_async_context(self._lifespan(self))
|
||||
await stack.enter_async_context(self._docket_lifespan())
|
||||
await stack.enter_async_context(self._shared_context_lifespan())
|
||||
await stack.enter_async_context(self._extensions_lifespan())
|
||||
|
||||
self._lifespan_result = user_lifespan_result
|
||||
|
|
@ -271,6 +203,8 @@ class LifespanMixin:
|
|||
for provider in self.providers:
|
||||
await stack.enter_async_context(provider.lifespan())
|
||||
|
||||
await self._validate_task_extension_registered()
|
||||
|
||||
self._started.set()
|
||||
try:
|
||||
yield
|
||||
|
|
@ -286,74 +220,3 @@ class LifespanMixin:
|
|||
if self._lifespan_ref_count == 0:
|
||||
self._lifespan_result_set = False
|
||||
self._lifespan_result = None
|
||||
|
||||
def _setup_task_protocol_handlers(self: FastMCP) -> None:
|
||||
"""Register SEP-1686 task protocol handlers with SDK.
|
||||
|
||||
Only registers handlers if docket is installed. Without docket,
|
||||
task protocol requests will return "method not found" errors.
|
||||
"""
|
||||
from fastmcp.server.dependencies import is_docket_available
|
||||
|
||||
if not is_docket_available():
|
||||
return
|
||||
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp_types import (
|
||||
CancelTaskRequestParams,
|
||||
GetTaskPayloadRequestParams,
|
||||
GetTaskRequestParams,
|
||||
PaginatedRequestParams,
|
||||
)
|
||||
|
||||
from fastmcp.server.dependencies import bind_request_context
|
||||
from fastmcp.server.tasks.requests import (
|
||||
tasks_cancel_handler,
|
||||
tasks_get_handler,
|
||||
tasks_list_handler,
|
||||
tasks_result_handler,
|
||||
)
|
||||
|
||||
# v2 handlers take (ctx, params) and return the bare result model.
|
||||
|
||||
async def handle_get_task(
|
||||
ctx: ServerRequestContext, params: GetTaskRequestParams
|
||||
) -> Any:
|
||||
with bind_request_context(ctx):
|
||||
p = params.model_dump(by_alias=True, exclude_none=True)
|
||||
return await tasks_get_handler(self, p)
|
||||
|
||||
async def handle_get_task_result(
|
||||
ctx: ServerRequestContext, params: GetTaskPayloadRequestParams
|
||||
) -> Any:
|
||||
with bind_request_context(ctx):
|
||||
p = params.model_dump(by_alias=True, exclude_none=True)
|
||||
return await tasks_result_handler(self, p)
|
||||
|
||||
async def handle_list_tasks(
|
||||
ctx: ServerRequestContext, params: PaginatedRequestParams | None
|
||||
) -> Any:
|
||||
with bind_request_context(ctx):
|
||||
p = (
|
||||
params.model_dump(by_alias=True, exclude_none=True)
|
||||
if params
|
||||
else {}
|
||||
)
|
||||
return await tasks_list_handler(self, p)
|
||||
|
||||
async def handle_cancel_task(
|
||||
ctx: ServerRequestContext, params: CancelTaskRequestParams
|
||||
) -> Any:
|
||||
with bind_request_context(ctx):
|
||||
p = params.model_dump(by_alias=True, exclude_none=True)
|
||||
return await tasks_cancel_handler(self, p)
|
||||
|
||||
s = self._mcp_server
|
||||
s.add_request_handler("tasks/get", GetTaskRequestParams, handle_get_task)
|
||||
s.add_request_handler(
|
||||
"tasks/result", GetTaskPayloadRequestParams, handle_get_task_result
|
||||
)
|
||||
s.add_request_handler("tasks/list", PaginatedRequestParams, handle_list_tasks)
|
||||
s.add_request_handler(
|
||||
"tasks/cancel", CancelTaskRequestParams, handle_cancel_task
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from mcp_types import (
|
|||
SetLevelRequestParams,
|
||||
)
|
||||
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fastmcp.exceptions import (
|
||||
DisabledError,
|
||||
|
|
@ -29,8 +30,7 @@ from fastmcp.exceptions import (
|
|||
)
|
||||
from fastmcp.server.completions import CompletionValues, normalize_completion
|
||||
from fastmcp.server.dependencies import bind_request_context, extract_version_spec
|
||||
from fastmcp.server.tasks.config import TaskMeta
|
||||
from fastmcp.tools.base import InputRequiredToolResult
|
||||
from fastmcp.tools.base import InputRequiredToolResult, ToolResult
|
||||
from fastmcp.utilities.async_utils import (
|
||||
call_sync_fn_in_threadpool,
|
||||
is_coroutine_function,
|
||||
|
|
@ -127,9 +127,6 @@ class MCPOperationsMixin:
|
|||
"logging/setLevel", SetLevelRequestParams, self._on_set_logging_level
|
||||
)
|
||||
|
||||
# Register SEP-1686 task protocol handlers
|
||||
self._setup_task_protocol_handlers()
|
||||
|
||||
async def _on_list_tools(
|
||||
self: FastMCP,
|
||||
ctx: ServerRequestContext,
|
||||
|
|
@ -220,17 +217,9 @@ class MCPOperationsMixin:
|
|||
self: FastMCP,
|
||||
ctx: ServerRequestContext,
|
||||
params: CallToolRequestParams,
|
||||
) -> (
|
||||
mcp_types.CallToolResult
|
||||
| mcp_types.InputRequiredResult
|
||||
| mcp_types.CreateTaskResult
|
||||
):
|
||||
) -> mcp_types.CallToolResult | mcp_types.InputRequiredResult | BaseModel:
|
||||
"""Handle MCP 'tools/call' requests.
|
||||
|
||||
Task metadata is a first-class params field (``params.task``); its
|
||||
presence triggers backgrounding. The tool's ``_run()`` handles the
|
||||
backgrounding decision so middleware runs before Docket.
|
||||
|
||||
A guard tool (SEP-2322 multi-round-trip) requests client input by
|
||||
returning an ``InputRequiredResult`` from its body; the run machinery
|
||||
wraps that in an ``InputRequiredToolResult`` (a ``ToolResult``
|
||||
|
|
@ -250,14 +239,9 @@ class MCPOperationsMixin:
|
|||
)
|
||||
|
||||
version = _version_from_ctx(ctx)
|
||||
task_meta = (
|
||||
TaskMeta(ttl=params.task.ttl) if params.task is not None else None
|
||||
)
|
||||
|
||||
try:
|
||||
result = await self.call_tool(
|
||||
key, arguments, version=version, task_meta=task_meta
|
||||
)
|
||||
result = await self.call_tool(key, arguments, version=version)
|
||||
except (DisabledError, NotFoundError):
|
||||
# Unknown/disabled tool: return an error result (matching the
|
||||
# v1 SDK's call_tool behavior) so the client surfaces a
|
||||
|
|
@ -280,8 +264,14 @@ class MCPOperationsMixin:
|
|||
is_error=True,
|
||||
)
|
||||
|
||||
if isinstance(result, mcp_types.CreateTaskResult):
|
||||
if not isinstance(result, ToolResult):
|
||||
# An extension's tools/call interceptor produced a non-ToolResult
|
||||
# wire result — the tasks extension's CreateTaskResult when it ran
|
||||
# the call as a task. Core does not interpret extension result
|
||||
# shapes; hand it straight to the runner, which serializes it for
|
||||
# the negotiated protocol version.
|
||||
return result
|
||||
|
||||
if isinstance(result, InputRequiredToolResult):
|
||||
# A guard tool requested client input (SEP-2322). The
|
||||
# multi-round-trip result type only exists at 2026-07-28; on an
|
||||
|
|
@ -305,14 +295,8 @@ class MCPOperationsMixin:
|
|||
self: FastMCP,
|
||||
ctx: ServerRequestContext,
|
||||
params: ReadResourceRequestParams,
|
||||
) -> mcp_types.ReadResourceResult | mcp_types.CreateTaskResult:
|
||||
"""Handle MCP 'resources/read' requests.
|
||||
|
||||
Note: ``ReadResourceRequestParams`` has no ``task`` field in this SDK
|
||||
version, so resource task submission over the wire is not expressible;
|
||||
``task_meta`` is always None here. The CreateTaskResult return branch is
|
||||
retained harmlessly pending an upstream ``task`` field on these params.
|
||||
"""
|
||||
) -> mcp_types.ReadResourceResult:
|
||||
"""Handle MCP 'resources/read' requests."""
|
||||
with bind_request_context(ctx):
|
||||
uri = params.uri
|
||||
logger.debug(f"[{self.name}] Handler called: read_resource %s", uri)
|
||||
|
|
@ -336,21 +320,14 @@ class MCPOperationsMixin:
|
|||
# already happened inside read_resource.
|
||||
raise to_mcp_error(e) from e
|
||||
|
||||
if isinstance(result, mcp_types.CreateTaskResult):
|
||||
return result
|
||||
return result.to_mcp_result(uri)
|
||||
|
||||
async def _on_get_prompt(
|
||||
self: FastMCP,
|
||||
ctx: ServerRequestContext,
|
||||
params: GetPromptRequestParams,
|
||||
) -> mcp_types.GetPromptResult | mcp_types.CreateTaskResult:
|
||||
"""Handle MCP 'prompts/get' requests.
|
||||
|
||||
Note: ``GetPromptRequestParams`` has no ``task`` field in this SDK
|
||||
version, so prompt task submission over the wire is not expressible;
|
||||
``task_meta`` is always None here.
|
||||
"""
|
||||
) -> mcp_types.GetPromptResult:
|
||||
"""Handle MCP 'prompts/get' requests."""
|
||||
with bind_request_context(ctx):
|
||||
name = params.name
|
||||
arguments = params.arguments
|
||||
|
|
@ -374,8 +351,6 @@ class MCPOperationsMixin:
|
|||
# Masking already happened inside render_prompt.
|
||||
raise to_mcp_error(e) from e
|
||||
|
||||
if isinstance(result, mcp_types.CreateTaskResult):
|
||||
return result
|
||||
return result.to_mcp_prompt_result()
|
||||
|
||||
async def _on_set_logging_level(
|
||||
|
|
|
|||
|
|
@ -12,25 +12,20 @@ from __future__ import annotations
|
|||
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Any, overload
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import mcp_types
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from fastmcp.prompts.base import Prompt, PromptResult
|
||||
from fastmcp.resources.base import Resource, ResourceResult
|
||||
from fastmcp.resources.template import ResourceTemplate, expand_uri_template
|
||||
from fastmcp.server.providers.base import Provider
|
||||
from fastmcp.server.tasks.config import TaskMeta
|
||||
from fastmcp.server.telemetry import delegate_span
|
||||
from fastmcp.tools.base import Tool, ToolResult
|
||||
from fastmcp.utilities.components import FastMCPComponent
|
||||
from fastmcp.utilities.versions import VersionSpec
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docket import Docket
|
||||
from docket.execution import Execution
|
||||
|
||||
from fastmcp.server.server import FastMCP
|
||||
|
||||
|
||||
|
|
@ -80,32 +75,13 @@ class FastMCPProviderTool(Tool):
|
|||
icons=tool.icons,
|
||||
)
|
||||
|
||||
@overload
|
||||
async def _run(
|
||||
self,
|
||||
arguments: dict[str, Any],
|
||||
task_meta: None = None,
|
||||
) -> ToolResult: ...
|
||||
async def _run(self, arguments: dict[str, Any]) -> ToolResult:
|
||||
"""Delegate to the child server's call_tool().
|
||||
|
||||
@overload
|
||||
async def _run(
|
||||
self,
|
||||
arguments: dict[str, Any],
|
||||
task_meta: TaskMeta,
|
||||
) -> mcp_types.CreateTaskResult: ...
|
||||
|
||||
async def _run(
|
||||
self,
|
||||
arguments: dict[str, Any],
|
||||
task_meta: TaskMeta | None = None,
|
||||
) -> ToolResult | mcp_types.CreateTaskResult:
|
||||
"""Delegate to child server's call_tool() with task_meta.
|
||||
|
||||
Passes task_meta through to the child server so it can handle
|
||||
backgrounding appropriately. fn_key is already set by the parent
|
||||
server before calling this method. A child tool that requests client
|
||||
input (SEP-2322) returns an `InputRequiredToolResult`, which forwards
|
||||
through this delegation to the parent's wire handler unchanged.
|
||||
fn_key is already set by the parent server before calling this method. A
|
||||
child tool that requests client input (SEP-2322) returns an
|
||||
`InputRequiredToolResult`, which forwards through this delegation to the
|
||||
parent's wire handler unchanged.
|
||||
"""
|
||||
# Pass exact version so child executes the correct version
|
||||
version = VersionSpec(eq=self.version) if self.version else None
|
||||
|
|
@ -120,27 +96,20 @@ class FastMCPProviderTool(Tool):
|
|||
self._original_name,
|
||||
arguments,
|
||||
version=version,
|
||||
task_meta=task_meta,
|
||||
)
|
||||
|
||||
async def run(self, arguments: dict[str, Any]) -> ToolResult:
|
||||
"""Delegate to child server's call_tool() without task_meta.
|
||||
"""Delegate to the child server's call_tool().
|
||||
|
||||
This is called when the tool is used within a TransformedTool
|
||||
forwarding function or other contexts where task_meta is not available.
|
||||
forwarding function or other contexts.
|
||||
"""
|
||||
# Pass exact version so child executes the correct version
|
||||
version = VersionSpec(eq=self.version) if self.version else None
|
||||
|
||||
result = await self._server.call_tool(
|
||||
return await self._server.call_tool(
|
||||
self._original_name, arguments, version=version
|
||||
)
|
||||
# Result from call_tool should always be ToolResult when no task_meta.
|
||||
if isinstance(result, mcp_types.CreateTaskResult):
|
||||
raise RuntimeError(
|
||||
"Unexpected CreateTaskResult from call_tool without task_meta"
|
||||
)
|
||||
return result
|
||||
|
||||
def get_span_attributes(self) -> dict[str, Any]:
|
||||
return super().get_span_attributes() | {
|
||||
|
|
@ -188,20 +157,10 @@ class FastMCPProviderResource(Resource):
|
|||
icons=resource.icons,
|
||||
)
|
||||
|
||||
@overload
|
||||
async def _read(self, task_meta: None = None) -> ResourceResult: ...
|
||||
async def _read(self) -> ResourceResult:
|
||||
"""Delegate to the child server's read_resource().
|
||||
|
||||
@overload
|
||||
async def _read(self, task_meta: TaskMeta) -> mcp_types.CreateTaskResult: ...
|
||||
|
||||
async def _read(
|
||||
self, task_meta: TaskMeta | None = None
|
||||
) -> ResourceResult | mcp_types.CreateTaskResult:
|
||||
"""Delegate to child server's read_resource() with task_meta.
|
||||
|
||||
Passes task_meta through to the child server so it can handle
|
||||
backgrounding appropriately. fn_key is already set by the parent
|
||||
server before calling this method.
|
||||
fn_key is already set by the parent server before calling this method.
|
||||
"""
|
||||
# Pass exact version so child reads the correct version
|
||||
version = VersionSpec(eq=self.version) if self.version else None
|
||||
|
|
@ -212,9 +171,7 @@ class FastMCPProviderResource(Resource):
|
|||
self._original_uri or "",
|
||||
method="resources/read",
|
||||
):
|
||||
return await self._server.read_resource(
|
||||
self._original_uri, version=version, task_meta=task_meta
|
||||
)
|
||||
return await self._server.read_resource(self._original_uri, version=version)
|
||||
|
||||
def get_span_attributes(self) -> dict[str, Any]:
|
||||
return super().get_span_attributes() | {
|
||||
|
|
@ -260,30 +217,10 @@ class FastMCPProviderPrompt(Prompt):
|
|||
icons=prompt.icons,
|
||||
)
|
||||
|
||||
@overload
|
||||
async def _render(
|
||||
self,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
task_meta: None = None,
|
||||
) -> PromptResult: ...
|
||||
async def _render(self, arguments: dict[str, Any] | None = None) -> PromptResult:
|
||||
"""Delegate to the child server's render_prompt().
|
||||
|
||||
@overload
|
||||
async def _render(
|
||||
self,
|
||||
arguments: dict[str, Any] | None,
|
||||
task_meta: TaskMeta,
|
||||
) -> mcp_types.CreateTaskResult: ...
|
||||
|
||||
async def _render(
|
||||
self,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
task_meta: TaskMeta | None = None,
|
||||
) -> PromptResult | mcp_types.CreateTaskResult:
|
||||
"""Delegate to child server's render_prompt() with task_meta.
|
||||
|
||||
Passes task_meta through to the child server so it can handle
|
||||
backgrounding appropriately. fn_key is already set by the parent
|
||||
server before calling this method.
|
||||
fn_key is already set by the parent server before calling this method.
|
||||
"""
|
||||
# Pass exact version so child renders the correct version
|
||||
version = VersionSpec(eq=self.version) if self.version else None
|
||||
|
|
@ -295,27 +232,21 @@ class FastMCPProviderPrompt(Prompt):
|
|||
method="prompts/get",
|
||||
):
|
||||
return await self._server.render_prompt(
|
||||
self._original_name, arguments, version=version, task_meta=task_meta
|
||||
self._original_name, arguments, version=version
|
||||
)
|
||||
|
||||
async def render(self, arguments: dict[str, Any] | None = None) -> PromptResult:
|
||||
"""Delegate to child server's render_prompt() without task_meta.
|
||||
"""Delegate to the child server's render_prompt().
|
||||
|
||||
This is called when the prompt is used within a transformed context
|
||||
or other contexts where task_meta is not available.
|
||||
or other contexts.
|
||||
"""
|
||||
# Pass exact version so child renders the correct version
|
||||
version = VersionSpec(eq=self.version) if self.version else None
|
||||
|
||||
result = await self._server.render_prompt(
|
||||
return await self._server.render_prompt(
|
||||
self._original_name, arguments, version=version
|
||||
)
|
||||
# Result from render_prompt should always be PromptResult when no task_meta
|
||||
if isinstance(result, mcp_types.CreateTaskResult):
|
||||
raise RuntimeError(
|
||||
"Unexpected CreateTaskResult from render_prompt without task_meta"
|
||||
)
|
||||
return result
|
||||
|
||||
def get_span_attributes(self) -> dict[str, Any]:
|
||||
return super().get_span_attributes() | {
|
||||
|
|
@ -391,24 +322,10 @@ class FastMCPProviderResourceTemplate(ResourceTemplate):
|
|||
icons=self.icons,
|
||||
)
|
||||
|
||||
@overload
|
||||
async def _read(
|
||||
self, uri: str, params: dict[str, Any], task_meta: None = None
|
||||
) -> ResourceResult: ...
|
||||
async def _read(self, uri: str, params: dict[str, Any]) -> ResourceResult:
|
||||
"""Delegate to the child server's read_resource().
|
||||
|
||||
@overload
|
||||
async def _read(
|
||||
self, uri: str, params: dict[str, Any], task_meta: TaskMeta
|
||||
) -> mcp_types.CreateTaskResult: ...
|
||||
|
||||
async def _read(
|
||||
self, uri: str, params: dict[str, Any], task_meta: TaskMeta | None = None
|
||||
) -> ResourceResult | mcp_types.CreateTaskResult:
|
||||
"""Delegate to child server's read_resource() with task_meta.
|
||||
|
||||
Passes task_meta through to the child server so it can handle
|
||||
backgrounding appropriately. fn_key is already set by the parent
|
||||
server before calling this method.
|
||||
fn_key is already set by the parent server before calling this method.
|
||||
"""
|
||||
# Expand the original template with params to get internal URI
|
||||
original_uri = expand_uri_template(self._original_uri_template or "", params)
|
||||
|
|
@ -422,50 +339,7 @@ class FastMCPProviderResourceTemplate(ResourceTemplate):
|
|||
self._original_uri_template or "",
|
||||
method="resources/read",
|
||||
):
|
||||
return await self._server.read_resource(
|
||||
original_uri, version=version, task_meta=task_meta
|
||||
)
|
||||
|
||||
async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult:
|
||||
"""Read the resource content for background task execution.
|
||||
|
||||
Reads the resource via the wrapped server and returns the ResourceResult.
|
||||
This method is called by Docket during background task execution.
|
||||
"""
|
||||
# Expand the original template with arguments to get internal URI
|
||||
original_uri = expand_uri_template(self._original_uri_template or "", arguments)
|
||||
|
||||
# Pass exact version so child reads the correct version
|
||||
version = VersionSpec(eq=self.version) if self.version else None
|
||||
|
||||
# Read from the wrapped server
|
||||
result = await self._server.read_resource(original_uri, version=version)
|
||||
if isinstance(result, mcp_types.CreateTaskResult):
|
||||
raise RuntimeError("Unexpected CreateTaskResult during Docket execution")
|
||||
|
||||
return result
|
||||
|
||||
def register_with_docket(self, docket: Docket) -> None:
|
||||
"""No-op: the child's actual template is registered via get_tasks()."""
|
||||
|
||||
async def add_to_docket(
|
||||
self,
|
||||
docket: Docket,
|
||||
params: dict[str, Any],
|
||||
*,
|
||||
fn_key: str | None = None,
|
||||
task_key: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Execution:
|
||||
"""Schedule this template for background execution via docket.
|
||||
|
||||
The child's FunctionResourceTemplate.fn is registered (via get_tasks),
|
||||
and it expects splatted **kwargs, so we splat params here.
|
||||
"""
|
||||
lookup_key = fn_key or self.key
|
||||
if task_key:
|
||||
kwargs["key"] = task_key
|
||||
return await docket.add(lookup_key, **kwargs)(**params)
|
||||
return await self._server.read_resource(original_uri, version=version)
|
||||
|
||||
def get_span_attributes(self) -> dict[str, Any]:
|
||||
return super().get_span_attributes() | {
|
||||
|
|
|
|||
|
|
@ -349,7 +349,6 @@ def extract_components(module: ModuleType) -> list[FastMCPComponent]:
|
|||
)
|
||||
components.append(tool)
|
||||
elif isinstance(meta, ResourceMeta):
|
||||
resolved_task = meta.task if meta.task is not None else False
|
||||
has_uri_params = "{" in meta.uri and "}" in meta.uri
|
||||
wrapper_fn = without_injected_parameters(obj)
|
||||
has_func_params = bool(inspect.signature(wrapper_fn).parameters)
|
||||
|
|
@ -367,7 +366,6 @@ def extract_components(module: ModuleType) -> list[FastMCPComponent]:
|
|||
tags=meta.tags,
|
||||
annotations=meta.annotations,
|
||||
meta=meta.meta,
|
||||
task=resolved_task,
|
||||
auth=meta.auth,
|
||||
)
|
||||
else:
|
||||
|
|
@ -383,12 +381,10 @@ def extract_components(module: ModuleType) -> list[FastMCPComponent]:
|
|||
tags=meta.tags,
|
||||
annotations=meta.annotations,
|
||||
meta=meta.meta,
|
||||
task=resolved_task,
|
||||
auth=meta.auth,
|
||||
)
|
||||
components.append(resource)
|
||||
elif isinstance(meta, PromptMeta):
|
||||
resolved_task = meta.task if meta.task is not None else False
|
||||
prompt = Prompt.from_function(
|
||||
obj,
|
||||
name=meta.name,
|
||||
|
|
@ -398,7 +394,6 @@ def extract_components(module: ModuleType) -> list[FastMCPComponent]:
|
|||
icons=meta.icons,
|
||||
tags=meta.tags,
|
||||
meta=meta.meta,
|
||||
task=resolved_task,
|
||||
auth=meta.auth,
|
||||
)
|
||||
components.append(prompt)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import mcp_types
|
|||
from fastmcp.prompts.base import Prompt
|
||||
from fastmcp.prompts.function_prompt import FunctionPrompt
|
||||
from fastmcp.server.auth.authorization import AuthCheck
|
||||
from fastmcp.server.tasks.config import TaskConfig
|
||||
from fastmcp.utilities.types import AnyFunction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -45,7 +44,6 @@ class PromptDecoratorMixin:
|
|||
|
||||
meta = get_fastmcp_meta(prompt)
|
||||
if meta is not None and isinstance(meta, PromptMeta):
|
||||
resolved_task = meta.task if meta.task is not None else False
|
||||
enabled = meta.enabled
|
||||
prompt = Prompt.from_function(
|
||||
prompt,
|
||||
|
|
@ -56,7 +54,6 @@ class PromptDecoratorMixin:
|
|||
icons=meta.icons,
|
||||
tags=meta.tags,
|
||||
meta=meta.meta,
|
||||
task=resolved_task,
|
||||
auth=meta.auth,
|
||||
)
|
||||
else:
|
||||
|
|
@ -82,7 +79,6 @@ class PromptDecoratorMixin:
|
|||
tags: set[str] | None = None,
|
||||
enabled: bool = True,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
auth: AuthCheck | list[AuthCheck] | None = None,
|
||||
) -> F: ...
|
||||
|
||||
|
|
@ -99,7 +95,6 @@ class PromptDecoratorMixin:
|
|||
tags: set[str] | None = None,
|
||||
enabled: bool = True,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
auth: AuthCheck | list[AuthCheck] | None = None,
|
||||
) -> Callable[[F], F]: ...
|
||||
|
||||
|
|
@ -115,7 +110,6 @@ class PromptDecoratorMixin:
|
|||
tags: set[str] | None = None,
|
||||
enabled: bool = True,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
auth: AuthCheck | list[AuthCheck] | None = None,
|
||||
) -> (
|
||||
Callable[[AnyFunction], FunctionPrompt]
|
||||
|
|
@ -140,7 +134,6 @@ class PromptDecoratorMixin:
|
|||
tags: Optional set of tags for categorizing the prompt
|
||||
enabled: Whether the prompt is enabled (default True). If False, adds to blocklist.
|
||||
meta: Optional meta information about the prompt
|
||||
task: Optional task configuration for background execution
|
||||
auth: Optional authorization checks for the prompt
|
||||
|
||||
Returns:
|
||||
|
|
@ -198,7 +191,6 @@ class PromptDecoratorMixin:
|
|||
icons=icons,
|
||||
tags=tags,
|
||||
meta=meta,
|
||||
task=task,
|
||||
auth=auth,
|
||||
enabled=enabled,
|
||||
)
|
||||
|
|
@ -232,6 +224,5 @@ class PromptDecoratorMixin:
|
|||
tags=tags,
|
||||
meta=meta,
|
||||
enabled=enabled,
|
||||
task=task,
|
||||
auth=auth,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ from fastmcp.resources.security import (
|
|||
)
|
||||
from fastmcp.resources.template import ResourceTemplate
|
||||
from fastmcp.server.auth.authorization import AuthCheck
|
||||
from fastmcp.server.tasks.config import TaskConfig
|
||||
from fastmcp.utilities.types import AnyFunction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -54,7 +53,6 @@ class ResourceDecoratorMixin:
|
|||
|
||||
meta = get_fastmcp_meta(resource)
|
||||
if meta is not None and isinstance(meta, ResourceMeta):
|
||||
resolved_task = meta.task if meta.task is not None else False
|
||||
enabled = meta.enabled
|
||||
has_uri_params = "{" in meta.uri and "}" in meta.uri
|
||||
wrapper_fn = without_injected_parameters(resource)
|
||||
|
|
@ -73,7 +71,6 @@ class ResourceDecoratorMixin:
|
|||
tags=meta.tags,
|
||||
annotations=meta.annotations,
|
||||
meta=meta.meta,
|
||||
task=resolved_task,
|
||||
auth=meta.auth,
|
||||
security=meta.security,
|
||||
)
|
||||
|
|
@ -90,7 +87,6 @@ class ResourceDecoratorMixin:
|
|||
tags=meta.tags,
|
||||
annotations=meta.annotations,
|
||||
meta=meta.meta,
|
||||
task=resolved_task,
|
||||
auth=meta.auth,
|
||||
)
|
||||
else:
|
||||
|
|
@ -123,7 +119,6 @@ class ResourceDecoratorMixin:
|
|||
enabled: bool = True,
|
||||
annotations: Annotations | dict[str, Any] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
auth: AuthCheck | list[AuthCheck] | None = None,
|
||||
security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY,
|
||||
) -> Callable[[F], F]:
|
||||
|
|
@ -143,7 +138,6 @@ class ResourceDecoratorMixin:
|
|||
enabled: Whether the resource is enabled (default True). If False, adds to blocklist.
|
||||
annotations: Optional annotations about the resource's behavior
|
||||
meta: Optional meta information about the resource
|
||||
task: Optional task configuration for background execution
|
||||
auth: Optional authorization checks for the resource
|
||||
|
||||
Returns:
|
||||
|
|
@ -206,7 +200,6 @@ class ResourceDecoratorMixin:
|
|||
mime_type=mime_type,
|
||||
annotations=annotations,
|
||||
meta=meta,
|
||||
task=task,
|
||||
auth=auth,
|
||||
enabled=enabled,
|
||||
security=security,
|
||||
|
|
|
|||
|
|
@ -26,9 +26,9 @@ import mcp_types
|
|||
from mcp_types import ToolAnnotations
|
||||
|
||||
from fastmcp.server.auth.authorization import AuthCheck
|
||||
from fastmcp.server.tasks.config import TaskConfig
|
||||
from fastmcp.tools.base import Tool
|
||||
from fastmcp.tools.function_tool import FunctionTool
|
||||
from fastmcp.utilities.tasks import TaskConfig
|
||||
from fastmcp.utilities.types import AnyFunction, NotSet, NotSetT
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ from fastmcp.resources import (
|
|||
ResourceTemplate,
|
||||
)
|
||||
from fastmcp.server.dependencies import get_http_headers
|
||||
from fastmcp.server.tasks.config import TaskConfig
|
||||
from fastmcp.tools.base import Tool, ToolResult
|
||||
from fastmcp.utilities.exceptions import (
|
||||
HTTP_STATUS_ERRORS,
|
||||
|
|
@ -27,6 +26,7 @@ from fastmcp.utilities.exceptions import (
|
|||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.openapi import HTTPRoute
|
||||
from fastmcp.utilities.openapi.director import RequestDirector
|
||||
from fastmcp.utilities.tasks import TaskConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.server import Context
|
||||
|
|
|
|||
|
|
@ -51,11 +51,11 @@ from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
|
|||
from fastmcp.server.providers.aggregate import ProviderErrorStrategy
|
||||
from fastmcp.server.providers.base import Provider
|
||||
from fastmcp.server.server import FastMCP
|
||||
from fastmcp.server.tasks.config import TaskConfig
|
||||
from fastmcp.telemetry import inject_trace_context
|
||||
from fastmcp.tools.base import InputRequiredToolResult, Tool, ToolResult
|
||||
from fastmcp.utilities.components import FastMCPComponent, get_fastmcp_metadata
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.tasks import TaskConfig
|
||||
from fastmcp.utilities.versions import VersionSpec, version_sort_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -98,16 +98,12 @@ class SkillFileTemplate(ResourceTemplate):
|
|||
else:
|
||||
return full_path.read_bytes()
|
||||
|
||||
async def _read( # type: ignore[override]
|
||||
async def _read(
|
||||
self,
|
||||
uri: str,
|
||||
params: dict[str, Any],
|
||||
task_meta: Any = None,
|
||||
) -> ResourceResult: # ty:ignore[invalid-method-override]
|
||||
"""Server entry point - read file directly without creating ephemeral resource.
|
||||
|
||||
Note: task_meta is ignored - this template doesn't support background tasks.
|
||||
"""
|
||||
) -> ResourceResult:
|
||||
"""Server entry point - read file directly without creating ephemeral resource."""
|
||||
# Call read() directly and convert to ResourceResult
|
||||
result = await self.read(arguments=params)
|
||||
return self.convert_result(result)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ from contextlib import (
|
|||
AbstractAsyncContextManager,
|
||||
asynccontextmanager,
|
||||
)
|
||||
from dataclasses import replace
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload
|
||||
|
|
@ -80,7 +79,6 @@ from fastmcp.server.middleware.middleware import (
|
|||
from fastmcp.server.mixins import LifespanMixin, MCPOperationsMixin, TransportMixin
|
||||
from fastmcp.server.providers import LocalProvider, Provider
|
||||
from fastmcp.server.providers.aggregate import AggregateProvider
|
||||
from fastmcp.server.tasks.config import TaskConfig, TaskMeta
|
||||
from fastmcp.server.telemetry import server_span
|
||||
from fastmcp.server.transforms import (
|
||||
ToolTransform,
|
||||
|
|
@ -94,6 +92,7 @@ from fastmcp.tools.tool_transform import ToolTransformConfig
|
|||
from fastmcp.utilities.components import FastMCPComponent, _coerce_version
|
||||
from fastmcp.utilities.exceptions import HTTP_STATUS_ERRORS, TIMEOUT_ERRORS
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.tasks import TaskConfig
|
||||
from fastmcp.utilities.types import AnyFunction, FastMCPBaseModel, NotSet, NotSetT
|
||||
from fastmcp.utilities.versions import (
|
||||
VersionSpec,
|
||||
|
|
@ -655,7 +654,15 @@ class FastMCP(
|
|||
can reach it), its method bindings are wired onto the low-level server,
|
||||
and it is recorded for capability advertisement, interception, and
|
||||
lifespan entry. Registering two extensions with the same identifier is
|
||||
an error.
|
||||
an error, as is registering after the server's lifespan has started —
|
||||
the extension's lifespan could no longer run, leaving it silently
|
||||
half-active.
|
||||
|
||||
Extensions are served by the server they are registered on. A mounted
|
||||
child's extensions do not propagate to the root: the root serves the
|
||||
wire, so only root-registered extensions advertise capabilities and
|
||||
answer methods (matching the lifespan, which also defers to the root).
|
||||
Register extensions on the server you run.
|
||||
"""
|
||||
from fastmcp.server.extensions import (
|
||||
build_method_handler,
|
||||
|
|
@ -670,6 +677,12 @@ class FastMCP(
|
|||
f"An extension with identifier {extension.identifier!r} is "
|
||||
"already registered."
|
||||
)
|
||||
if self._lifespan_result_set:
|
||||
raise RuntimeError(
|
||||
f"Cannot register extension {extension.identifier!r}: the "
|
||||
"server's lifespan has already started, so the extension's "
|
||||
"lifespan would never run. Register extensions before serving."
|
||||
)
|
||||
|
||||
extension._bind(self)
|
||||
for binding in extension.methods():
|
||||
|
|
@ -1316,7 +1329,6 @@ class FastMCP(
|
|||
return None
|
||||
return max(authorized, key=version_sort_key)
|
||||
|
||||
@overload
|
||||
async def call_tool(
|
||||
self,
|
||||
name: str,
|
||||
|
|
@ -1324,29 +1336,7 @@ class FastMCP(
|
|||
*,
|
||||
version: VersionSpec | None = None,
|
||||
run_middleware: bool = True,
|
||||
task_meta: None = None,
|
||||
) -> ToolResult: ...
|
||||
|
||||
@overload
|
||||
async def call_tool(
|
||||
self,
|
||||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
version: VersionSpec | None = None,
|
||||
run_middleware: bool = True,
|
||||
task_meta: TaskMeta,
|
||||
) -> mcp_types.CreateTaskResult: ...
|
||||
|
||||
async def call_tool(
|
||||
self,
|
||||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
version: VersionSpec | None = None,
|
||||
run_middleware: bool = True,
|
||||
task_meta: TaskMeta | None = None,
|
||||
) -> ToolResult | mcp_types.CreateTaskResult:
|
||||
) -> ToolResult:
|
||||
"""Call a tool by name.
|
||||
|
||||
This is the public API for executing tools. By default, middleware is applied.
|
||||
|
|
@ -1357,13 +1347,9 @@ class FastMCP(
|
|||
version: Specific version to call. If None, calls highest version.
|
||||
run_middleware: If True (default), apply the middleware chain.
|
||||
Set to False when called from middleware to avoid re-applying.
|
||||
task_meta: If provided, execute as a background task and return
|
||||
CreateTaskResult. If None (default), execute synchronously and
|
||||
return ToolResult.
|
||||
|
||||
Returns:
|
||||
ToolResult when task_meta is None.
|
||||
CreateTaskResult when task_meta is provided.
|
||||
ToolResult.
|
||||
|
||||
A guard tool that requests client input (SEP-2322 multi-round-trip)
|
||||
returns an ``InputRequiredToolResult`` (a ``ToolResult`` subclass); it
|
||||
|
|
@ -1425,7 +1411,6 @@ class FastMCP(
|
|||
context.message.arguments or {},
|
||||
version=version,
|
||||
run_middleware=False,
|
||||
task_meta=task_meta,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
|
@ -1467,10 +1452,8 @@ class FastMCP(
|
|||
if tool is None:
|
||||
raise NotFoundError(f"Unknown tool: {name!r}")
|
||||
span.set_attributes(tool.get_span_attributes())
|
||||
if task_meta is not None and task_meta.fn_key is None:
|
||||
task_meta = replace(task_meta, fn_key=tool.key)
|
||||
try:
|
||||
return await tool._run(arguments or {}, task_meta=task_meta)
|
||||
return await tool._run(arguments or {})
|
||||
except ValidationError as e:
|
||||
# Argument-validation failure (a bad call). FunctionTool
|
||||
# converts pydantic's call-validation error into fastmcp's
|
||||
|
|
@ -1521,34 +1504,13 @@ class FastMCP(
|
|||
raise ToolError(f"Error calling tool {name!r}") from e
|
||||
raise ToolError(f"Error calling tool {name!r}: {e}") from e
|
||||
|
||||
@overload
|
||||
async def read_resource(
|
||||
self,
|
||||
uri: str,
|
||||
*,
|
||||
version: VersionSpec | None = None,
|
||||
run_middleware: bool = True,
|
||||
task_meta: None = None,
|
||||
) -> ResourceResult: ...
|
||||
|
||||
@overload
|
||||
async def read_resource(
|
||||
self,
|
||||
uri: str,
|
||||
*,
|
||||
version: VersionSpec | None = None,
|
||||
run_middleware: bool = True,
|
||||
task_meta: TaskMeta,
|
||||
) -> mcp_types.CreateTaskResult: ...
|
||||
|
||||
async def read_resource(
|
||||
self,
|
||||
uri: str,
|
||||
*,
|
||||
version: VersionSpec | None = None,
|
||||
run_middleware: bool = True,
|
||||
task_meta: TaskMeta | None = None,
|
||||
) -> ResourceResult | mcp_types.CreateTaskResult:
|
||||
) -> ResourceResult:
|
||||
"""Read a resource by URI.
|
||||
|
||||
This is the public API for reading resources. By default, middleware is applied.
|
||||
|
|
@ -1559,25 +1521,14 @@ class FastMCP(
|
|||
version: Specific version to read. If None, reads highest version.
|
||||
run_middleware: If True (default), apply the middleware chain.
|
||||
Set to False when called from middleware to avoid re-applying.
|
||||
task_meta: If provided, execute as a background task and return
|
||||
CreateTaskResult. If None (default), execute synchronously and
|
||||
return ResourceResult.
|
||||
|
||||
Returns:
|
||||
ResourceResult when task_meta is None.
|
||||
CreateTaskResult when task_meta is provided.
|
||||
ResourceResult.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If resource not found or disabled
|
||||
ResourceError: If resource read fails
|
||||
"""
|
||||
# Note: fn_key enrichment happens here after finding the resource/template.
|
||||
# Resources and templates use different key formats:
|
||||
# - Resources use resource.key (derived from the concrete URI)
|
||||
# - Templates use template.key (the template pattern)
|
||||
# For mounted servers, the parent's provider sets fn_key to the
|
||||
# namespaced key before delegating, ensuring correct Docket routing.
|
||||
|
||||
async with fastmcp.server.context.Context(fastmcp=self) as ctx:
|
||||
if run_middleware:
|
||||
mw_context = MiddlewareContext(
|
||||
|
|
@ -1596,7 +1547,6 @@ class FastMCP(
|
|||
str(context.message.uri),
|
||||
version=version,
|
||||
run_middleware=False,
|
||||
task_meta=task_meta,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -1619,16 +1569,14 @@ class FastMCP(
|
|||
synthesized = await synthesize_prefab_resource_by_uri(self, uri)
|
||||
if synthesized is not None:
|
||||
span.set_attributes(synthesized.get_span_attributes())
|
||||
return await synthesized._read(task_meta=task_meta)
|
||||
return await synthesized._read()
|
||||
|
||||
# Try concrete resources first (transforms + auth via _get_resource)
|
||||
resource = await self.get_resource(uri, version=version)
|
||||
if resource is not None:
|
||||
span.set_attributes(resource.get_span_attributes())
|
||||
if task_meta is not None and task_meta.fn_key is None:
|
||||
task_meta = replace(task_meta, fn_key=resource.key)
|
||||
try:
|
||||
return await resource._read(task_meta=task_meta)
|
||||
return await resource._read()
|
||||
except FastMCPError as e:
|
||||
logger.log(
|
||||
e.log_level,
|
||||
|
|
@ -1692,10 +1640,8 @@ class FastMCP(
|
|||
)
|
||||
raise ResourceSecurityError(f"Unknown resource: {uri!r}")
|
||||
|
||||
if task_meta is not None and task_meta.fn_key is None:
|
||||
task_meta = replace(task_meta, fn_key=template.key)
|
||||
try:
|
||||
return await template._read(uri, params, task_meta=task_meta)
|
||||
return await template._read(uri, params)
|
||||
except FastMCPError as e:
|
||||
logger.log(
|
||||
e.log_level, f"Error reading resource {uri!r}", exc_info=True
|
||||
|
|
@ -1724,7 +1670,6 @@ class FastMCP(
|
|||
raise ResourceError(f"Error reading resource {uri!r}") from e
|
||||
raise ResourceError(f"Error reading resource {uri!r}: {e}") from e
|
||||
|
||||
@overload
|
||||
async def render_prompt(
|
||||
self,
|
||||
name: str,
|
||||
|
|
@ -1732,29 +1677,7 @@ class FastMCP(
|
|||
*,
|
||||
version: VersionSpec | None = None,
|
||||
run_middleware: bool = True,
|
||||
task_meta: None = None,
|
||||
) -> PromptResult: ...
|
||||
|
||||
@overload
|
||||
async def render_prompt(
|
||||
self,
|
||||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
version: VersionSpec | None = None,
|
||||
run_middleware: bool = True,
|
||||
task_meta: TaskMeta,
|
||||
) -> mcp_types.CreateTaskResult: ...
|
||||
|
||||
async def render_prompt(
|
||||
self,
|
||||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
version: VersionSpec | None = None,
|
||||
run_middleware: bool = True,
|
||||
task_meta: TaskMeta | None = None,
|
||||
) -> PromptResult | mcp_types.CreateTaskResult:
|
||||
) -> PromptResult:
|
||||
"""Render a prompt by name.
|
||||
|
||||
This is the public API for rendering prompts. By default, middleware is applied.
|
||||
|
|
@ -1766,13 +1689,9 @@ class FastMCP(
|
|||
version: Specific version to render. If None, renders highest version.
|
||||
run_middleware: If True (default), apply the middleware chain.
|
||||
Set to False when called from middleware to avoid re-applying.
|
||||
task_meta: If provided, execute as a background task and return
|
||||
CreateTaskResult. If None (default), execute synchronously and
|
||||
return PromptResult.
|
||||
|
||||
Returns:
|
||||
PromptResult when task_meta is None.
|
||||
CreateTaskResult when task_meta is provided.
|
||||
PromptResult.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If prompt not found or disabled
|
||||
|
|
@ -1798,7 +1717,6 @@ class FastMCP(
|
|||
context.message.arguments,
|
||||
version=version,
|
||||
run_middleware=False,
|
||||
task_meta=task_meta,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -1816,10 +1734,8 @@ class FastMCP(
|
|||
if prompt is None:
|
||||
raise NotFoundError(f"Unknown prompt: {name!r}")
|
||||
span.set_attributes(prompt.get_span_attributes())
|
||||
if task_meta is not None and task_meta.fn_key is None:
|
||||
task_meta = replace(task_meta, fn_key=prompt.key)
|
||||
try:
|
||||
return await prompt._render(arguments, task_meta=task_meta)
|
||||
return await prompt._render(arguments)
|
||||
except FastMCPError as e:
|
||||
logger.log(
|
||||
e.log_level, f"Error rendering prompt {name!r}", exc_info=True
|
||||
|
|
@ -2025,7 +1941,6 @@ class FastMCP(
|
|||
annotations: Annotations | dict[str, Any] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
app: AppConfig | dict[str, Any] | bool | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
auth: AuthCheck | list[AuthCheck] | None = None,
|
||||
security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY,
|
||||
) -> Callable[[F], F]:
|
||||
|
|
@ -2125,7 +2040,6 @@ class FastMCP(
|
|||
tags=tags,
|
||||
annotations=annotations,
|
||||
meta=meta,
|
||||
task=task if task is not None else self._support_tasks_by_default,
|
||||
auth=auth,
|
||||
security=security,
|
||||
)
|
||||
|
|
@ -2155,7 +2069,6 @@ class FastMCP(
|
|||
icons: list[mcp_types.Icon] | None = None,
|
||||
tags: set[str] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
auth: AuthCheck | list[AuthCheck] | None = None,
|
||||
) -> F: ...
|
||||
|
||||
|
|
@ -2171,7 +2084,6 @@ class FastMCP(
|
|||
icons: list[mcp_types.Icon] | None = None,
|
||||
tags: set[str] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
auth: AuthCheck | list[AuthCheck] | None = None,
|
||||
) -> Callable[[F], F]: ...
|
||||
|
||||
|
|
@ -2186,7 +2098,6 @@ class FastMCP(
|
|||
icons: list[mcp_types.Icon] | None = None,
|
||||
tags: set[str] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
auth: AuthCheck | list[AuthCheck] | None = None,
|
||||
) -> (
|
||||
Callable[[AnyFunction], FunctionPrompt]
|
||||
|
|
@ -2271,7 +2182,6 @@ class FastMCP(
|
|||
icons=icons,
|
||||
tags=tags,
|
||||
meta=meta,
|
||||
task=task if task is not None else self._support_tasks_by_default,
|
||||
auth=auth,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
"""MCP SEP-1686 background tasks support.
|
||||
|
||||
This module implements protocol-level background task execution for MCP servers.
|
||||
"""
|
||||
|
||||
from fastmcp.server.tasks.capabilities import get_task_capabilities
|
||||
from fastmcp.server.tasks.config import TaskConfig, TaskMeta, TaskMode
|
||||
from fastmcp.server.tasks.elicitation import (
|
||||
elicit_for_task,
|
||||
handle_task_input,
|
||||
relay_elicitation,
|
||||
)
|
||||
from fastmcp.server.tasks.keys import (
|
||||
build_task_key,
|
||||
get_client_task_id_from_key,
|
||||
parse_task_key,
|
||||
)
|
||||
from fastmcp.server.tasks.notifications import (
|
||||
ensure_subscriber_running,
|
||||
push_notification,
|
||||
stop_subscriber,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"TaskConfig",
|
||||
"TaskMeta",
|
||||
"TaskMode",
|
||||
"build_task_key",
|
||||
"elicit_for_task",
|
||||
"ensure_subscriber_running",
|
||||
"get_client_task_id_from_key",
|
||||
"get_task_capabilities",
|
||||
"handle_task_input",
|
||||
"parse_task_key",
|
||||
"push_notification",
|
||||
"relay_elicitation",
|
||||
"stop_subscriber",
|
||||
]
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
"""SEP-1686 task capabilities declaration."""
|
||||
|
||||
from mcp_types import (
|
||||
ServerTasksCapability,
|
||||
ServerTasksRequestsCapability,
|
||||
TasksCallCapability,
|
||||
TasksCancelCapability,
|
||||
TasksListCapability,
|
||||
TasksToolsCapability,
|
||||
)
|
||||
|
||||
|
||||
def get_task_capabilities() -> ServerTasksCapability | None:
|
||||
"""Return the SEP-1686 task capabilities.
|
||||
|
||||
Returns task capabilities as a first-class ServerCapabilities field,
|
||||
declaring support for list, cancel, and request operations per SEP-1686.
|
||||
|
||||
Returns None if a compatible pydocket is not installed (no task support).
|
||||
Uses the canonical ``is_docket_available()`` check so that capability
|
||||
advertisement and handler registration stay in sync — otherwise a server
|
||||
with an old transitive pydocket would advertise task support and then
|
||||
return "method not found" when clients invoked it.
|
||||
|
||||
Only tools are advertised as task-capable. In the SDK v2 b1 wire types,
|
||||
``ReadResourceRequestParams`` / ``GetPromptRequestParams`` carry no ``task``
|
||||
field (sdk-feedback #3), so resource/prompt task submissions are not
|
||||
wire-expressible and always graceful-degrade to synchronous execution.
|
||||
Advertising ``prompts``/``resources`` task support would mislead
|
||||
capability-discovering clients into sending task-augmented reads/gets that
|
||||
silently run synchronously. Restore them here once the SDK adds task
|
||||
metadata to those request params.
|
||||
"""
|
||||
# Function-local import to avoid a circular import at module load time:
|
||||
# fastmcp.server.tasks.__init__ pulls in this module, and dependencies
|
||||
# transitively reaches back into fastmcp.server.tasks.keys.
|
||||
from fastmcp.server.dependencies import is_docket_available
|
||||
|
||||
if not is_docket_available():
|
||||
return None
|
||||
|
||||
return ServerTasksCapability(
|
||||
list=TasksListCapability(),
|
||||
cancel=TasksCancelCapability(),
|
||||
requests=ServerTasksRequestsCapability(
|
||||
tools=TasksToolsCapability(call=TasksCallCapability()),
|
||||
),
|
||||
)
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
"""Backward-compatible exports for task configuration primitives."""
|
||||
|
||||
from fastmcp.utilities.tasks import (
|
||||
DEFAULT_POLL_INTERVAL,
|
||||
DEFAULT_POLL_INTERVAL_MS,
|
||||
DEFAULT_TTL_MS,
|
||||
TaskConfig,
|
||||
TaskMeta,
|
||||
TaskMode,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_POLL_INTERVAL",
|
||||
"DEFAULT_POLL_INTERVAL_MS",
|
||||
"DEFAULT_TTL_MS",
|
||||
"TaskConfig",
|
||||
"TaskMeta",
|
||||
"TaskMode",
|
||||
]
|
||||
|
|
@ -1,347 +0,0 @@
|
|||
"""Background task elicitation support (SEP-1686).
|
||||
|
||||
This module provides elicitation capabilities for background tasks running
|
||||
in Docket workers. Unlike regular MCP requests, background tasks don't have
|
||||
an active request context, so elicitation requires special handling:
|
||||
|
||||
1. Set task status to "input_required" via Redis
|
||||
2. Send notifications/tasks/status with elicitation metadata
|
||||
3. Wait for client to send input via tasks/sendInput
|
||||
4. Resume task execution with the provided input
|
||||
|
||||
This uses the public MCP SDK APIs where possible, with minimal use of
|
||||
internal APIs for background task coordination.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import mcp_types
|
||||
from mcp import ServerSession
|
||||
|
||||
from fastmcp.server.tasks.context import get_task_context, get_task_session_id
|
||||
from fastmcp.server.tasks.keys import task_redis_prefix
|
||||
from fastmcp.server.tasks.notifications import push_notification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.server.server import FastMCP
|
||||
|
||||
|
||||
# TTL for elicitation state (1 hour)
|
||||
ELICIT_TTL_SECONDS = 3600
|
||||
|
||||
|
||||
def _elicit_keys(task_scope: str | None, task_id: str) -> tuple[str, str, str]:
|
||||
"""Build (request, response, status) Redis keys for a task's elicitation."""
|
||||
prefix = f"{task_redis_prefix(task_scope)}:{task_id}:elicit"
|
||||
return f"{prefix}:request", f"{prefix}:response", f"{prefix}:status"
|
||||
|
||||
|
||||
async def elicit_for_task(
|
||||
task_id: str,
|
||||
session: ServerSession | None,
|
||||
message: str,
|
||||
schema: dict[str, Any],
|
||||
fastmcp: FastMCP,
|
||||
) -> mcp_types.ElicitResult:
|
||||
"""Send an elicitation request from a background task.
|
||||
|
||||
This function handles the complexity of eliciting user input when running
|
||||
in a Docket worker context where there's no active MCP request.
|
||||
|
||||
Args:
|
||||
task_id: The background task ID
|
||||
session: The MCP ServerSession for this task
|
||||
message: The message to display to the user
|
||||
schema: The JSON schema for the expected response
|
||||
fastmcp: The FastMCP server instance
|
||||
|
||||
Returns:
|
||||
ElicitResult containing the user's response
|
||||
|
||||
Raises:
|
||||
RuntimeError: If Docket is not available
|
||||
MCPError: If the elicitation request fails
|
||||
"""
|
||||
docket = fastmcp._docket
|
||||
if docket is None:
|
||||
raise RuntimeError(
|
||||
"Background task elicitation requires Docket. "
|
||||
"Ensure 'fastmcp[tasks]' is installed and the server has task-enabled components."
|
||||
)
|
||||
|
||||
# Generate a unique request ID for this elicitation
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
task_context = get_task_context()
|
||||
if task_context is not None:
|
||||
task_scope = task_context.task_scope
|
||||
# Prefer the live session's cached ID (always available in-process),
|
||||
# fall back to the snapshot for distributed workers.
|
||||
session_id = (
|
||||
getattr(session, "_fastmcp_state_prefix", None) or get_task_session_id()
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"Cannot determine task scope for elicitation. "
|
||||
"This typically means elicit_for_task() was called outside a Docket worker context."
|
||||
)
|
||||
|
||||
# Store elicitation request in Redis
|
||||
request_key, response_key, status_key = _elicit_keys(task_scope, task_id)
|
||||
|
||||
elicit_request = {
|
||||
"request_id": request_id,
|
||||
"message": message,
|
||||
"schema": schema,
|
||||
}
|
||||
|
||||
async with docket.redis() as redis:
|
||||
# Store the elicitation request
|
||||
await redis.set(
|
||||
docket.key(request_key),
|
||||
json.dumps(elicit_request),
|
||||
ex=ELICIT_TTL_SECONDS,
|
||||
)
|
||||
# Set status to "waiting"
|
||||
await redis.set(
|
||||
docket.key(status_key),
|
||||
"waiting",
|
||||
ex=ELICIT_TTL_SECONDS,
|
||||
)
|
||||
|
||||
# Send task status update notification with input_required status.
|
||||
# Use notifications/tasks/status so typed MCP clients can consume it.
|
||||
#
|
||||
# NOTE: We use the distributed notification queue instead of session.send_notification()
|
||||
# This enables notifications to work when workers run in separate processes
|
||||
# (Azure Web PubSub / Service Bus inspired pattern)
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
notification_dict = {
|
||||
"method": "notifications/tasks/status",
|
||||
"params": {
|
||||
"taskId": task_id,
|
||||
"status": "input_required",
|
||||
"statusMessage": message,
|
||||
"createdAt": timestamp,
|
||||
"lastUpdatedAt": timestamp,
|
||||
"ttl": ELICIT_TTL_SECONDS * 1000,
|
||||
},
|
||||
"_meta": {
|
||||
"io.modelcontextprotocol/related-task": {
|
||||
"taskId": task_id,
|
||||
"status": "input_required",
|
||||
"statusMessage": message,
|
||||
"task_scope": task_scope,
|
||||
"elicitation": {
|
||||
"requestId": request_id,
|
||||
"message": message,
|
||||
"requestedSchema": schema,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
if session_id is None:
|
||||
logger.warning(
|
||||
"No session_id available for task %s, cannot deliver elicitation notification",
|
||||
task_id,
|
||||
)
|
||||
return mcp_types.ElicitResult(action="cancel", content=None)
|
||||
|
||||
try:
|
||||
await push_notification(session_id, notification_dict, docket)
|
||||
except Exception as e:
|
||||
# Fail fast: if notification can't be queued, client won't know to respond
|
||||
# Return cancel immediately rather than waiting for 1-hour timeout
|
||||
logger.warning(
|
||||
"Failed to queue input_required notification for task %s, cancelling elicitation: %s",
|
||||
task_id,
|
||||
e,
|
||||
)
|
||||
# Best-effort cleanup
|
||||
try:
|
||||
async with docket.redis() as redis:
|
||||
await redis.delete(
|
||||
docket.key(request_key),
|
||||
docket.key(status_key),
|
||||
)
|
||||
except Exception:
|
||||
pass # Keys will expire via TTL
|
||||
return mcp_types.ElicitResult(action="cancel", content=None)
|
||||
|
||||
# Wait for response using BLPOP (blocking pop)
|
||||
# This is much more efficient than polling - single Redis round-trip
|
||||
# that blocks until a response is pushed, vs 7,200 round-trips/hour with polling
|
||||
max_wait_seconds = ELICIT_TTL_SECONDS
|
||||
|
||||
try:
|
||||
async with docket.redis() as redis:
|
||||
# BLPOP blocks until an item is pushed to the list or timeout
|
||||
# Returns tuple of (key, value) or None on timeout
|
||||
result = await redis.blpop(
|
||||
[docket.key(response_key)],
|
||||
timeout=max_wait_seconds,
|
||||
)
|
||||
|
||||
if result:
|
||||
# result is (key, value) tuple
|
||||
_key, response_data = result
|
||||
response = json.loads(response_data)
|
||||
|
||||
# Clean up Redis keys
|
||||
await redis.delete(
|
||||
docket.key(request_key),
|
||||
docket.key(status_key),
|
||||
)
|
||||
|
||||
# Convert to ElicitResult
|
||||
return mcp_types.ElicitResult(
|
||||
action=response.get("action", "accept"),
|
||||
content=response.get("content"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"BLPOP failed for task %s elicitation, falling back to cancel: %s",
|
||||
task_id,
|
||||
e,
|
||||
)
|
||||
|
||||
# Timeout or error - treat as cancellation
|
||||
# Best-effort cleanup - if Redis is unavailable, keys will expire via TTL
|
||||
try:
|
||||
async with docket.redis() as redis:
|
||||
await redis.delete(
|
||||
docket.key(request_key),
|
||||
docket.key(response_key),
|
||||
docket.key(status_key),
|
||||
)
|
||||
except Exception as cleanup_error:
|
||||
logger.debug(
|
||||
"Failed to clean up elicitation keys for task %s (will expire via TTL): %s",
|
||||
task_id,
|
||||
cleanup_error,
|
||||
)
|
||||
|
||||
return mcp_types.ElicitResult(action="cancel", content=None)
|
||||
|
||||
|
||||
async def relay_elicitation(
|
||||
session: ServerSession,
|
||||
task_scope: str | None,
|
||||
task_id: str,
|
||||
elicitation: dict[str, Any],
|
||||
fastmcp: FastMCP,
|
||||
) -> None:
|
||||
"""Relay elicitation from a background task worker to the client.
|
||||
|
||||
Called by the notification subscriber when it detects an input_required
|
||||
notification with elicitation metadata. Sends a standard elicitation/create
|
||||
request to the client session, then uses handle_task_input() to push the
|
||||
response to Redis so the blocked worker can resume.
|
||||
|
||||
Args:
|
||||
session: MCP ServerSession
|
||||
task_scope: Authorization scope for Redis key construction
|
||||
task_id: Background task ID
|
||||
elicitation: Elicitation metadata (message, requestedSchema)
|
||||
fastmcp: FastMCP server instance
|
||||
"""
|
||||
try:
|
||||
result = await session.elicit(
|
||||
message=elicitation["message"],
|
||||
requested_schema=elicitation["requestedSchema"],
|
||||
)
|
||||
await handle_task_input(
|
||||
task_id=task_id,
|
||||
task_scope=task_scope,
|
||||
action=result.action,
|
||||
content=result.content,
|
||||
fastmcp=fastmcp,
|
||||
)
|
||||
logger.debug(
|
||||
"Relayed elicitation response for task %s (action=%s)",
|
||||
task_id,
|
||||
result.action,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to relay elicitation for task %s: %s", task_id, e)
|
||||
# Push a cancel response so the worker's BLPOP doesn't block forever
|
||||
success = await handle_task_input(
|
||||
task_id=task_id,
|
||||
task_scope=task_scope,
|
||||
action="cancel",
|
||||
content=None,
|
||||
fastmcp=fastmcp,
|
||||
)
|
||||
if not success:
|
||||
logger.warning(
|
||||
"Failed to push cancel response for task %s "
|
||||
"(worker may block until TTL)",
|
||||
task_id,
|
||||
)
|
||||
|
||||
|
||||
async def handle_task_input(
|
||||
task_id: str,
|
||||
task_scope: str | None,
|
||||
action: str,
|
||||
content: dict[str, Any] | None,
|
||||
fastmcp: FastMCP,
|
||||
) -> bool:
|
||||
"""Handle input sent to a background task via tasks/sendInput.
|
||||
|
||||
This is called when a client sends input in response to an elicitation
|
||||
request from a background task.
|
||||
|
||||
Args:
|
||||
task_id: The background task ID
|
||||
task_scope: Authorization scope for Redis key construction
|
||||
action: The elicitation action ("accept", "decline", "cancel")
|
||||
content: The response content (for "accept" action)
|
||||
fastmcp: The FastMCP server instance
|
||||
|
||||
Returns:
|
||||
True if the input was successfully stored, False otherwise
|
||||
"""
|
||||
docket = fastmcp._docket
|
||||
if docket is None:
|
||||
return False
|
||||
|
||||
_, response_key, status_key = _elicit_keys(task_scope, task_id)
|
||||
|
||||
response = {
|
||||
"action": action,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
async with docket.redis() as redis:
|
||||
# Check if there's a pending elicitation
|
||||
status = await redis.get(docket.key(status_key))
|
||||
if status is None or status.decode("utf-8") != "waiting":
|
||||
return False
|
||||
|
||||
# Push response to list - this wakes up the BLPOP in elicit_for_task
|
||||
# Using LPUSH instead of SET enables the efficient blocking wait pattern
|
||||
await redis.lpush(
|
||||
docket.key(response_key),
|
||||
json.dumps(response),
|
||||
)
|
||||
# Set TTL on the response list (in case BLPOP doesn't consume it)
|
||||
await redis.expire(docket.key(response_key), ELICIT_TTL_SECONDS)
|
||||
|
||||
# Update status to "responded"
|
||||
await redis.set(
|
||||
docket.key(status_key),
|
||||
"responded",
|
||||
ex=ELICIT_TTL_SECONDS,
|
||||
)
|
||||
|
||||
return True
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
"""SEP-1686 task execution handlers.
|
||||
|
||||
Handles queuing tool/prompt/resource executions to Docket as background tasks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import mcp_types
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp_types import INTERNAL_ERROR
|
||||
|
||||
from fastmcp.server.dependencies import (
|
||||
_current_docket,
|
||||
get_context,
|
||||
)
|
||||
from fastmcp.server.tasks.config import TaskMeta
|
||||
from fastmcp.server.tasks.context import (
|
||||
TaskContextSnapshot,
|
||||
get_task_scope,
|
||||
register_task_server,
|
||||
register_task_session,
|
||||
)
|
||||
from fastmcp.server.tasks.keys import build_task_key, task_redis_prefix
|
||||
from fastmcp.tools.function_tool import _strict_input_validation
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.prompts.base import Prompt
|
||||
from fastmcp.resources.base import Resource
|
||||
from fastmcp.resources.template import ResourceTemplate
|
||||
from fastmcp.tools.base import Tool
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Redis mapping TTL buffer: Add 15 minutes to Docket's execution_ttl
|
||||
TASK_MAPPING_TTL_BUFFER_SECONDS = 15 * 60
|
||||
|
||||
|
||||
async def submit_to_docket(
|
||||
task_type: Literal["tool", "resource", "template", "prompt"],
|
||||
key: str,
|
||||
component: Tool | Resource | ResourceTemplate | Prompt,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
task_meta: TaskMeta | None = None,
|
||||
) -> mcp_types.CreateTaskResult:
|
||||
"""Submit any component to Docket for background execution (SEP-1686).
|
||||
|
||||
Unified handler for all component types. Called by component's internal
|
||||
methods (_run, _read, _render) when task metadata is present and mode allows.
|
||||
|
||||
Queues the component's method to Docket, stores raw return values,
|
||||
and converts to MCP types on retrieval.
|
||||
|
||||
Args:
|
||||
task_type: Component type for task key construction
|
||||
key: The component key as seen by MCP layer (with namespace prefix)
|
||||
component: The component instance (Tool, Resource, ResourceTemplate, Prompt)
|
||||
arguments: Arguments/params (None for Resource which has no args)
|
||||
task_meta: Task execution metadata. If task_meta.ttl is provided, it
|
||||
overrides the server default (docket.execution_ttl).
|
||||
|
||||
Returns:
|
||||
CreateTaskResult: Task stub with proper Task object
|
||||
"""
|
||||
# Validate and coerce arguments before creating any task state. A failure
|
||||
# here must surface before the Redis metadata and initial "working"
|
||||
# notification below are written, otherwise an invalid input would orphan a
|
||||
# task the client has already observed (#4349).
|
||||
#
|
||||
# Honor the server's strict_input_validation setting so a strict tool
|
||||
# rejects lax coercions (e.g. {"n": "1"} for n: int) at submission just as
|
||||
# it does on the synchronous call path — otherwise task=True would bypass
|
||||
# strict validation entirely.
|
||||
if arguments is not None:
|
||||
arguments = component.coerce_task_arguments(
|
||||
arguments, strict=_strict_input_validation()
|
||||
)
|
||||
|
||||
# Generate server-side task ID per SEP-1686 final spec (line 375-377)
|
||||
# Server MUST generate task IDs, clients no longer provide them
|
||||
server_task_id = str(uuid.uuid4())
|
||||
|
||||
# Record creation timestamp per SEP-1686 final spec (line 430). SDK v2
|
||||
# types `Task.created_at` / `TaskStatusNotificationParams.created_at` as ISO
|
||||
# strings, so carry a serialized copy for wire-crossing models.
|
||||
created_at = datetime.now(timezone.utc)
|
||||
created_at_iso = created_at.isoformat()
|
||||
|
||||
ctx = get_context()
|
||||
|
||||
# Authorization scope for task isolation (auth identity, or None for anonymous)
|
||||
task_scope = get_task_scope()
|
||||
|
||||
# Transport session ID for notification delivery
|
||||
try:
|
||||
session_id = ctx.session_id
|
||||
except RuntimeError:
|
||||
session_id = None
|
||||
|
||||
# Try the server's own Docket first; fall back to the ContextVar for
|
||||
# mounted children (whose parent server owns the Docket instance).
|
||||
docket = ctx.fastmcp._docket or _current_docket.get()
|
||||
if docket is None:
|
||||
raise MCPError(
|
||||
code=INTERNAL_ERROR,
|
||||
message="Background tasks require a running FastMCP server context",
|
||||
)
|
||||
|
||||
# Register the current server so background workers resolve
|
||||
# CurrentFastMCP() / ctx.fastmcp to the correct (child) server
|
||||
# for mounted tasks. At this point ctx.fastmcp is the child because
|
||||
# we're inside the child's call_tool dispatch.
|
||||
register_task_server(server_task_id, ctx.fastmcp)
|
||||
|
||||
# Build full task key with embedded metadata
|
||||
task_key = build_task_key(task_scope, server_task_id, task_type, key)
|
||||
|
||||
# Determine TTL: use task_meta.ttl if provided, else docket default
|
||||
if task_meta is not None and task_meta.ttl is not None:
|
||||
ttl_ms = task_meta.ttl
|
||||
else:
|
||||
ttl_ms = int(docket.execution_ttl.total_seconds() * 1000)
|
||||
ttl_seconds = int(ttl_ms / 1000) + TASK_MAPPING_TTL_BUFFER_SECONDS
|
||||
|
||||
# Store task metadata in Redis for protocol handlers
|
||||
prefix = task_redis_prefix(task_scope)
|
||||
task_meta_key = docket.key(f"{prefix}:{server_task_id}")
|
||||
created_at_key = docket.key(f"{prefix}:{server_task_id}:created_at")
|
||||
poll_interval_key = docket.key(f"{prefix}:{server_task_id}:poll_interval")
|
||||
poll_interval_ms = int(component.task_config.poll_interval.total_seconds() * 1000)
|
||||
|
||||
# Snapshot all context (access token, headers, origin request ID,
|
||||
# and session_id for notification delivery in background workers)
|
||||
snapshot = TaskContextSnapshot.capture()
|
||||
|
||||
async with docket.redis() as redis:
|
||||
await redis.set(task_meta_key, task_key, ex=ttl_seconds)
|
||||
await redis.set(created_at_key, created_at.isoformat(), ex=ttl_seconds)
|
||||
await redis.set(poll_interval_key, str(poll_interval_ms), ex=ttl_seconds)
|
||||
|
||||
await snapshot.save(docket, task_scope, server_task_id, ttl_seconds)
|
||||
|
||||
# Register session for Context access in background workers (SEP-1686)
|
||||
# This enables elicitation/sampling from background tasks via weakref
|
||||
# Skip when there is no session (programmatic calls without MCP session)
|
||||
if session_id is not None:
|
||||
register_task_session(session_id, ctx.session)
|
||||
|
||||
# Send an initial tasks/status notification before queueing.
|
||||
# This guarantees clients can observe task creation immediately.
|
||||
notification = mcp_types.TaskStatusNotification.model_validate(
|
||||
{
|
||||
"method": "notifications/tasks/status",
|
||||
"params": {
|
||||
"taskId": server_task_id,
|
||||
"status": "working",
|
||||
"statusMessage": "Task submitted",
|
||||
"createdAt": created_at_iso,
|
||||
"lastUpdatedAt": created_at_iso,
|
||||
"ttl": ttl_ms,
|
||||
"pollInterval": poll_interval_ms,
|
||||
},
|
||||
"_meta": {
|
||||
"io.modelcontextprotocol/related-task": {
|
||||
"taskId": server_task_id,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
# SDK v2: `ServerNotification` is a union type, not a wrapper class;
|
||||
# `send_notification` takes the bare notification model directly.
|
||||
with suppress(Exception):
|
||||
# Don't let notification failures break task creation
|
||||
await ctx.session.send_notification(notification) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
|
||||
|
||||
# Queue function to Docket by key (result storage via execution_ttl)
|
||||
# Use component.add_to_docket() which handles calling conventions
|
||||
# `fn_key` is the function lookup key (e.g., "child_multiply")
|
||||
# `task_key` is the task result key (e.g., "fastmcp:task:{task_scope}:{task_id}:tool:child_multiply")
|
||||
# Resources don't take arguments; tools/prompts/templates always pass arguments (even if None/empty)
|
||||
if task_type == "resource":
|
||||
await component.add_to_docket(docket, fn_key=key, task_key=task_key) # type: ignore[call-arg] # ty:ignore[missing-argument]
|
||||
else:
|
||||
await component.add_to_docket(docket, arguments, fn_key=key, task_key=task_key) # type: ignore[call-arg] # ty:ignore[invalid-argument-type, too-many-positional-arguments]
|
||||
|
||||
# Spawn subscription task to send status notifications (SEP-1686 optional feature).
|
||||
# SDK v2 constructs a ServerSession per request and exposes no per-connection
|
||||
# task group, so the subscription runs as a standalone asyncio task that
|
||||
# outlives the submitting request; it is cancelled when the connection closes.
|
||||
# Deferred: subscriptions and notifications depend on docket at import time
|
||||
from fastmcp.server.tasks.subscriptions import subscribe_to_task_updates
|
||||
|
||||
subscription_task = asyncio.create_task(
|
||||
subscribe_to_task_updates(
|
||||
server_task_id,
|
||||
task_key,
|
||||
ctx.session,
|
||||
docket,
|
||||
poll_interval_ms,
|
||||
),
|
||||
name=f"task-subscription-{server_task_id[:8]}",
|
||||
)
|
||||
connection = getattr(ctx.session, "_connection", None)
|
||||
if connection is not None:
|
||||
|
||||
async def _cancel_subscription() -> None:
|
||||
if not subscription_task.done():
|
||||
subscription_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await subscription_task
|
||||
|
||||
connection.exit_stack.push_async_callback(_cancel_subscription)
|
||||
|
||||
# Deferred: notifications depends on docket at import time
|
||||
from fastmcp.server.tasks.notifications import (
|
||||
ensure_subscriber_running,
|
||||
stop_subscriber,
|
||||
)
|
||||
|
||||
if session_id is not None:
|
||||
try:
|
||||
await ensure_subscriber_running(
|
||||
session_id, ctx.session, docket, ctx.fastmcp
|
||||
)
|
||||
|
||||
# Register cleanup callback on connection exit (once per session).
|
||||
# SDK v2 constructs ServerSession per request, so the stable
|
||||
# per-connection lifecycle hook lives on the underlying Connection
|
||||
# (`connection.exit_stack`), not the session. The registration flag
|
||||
# is likewise stashed on the connection's `state` so it survives
|
||||
# across requests.
|
||||
connection = getattr(ctx.session, "_connection", None)
|
||||
if connection is not None and not connection.state.get(
|
||||
"_notification_cleanup_registered"
|
||||
):
|
||||
|
||||
async def _cleanup_subscriber() -> None:
|
||||
await stop_subscriber(session_id) # type: ignore[arg-type]
|
||||
|
||||
connection.exit_stack.push_async_callback(_cleanup_subscriber)
|
||||
connection.state["_notification_cleanup_registered"] = True
|
||||
except Exception as e:
|
||||
# Non-fatal: elicitation will still work via polling fallback
|
||||
logger.debug("Failed to start notification subscriber: %s", e)
|
||||
|
||||
# Return CreateTaskResult with proper Task object
|
||||
# Tasks MUST begin in "working" status per SEP-1686 final spec (line 381)
|
||||
return mcp_types.CreateTaskResult(
|
||||
task=mcp_types.Task(
|
||||
task_id=server_task_id,
|
||||
status="working",
|
||||
created_at=created_at_iso,
|
||||
last_updated_at=created_at_iso,
|
||||
ttl=ttl_ms,
|
||||
poll_interval=poll_interval_ms,
|
||||
)
|
||||
)
|
||||
|
|
@ -1,312 +0,0 @@
|
|||
"""Distributed notification queue for background task events (SEP-1686).
|
||||
|
||||
Enables distributed Docket workers to send MCP notifications to clients
|
||||
without holding session references. Workers push to a Redis queue,
|
||||
the MCP server process subscribes and forwards to the client's session.
|
||||
|
||||
Pattern: Fire-and-forward with retry
|
||||
- One queue per session_id
|
||||
- LPUSH/BRPOP for reliable ordered delivery
|
||||
- Retry up to 3 times on delivery failure, then discard
|
||||
- TTL-based expiration for stale messages
|
||||
|
||||
Note: Docket's execution.subscribe() handles task state/progress events via
|
||||
Redis Pub/Sub. This module handles elicitation-specific notifications that
|
||||
require reliable delivery (input_required prompts, cancel signals).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import weakref
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import mcp_types
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docket import Docket
|
||||
from mcp.server.session import ServerSession
|
||||
|
||||
from fastmcp.server.server import FastMCP
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Redis key patterns
|
||||
NOTIFICATION_QUEUE_KEY = "fastmcp:notifications:{session_id}"
|
||||
NOTIFICATION_ACTIVE_KEY = "fastmcp:notifications:{session_id}:active"
|
||||
|
||||
# Configuration
|
||||
NOTIFICATION_TTL_SECONDS = 300 # 5 minute message TTL (elicitation response window)
|
||||
MAX_DELIVERY_ATTEMPTS = 3 # Retry failed deliveries before discarding
|
||||
SUBSCRIBER_TIMEOUT_SECONDS = 30 # BRPOP timeout (also heartbeat interval)
|
||||
|
||||
|
||||
async def push_notification(
|
||||
session_id: str,
|
||||
notification: dict[str, Any],
|
||||
docket: Docket,
|
||||
) -> None:
|
||||
"""Push notification to session's queue (called from Docket worker).
|
||||
|
||||
Used for elicitation-specific notifications (input_required, cancel)
|
||||
that need reliable delivery across distributed processes.
|
||||
|
||||
Args:
|
||||
session_id: Target session's identifier
|
||||
notification: MCP notification dict (method, params, _meta)
|
||||
docket: Docket instance for Redis access
|
||||
"""
|
||||
key = docket.key(NOTIFICATION_QUEUE_KEY.format(session_id=session_id))
|
||||
message = json.dumps(
|
||||
{
|
||||
"notification": notification,
|
||||
"attempt": 0,
|
||||
"enqueued_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
async with docket.redis() as redis:
|
||||
await redis.lpush(key, message)
|
||||
await redis.expire(key, NOTIFICATION_TTL_SECONDS)
|
||||
|
||||
|
||||
async def notification_subscriber_loop(
|
||||
session_id: str,
|
||||
session: ServerSession,
|
||||
docket: Docket,
|
||||
fastmcp: FastMCP,
|
||||
) -> None:
|
||||
"""Subscribe to notification queue and forward to session.
|
||||
|
||||
Runs in the MCP server process. Bridges distributed workers to clients.
|
||||
|
||||
This loop:
|
||||
1. Maintains a heartbeat (active subscriber marker for debugging)
|
||||
2. Blocks on BRPOP waiting for notifications
|
||||
3. Forwards notifications to the client's session
|
||||
4. Retries failed deliveries, then discards (no dead-letter queue)
|
||||
|
||||
Args:
|
||||
session_id: Session identifier to subscribe to
|
||||
session: MCP ServerSession for sending notifications
|
||||
docket: Docket instance for Redis access
|
||||
fastmcp: FastMCP server instance (for elicitation relay)
|
||||
"""
|
||||
queue_key = docket.key(NOTIFICATION_QUEUE_KEY.format(session_id=session_id))
|
||||
active_key = docket.key(NOTIFICATION_ACTIVE_KEY.format(session_id=session_id))
|
||||
|
||||
logger.debug("Starting notification subscriber for session %s", session_id)
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with docket.redis() as redis:
|
||||
# Heartbeat: mark subscriber as active (for distributed debugging)
|
||||
await redis.set(active_key, "1", ex=SUBSCRIBER_TIMEOUT_SECONDS * 2)
|
||||
|
||||
# Blocking wait for notification (timeout refreshes heartbeat)
|
||||
# Using BRPOP (right pop) for FIFO order with LPUSH (left push)
|
||||
result = await redis.brpop(
|
||||
[queue_key], timeout=SUBSCRIBER_TIMEOUT_SECONDS
|
||||
)
|
||||
if not result:
|
||||
continue # Timeout - refresh heartbeat and retry
|
||||
|
||||
_, message_bytes = result
|
||||
message = json.loads(message_bytes)
|
||||
notification_dict = message["notification"]
|
||||
attempt = message.get("attempt", 0)
|
||||
|
||||
try:
|
||||
# Reconstruct and send MCP notification
|
||||
await _send_mcp_notification(
|
||||
session, notification_dict, session_id, docket, fastmcp
|
||||
)
|
||||
logger.debug(
|
||||
"Delivered notification to session %s (attempt %d)",
|
||||
session_id,
|
||||
attempt + 1,
|
||||
)
|
||||
except Exception as send_error:
|
||||
# Delivery failed - retry or discard
|
||||
if attempt < MAX_DELIVERY_ATTEMPTS - 1:
|
||||
# Re-queue with incremented attempt (back of queue)
|
||||
message["attempt"] = attempt + 1
|
||||
message["last_error"] = str(send_error)
|
||||
await redis.lpush(queue_key, json.dumps(message))
|
||||
logger.debug(
|
||||
"Requeued notification for session %s (attempt %d): %s",
|
||||
session_id,
|
||||
attempt + 2,
|
||||
send_error,
|
||||
)
|
||||
else:
|
||||
# Discard after max attempts (session likely disconnected)
|
||||
logger.warning(
|
||||
"Discarding notification for session %s after %d attempts: %s",
|
||||
session_id,
|
||||
MAX_DELIVERY_ATTEMPTS,
|
||||
send_error,
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Graceful shutdown - leave pending messages in queue for reconnect
|
||||
logger.debug("Notification subscriber cancelled for session %s", session_id)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Notification subscriber error for session %s: %s", session_id, e
|
||||
)
|
||||
await asyncio.sleep(1) # Backoff on error
|
||||
|
||||
|
||||
async def _send_mcp_notification(
|
||||
session: ServerSession,
|
||||
notification_dict: dict[str, Any],
|
||||
session_id: str,
|
||||
docket: Docket,
|
||||
fastmcp: FastMCP,
|
||||
) -> None:
|
||||
"""Reconstruct MCP notification from dict and send to session.
|
||||
|
||||
For input_required notifications with elicitation metadata, also sends
|
||||
a standard elicitation/create request to the client and relays the
|
||||
response back to the worker via Redis.
|
||||
|
||||
Args:
|
||||
session: MCP ServerSession
|
||||
notification_dict: Notification as dict (method, params, _meta)
|
||||
session_id: Session identifier (for elicitation relay)
|
||||
docket: Docket instance (for notification delivery)
|
||||
fastmcp: FastMCP server instance (for elicitation relay)
|
||||
"""
|
||||
method = notification_dict.get("method", "notifications/tasks/status")
|
||||
if method != "notifications/tasks/status":
|
||||
raise ValueError(f"Unsupported notification method for subscriber: {method}")
|
||||
|
||||
# SDK v2: a notification's `_meta` lives on its params (`params._meta`), not
|
||||
# at the notification envelope level, so nest it under params before parsing.
|
||||
params_dict = dict(notification_dict.get("params", {}))
|
||||
meta_dict = notification_dict.get("_meta")
|
||||
if meta_dict is not None:
|
||||
params_dict["_meta"] = meta_dict
|
||||
notification = mcp_types.TaskStatusNotification.model_validate(
|
||||
{
|
||||
"method": "notifications/tasks/status",
|
||||
"params": params_dict,
|
||||
}
|
||||
)
|
||||
# SDK v2: `ServerNotification` is a union type; send the bare model.
|
||||
await session.send_notification(notification) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
|
||||
|
||||
# If this is an input_required notification with elicitation metadata,
|
||||
# relay the elicitation to the client via standard elicitation/create
|
||||
params = notification_dict.get("params", {})
|
||||
if params.get("status") == "input_required":
|
||||
meta = notification_dict.get("_meta", {})
|
||||
related_task = meta.get("io.modelcontextprotocol/related-task", {})
|
||||
elicitation = related_task.get("elicitation")
|
||||
if elicitation:
|
||||
task_id = params.get("taskId")
|
||||
if not task_id:
|
||||
logger.warning(
|
||||
"input_required notification missing taskId, skipping relay"
|
||||
)
|
||||
return
|
||||
if "task_scope" not in related_task:
|
||||
logger.warning(
|
||||
"input_required notification for task %s missing task_scope "
|
||||
"metadata, skipping elicitation relay",
|
||||
task_id,
|
||||
)
|
||||
return
|
||||
task_scope = related_task["task_scope"]
|
||||
from fastmcp.server.tasks.elicitation import relay_elicitation
|
||||
|
||||
task = asyncio.create_task(
|
||||
relay_elicitation(session, task_scope, task_id, elicitation, fastmcp),
|
||||
name=f"elicitation-relay-{task_id[:8]}",
|
||||
)
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Subscriber Management
|
||||
# =============================================================================
|
||||
|
||||
# Strong references to fire-and-forget relay tasks (prevent GC mid-flight)
|
||||
_background_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
# Registry of active subscribers per session (prevents duplicates)
|
||||
# Uses weakref to session to detect disconnects
|
||||
_active_subscribers: dict[
|
||||
str, tuple[asyncio.Task[None], weakref.ref[ServerSession]]
|
||||
] = {}
|
||||
|
||||
|
||||
async def ensure_subscriber_running(
|
||||
session_id: str,
|
||||
session: ServerSession,
|
||||
docket: Docket,
|
||||
fastmcp: FastMCP,
|
||||
) -> None:
|
||||
"""Start notification subscriber if not already running (idempotent).
|
||||
|
||||
Subscriber is created on first task submission and cleaned up on disconnect.
|
||||
Safe to call multiple times for the same session.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
session: MCP ServerSession
|
||||
docket: Docket instance
|
||||
fastmcp: FastMCP server instance (for elicitation relay)
|
||||
"""
|
||||
# Check if subscriber already running for this session
|
||||
if session_id in _active_subscribers:
|
||||
task, session_ref = _active_subscribers[session_id]
|
||||
# Check if task is still running AND session is still alive
|
||||
if not task.done() and session_ref() is not None:
|
||||
return # Already running
|
||||
|
||||
# Task finished or session dead - clean up
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
del _active_subscribers[session_id]
|
||||
|
||||
# Start new subscriber task
|
||||
task = asyncio.create_task(
|
||||
notification_subscriber_loop(session_id, session, docket, fastmcp),
|
||||
name=f"notification-subscriber-{session_id[:8]}",
|
||||
)
|
||||
_active_subscribers[session_id] = (task, weakref.ref(session))
|
||||
logger.debug("Started notification subscriber for session %s", session_id)
|
||||
|
||||
|
||||
async def stop_subscriber(session_id: str) -> None:
|
||||
"""Stop notification subscriber for a session.
|
||||
|
||||
Called when session disconnects. Pending messages remain in queue
|
||||
for delivery if client reconnects (with TTL expiration).
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
"""
|
||||
if session_id not in _active_subscribers:
|
||||
return
|
||||
|
||||
task, _ = _active_subscribers.pop(session_id)
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
logger.debug("Stopped notification subscriber for session %s", session_id)
|
||||
|
||||
|
||||
def get_subscriber_count() -> int:
|
||||
"""Get number of active subscribers (for monitoring)."""
|
||||
return len(_active_subscribers)
|
||||
|
|
@ -1,469 +0,0 @@
|
|||
"""SEP-1686 task request handlers.
|
||||
|
||||
Handles MCP task protocol requests: tasks/get, tasks/result, tasks/list, tasks/cancel.
|
||||
These handlers query and manage existing tasks (contrast with handlers.py which creates tasks).
|
||||
|
||||
This module requires fastmcp[tasks] (pydocket). It is only imported when docket is available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import mcp_types
|
||||
from docket.execution import ExecutionState
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp_types import (
|
||||
INTERNAL_ERROR,
|
||||
INVALID_PARAMS,
|
||||
CancelTaskResult,
|
||||
GetTaskResult,
|
||||
ListTasksResult,
|
||||
)
|
||||
|
||||
import fastmcp.server.context
|
||||
from fastmcp.exceptions import NotFoundError
|
||||
from fastmcp.prompts.base import Prompt
|
||||
from fastmcp.resources.base import Resource
|
||||
from fastmcp.resources.template import ResourceTemplate
|
||||
from fastmcp.server.tasks.config import DEFAULT_POLL_INTERVAL_MS, DEFAULT_TTL_MS
|
||||
from fastmcp.server.tasks.context import get_task_scope
|
||||
from fastmcp.server.tasks.keys import parse_task_key, task_redis_prefix
|
||||
from fastmcp.tools.base import InputRequiredToolResult, Tool
|
||||
from fastmcp.utilities.versions import VersionSpec
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.server.server import FastMCP
|
||||
|
||||
|
||||
# Map Docket execution states to MCP task status strings
|
||||
# Per SEP-1686 final spec (line 381): tasks MUST begin in "working" status
|
||||
DOCKET_TO_MCP_STATE: dict[ExecutionState, str] = {
|
||||
ExecutionState.SCHEDULED: "working", # Initial state per spec
|
||||
ExecutionState.QUEUED: "working", # Initial state per spec
|
||||
ExecutionState.RUNNING: "working",
|
||||
ExecutionState.COMPLETED: "completed",
|
||||
ExecutionState.FAILED: "failed",
|
||||
ExecutionState.CANCELLED: "cancelled",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_iso_timestamp(stored: str | None) -> str:
|
||||
"""Return an ISO 8601 timestamp string for a Task's createdAt/lastUpdatedAt.
|
||||
|
||||
The v2 Task model types these fields as ISO 8601 strings. `stored` is the
|
||||
value read from Redis (already an ISO string) or None; either way this
|
||||
returns a valid ISO string, falling back to the current UTC time.
|
||||
"""
|
||||
if stored:
|
||||
try:
|
||||
return datetime.fromisoformat(stored.replace("Z", "+00:00")).isoformat()
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _parse_key_version(key_suffix: str) -> tuple[str, str | None]:
|
||||
"""Parse a key suffix into (name_or_uri, version).
|
||||
|
||||
Keys always contain @ as a version delimiter (sentinel pattern):
|
||||
- "add@1.0" → ("add", "1.0") # versioned
|
||||
- "add@" → ("add", None) # unversioned
|
||||
- "user@example.com@1.0" → ("user@example.com", "1.0") # @ in URI
|
||||
|
||||
Uses rsplit to split on the LAST @ which is always the version delimiter.
|
||||
Falls back to treating the whole string as the name if @ is not present
|
||||
(for backwards compatibility with legacy task keys).
|
||||
"""
|
||||
if "@" not in key_suffix:
|
||||
# Legacy key without version sentinel - treat as unversioned
|
||||
return key_suffix, None
|
||||
name_or_uri, version = key_suffix.rsplit("@", 1)
|
||||
return name_or_uri, version if version else None
|
||||
|
||||
|
||||
async def _lookup_task_execution(
|
||||
docket: Any,
|
||||
task_scope: str | None,
|
||||
client_task_id: str,
|
||||
) -> tuple[Any, str | None, int]:
|
||||
"""Look up task execution and metadata from Redis.
|
||||
|
||||
Consolidates the common pattern of fetching task metadata from Redis,
|
||||
validating it exists, and retrieving the Docket execution.
|
||||
|
||||
Args:
|
||||
docket: Docket instance
|
||||
task_scope: Authorization scope
|
||||
client_task_id: Client-provided task ID
|
||||
|
||||
Returns:
|
||||
Tuple of (execution, created_at, poll_interval_ms)
|
||||
|
||||
Raises:
|
||||
MCPError: If task not found or execution not found
|
||||
"""
|
||||
prefix = task_redis_prefix(task_scope)
|
||||
task_meta_key = docket.key(f"{prefix}:{client_task_id}")
|
||||
created_at_key = docket.key(f"{prefix}:{client_task_id}:created_at")
|
||||
poll_interval_key = docket.key(f"{prefix}:{client_task_id}:poll_interval")
|
||||
|
||||
# Fetch metadata (single round-trip with mget)
|
||||
async with docket.redis() as redis:
|
||||
task_key_bytes, created_at_bytes, poll_interval_bytes = await redis.mget(
|
||||
task_meta_key, created_at_key, poll_interval_key
|
||||
)
|
||||
|
||||
# Decode and validate task_key
|
||||
task_key = task_key_bytes.decode("utf-8") if task_key_bytes else None
|
||||
if not task_key:
|
||||
raise MCPError(code=INVALID_PARAMS, message=f"Task {client_task_id} not found")
|
||||
|
||||
# Get execution
|
||||
execution = await docket.get_execution(task_key)
|
||||
if not execution:
|
||||
raise MCPError(
|
||||
code=INVALID_PARAMS,
|
||||
message=f"Task {client_task_id} execution not found",
|
||||
)
|
||||
|
||||
# Parse metadata with defaults
|
||||
created_at = created_at_bytes.decode("utf-8") if created_at_bytes else None
|
||||
try:
|
||||
poll_interval_ms = (
|
||||
int(poll_interval_bytes.decode("utf-8"))
|
||||
if poll_interval_bytes
|
||||
else DEFAULT_POLL_INTERVAL_MS
|
||||
)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
poll_interval_ms = DEFAULT_POLL_INTERVAL_MS
|
||||
|
||||
return execution, created_at, poll_interval_ms
|
||||
|
||||
|
||||
async def tasks_get_handler(server: FastMCP, params: dict[str, Any]) -> GetTaskResult:
|
||||
"""Handle MCP 'tasks/get' request (SEP-1686).
|
||||
|
||||
Args:
|
||||
server: FastMCP server instance
|
||||
params: Request params containing taskId
|
||||
|
||||
Returns:
|
||||
GetTaskResult: Task status response with spec-compliant fields
|
||||
"""
|
||||
async with fastmcp.server.context.Context(fastmcp=server):
|
||||
client_task_id = params.get("taskId")
|
||||
if not client_task_id:
|
||||
raise MCPError(
|
||||
code=INVALID_PARAMS, message="Missing required parameter: taskId"
|
||||
)
|
||||
|
||||
# Get authorization scope for task lookup
|
||||
task_scope = get_task_scope()
|
||||
|
||||
# Get Docket instance
|
||||
docket = server._docket
|
||||
if docket is None:
|
||||
raise MCPError(
|
||||
code=INTERNAL_ERROR,
|
||||
message="Background tasks require Docket",
|
||||
)
|
||||
|
||||
# Look up task execution and metadata
|
||||
execution, created_at, poll_interval_ms = await _lookup_task_execution(
|
||||
docket, task_scope, client_task_id
|
||||
)
|
||||
|
||||
# Sync state from Redis
|
||||
await execution.sync()
|
||||
|
||||
# Map Docket state to MCP state
|
||||
state_map = DOCKET_TO_MCP_STATE
|
||||
mcp_state: Literal[
|
||||
"working", "input_required", "completed", "failed", "cancelled"
|
||||
] = state_map.get(execution.state, "failed") # type: ignore[assignment] # ty:ignore[invalid-assignment]
|
||||
|
||||
# Build response (use default ttl since we don't track per-task values)
|
||||
# createdAt is REQUIRED per SEP-1686 final spec (line 430)
|
||||
# Per spec lines 447-448: SHOULD NOT include related-task metadata in tasks/get
|
||||
error_message = None
|
||||
status_message = None
|
||||
|
||||
if execution.state == ExecutionState.FAILED:
|
||||
try:
|
||||
await execution.get_result(timeout=timedelta(seconds=0))
|
||||
except Exception as error:
|
||||
error_message = str(error)
|
||||
status_message = f"Task failed: {error_message}"
|
||||
elif execution.progress and execution.progress.message:
|
||||
# Extract progress message from Docket if available (spec line 403)
|
||||
status_message = execution.progress.message
|
||||
|
||||
# createdAt is required per spec, but can be None from Redis. The v2
|
||||
# Task model types createdAt/lastUpdatedAt as ISO 8601 strings, so
|
||||
# normalize the stored value (or fall back to now) to an ISO string.
|
||||
created_at_iso = _normalize_iso_timestamp(created_at)
|
||||
|
||||
return GetTaskResult(
|
||||
task_id=client_task_id,
|
||||
status=mcp_state,
|
||||
created_at=created_at_iso,
|
||||
last_updated_at=datetime.now(timezone.utc).isoformat(),
|
||||
ttl=DEFAULT_TTL_MS,
|
||||
poll_interval=poll_interval_ms,
|
||||
status_message=status_message,
|
||||
)
|
||||
|
||||
|
||||
async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
|
||||
"""Handle MCP 'tasks/result' request (SEP-1686).
|
||||
|
||||
Converts raw task return values to MCP types based on task type.
|
||||
|
||||
Args:
|
||||
server: FastMCP server instance
|
||||
params: Request params containing taskId
|
||||
|
||||
Returns:
|
||||
MCP result (CallToolResult, GetPromptResult, or ReadResourceResult)
|
||||
"""
|
||||
async with fastmcp.server.context.Context(fastmcp=server):
|
||||
client_task_id = params.get("taskId")
|
||||
if not client_task_id:
|
||||
raise MCPError(
|
||||
code=INVALID_PARAMS, message="Missing required parameter: taskId"
|
||||
)
|
||||
|
||||
# Get authorization scope for task lookup
|
||||
task_scope = get_task_scope()
|
||||
|
||||
# Get execution from Docket (use instance attribute for cross-task access)
|
||||
docket = server._docket
|
||||
if docket is None:
|
||||
raise MCPError(
|
||||
code=INTERNAL_ERROR,
|
||||
message="Background tasks require Docket",
|
||||
)
|
||||
|
||||
# Look up full task key from Redis
|
||||
task_meta_key = docket.key(f"{task_redis_prefix(task_scope)}:{client_task_id}")
|
||||
async with docket.redis() as redis:
|
||||
task_key_bytes = await redis.get(task_meta_key)
|
||||
|
||||
task_key = None if task_key_bytes is None else task_key_bytes.decode("utf-8")
|
||||
|
||||
if task_key is None:
|
||||
raise MCPError(
|
||||
code=INVALID_PARAMS,
|
||||
message=f"Invalid taskId: {client_task_id} not found",
|
||||
)
|
||||
|
||||
execution = await docket.get_execution(task_key)
|
||||
if execution is None:
|
||||
raise MCPError(
|
||||
code=INVALID_PARAMS,
|
||||
message=f"Invalid taskId: {client_task_id} not found",
|
||||
)
|
||||
|
||||
# Sync state from Redis
|
||||
await execution.sync()
|
||||
|
||||
# Check if completed
|
||||
state_map = DOCKET_TO_MCP_STATE
|
||||
if execution.state not in (ExecutionState.COMPLETED, ExecutionState.FAILED):
|
||||
mcp_state = state_map.get(execution.state, "failed")
|
||||
raise MCPError(
|
||||
code=INVALID_PARAMS,
|
||||
message=f"Task not completed yet (current state: {mcp_state})",
|
||||
)
|
||||
|
||||
# Get result from Docket
|
||||
try:
|
||||
raw_value = await execution.get_result(timeout=timedelta(seconds=0))
|
||||
except Exception as error:
|
||||
# Task failed - return error result
|
||||
return mcp_types.CallToolResult(
|
||||
content=[mcp_types.TextContent(type="text", text=str(error))],
|
||||
is_error=True,
|
||||
_meta={ # type: ignore[call-arg] # _meta is Pydantic alias for meta field
|
||||
"io.modelcontextprotocol/related-task": {
|
||||
"taskId": client_task_id,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Parse task key to get component key
|
||||
key_parts = parse_task_key(task_key)
|
||||
component_key = key_parts["component_identifier"]
|
||||
|
||||
# Look up component by its prefixed key (inlined from deleted get_component)
|
||||
component: Tool | Resource | ResourceTemplate | Prompt | None = None
|
||||
try:
|
||||
if component_key.startswith("tool:"):
|
||||
name, version_str = _parse_key_version(component_key[5:])
|
||||
version = VersionSpec(eq=version_str) if version_str else None
|
||||
component = await server.get_tool(name, version)
|
||||
elif component_key.startswith("resource:"):
|
||||
uri, version_str = _parse_key_version(component_key[9:])
|
||||
version = VersionSpec(eq=version_str) if version_str else None
|
||||
component = await server.get_resource(uri, version)
|
||||
elif component_key.startswith("template:"):
|
||||
uri, version_str = _parse_key_version(component_key[9:])
|
||||
version = VersionSpec(eq=version_str) if version_str else None
|
||||
component = await server.get_resource_template(uri, version)
|
||||
elif component_key.startswith("prompt:"):
|
||||
name, version_str = _parse_key_version(component_key[7:])
|
||||
version = VersionSpec(eq=version_str) if version_str else None
|
||||
component = await server.get_prompt(name, version)
|
||||
except NotFoundError:
|
||||
component = None
|
||||
|
||||
if component is None:
|
||||
raise MCPError(
|
||||
code=INTERNAL_ERROR,
|
||||
message=f"Component not found for task: {component_key}",
|
||||
)
|
||||
|
||||
# Build related-task metadata
|
||||
related_task_meta = {
|
||||
"io.modelcontextprotocol/related-task": {
|
||||
"taskId": client_task_id,
|
||||
}
|
||||
}
|
||||
|
||||
# Convert based on component type.
|
||||
# Each branch merges related_task_meta with any existing _meta
|
||||
# (e.g. fastmcp.wrap_result) rather than overwriting it.
|
||||
if isinstance(component, Tool):
|
||||
if isinstance(
|
||||
raw_value, mcp_types.InputRequiredResult | InputRequiredToolResult
|
||||
):
|
||||
raise MCPError(
|
||||
code=INTERNAL_ERROR,
|
||||
message=(
|
||||
f"Tool {component_key!r} requested input while running as a "
|
||||
"background task. Input-required (multi-round-trip) tools "
|
||||
"need a live request to answer the prompt and cannot run as "
|
||||
"tasks; remove task execution from this tool or the code path "
|
||||
"that returns an InputRequiredResult."
|
||||
),
|
||||
)
|
||||
fastmcp_result = component.convert_result(raw_value)
|
||||
mcp_result = fastmcp_result.to_mcp_result()
|
||||
if isinstance(mcp_result, mcp_types.CallToolResult):
|
||||
merged = {**(mcp_result.meta or {}), **related_task_meta}
|
||||
mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
|
||||
elif isinstance(mcp_result, tuple):
|
||||
content, structured_content = mcp_result
|
||||
mcp_result = mcp_types.CallToolResult(
|
||||
content=content,
|
||||
structured_content=structured_content,
|
||||
_meta=related_task_meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field
|
||||
)
|
||||
else:
|
||||
mcp_result = mcp_types.CallToolResult(
|
||||
content=mcp_result,
|
||||
_meta=related_task_meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field
|
||||
)
|
||||
return mcp_result
|
||||
|
||||
elif isinstance(component, Prompt):
|
||||
fastmcp_result = component.convert_result(raw_value)
|
||||
mcp_result = fastmcp_result.to_mcp_prompt_result()
|
||||
merged = {**(mcp_result.meta or {}), **related_task_meta}
|
||||
mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
|
||||
return mcp_result
|
||||
|
||||
elif isinstance(component, ResourceTemplate):
|
||||
fastmcp_result = component.convert_result(raw_value)
|
||||
mcp_result = fastmcp_result.to_mcp_result(component.uri_template)
|
||||
merged = {**(mcp_result.meta or {}), **related_task_meta}
|
||||
mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
|
||||
return mcp_result
|
||||
|
||||
elif isinstance(component, Resource):
|
||||
fastmcp_result = component.convert_result(raw_value)
|
||||
mcp_result = fastmcp_result.to_mcp_result(str(component.uri))
|
||||
merged = {**(mcp_result.meta or {}), **related_task_meta}
|
||||
mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
|
||||
return mcp_result
|
||||
|
||||
else:
|
||||
raise MCPError(
|
||||
code=INTERNAL_ERROR,
|
||||
message=f"Internal error: Unknown component type: {type(component).__name__}",
|
||||
)
|
||||
|
||||
|
||||
async def tasks_list_handler(
|
||||
server: FastMCP, params: dict[str, Any]
|
||||
) -> ListTasksResult:
|
||||
"""Handle MCP 'tasks/list' request (SEP-1686).
|
||||
|
||||
Note: With client-side tracking, this returns minimal info.
|
||||
|
||||
Args:
|
||||
server: FastMCP server instance
|
||||
params: Request params (cursor, limit)
|
||||
|
||||
Returns:
|
||||
ListTasksResult: Response with tasks list and pagination
|
||||
"""
|
||||
# Return empty list - client tracks tasks locally
|
||||
return ListTasksResult(tasks=[], next_cursor=None)
|
||||
|
||||
|
||||
async def tasks_cancel_handler(
|
||||
server: FastMCP, params: dict[str, Any]
|
||||
) -> CancelTaskResult:
|
||||
"""Handle MCP 'tasks/cancel' request (SEP-1686).
|
||||
|
||||
Cancels a running task, transitioning it to cancelled state.
|
||||
|
||||
Args:
|
||||
server: FastMCP server instance
|
||||
params: Request params containing taskId
|
||||
|
||||
Returns:
|
||||
CancelTaskResult: Task status response showing cancelled state
|
||||
"""
|
||||
async with fastmcp.server.context.Context(fastmcp=server):
|
||||
client_task_id = params.get("taskId")
|
||||
if not client_task_id:
|
||||
raise MCPError(
|
||||
code=INVALID_PARAMS, message="Missing required parameter: taskId"
|
||||
)
|
||||
|
||||
# Get authorization scope for task lookup
|
||||
task_scope = get_task_scope()
|
||||
|
||||
# Get Docket instance
|
||||
docket = server._docket
|
||||
if docket is None:
|
||||
raise MCPError(
|
||||
code=INTERNAL_ERROR,
|
||||
message="Background tasks require Docket",
|
||||
)
|
||||
|
||||
# Look up task execution and metadata
|
||||
execution, created_at, poll_interval_ms = await _lookup_task_execution(
|
||||
docket, task_scope, client_task_id
|
||||
)
|
||||
|
||||
# Cancel via Docket (now sets CANCELLED state natively)
|
||||
# Note: We need to get task_key from execution.key for cancellation
|
||||
await docket.cancel(execution.key)
|
||||
|
||||
# Return task status with cancelled state
|
||||
# createdAt is REQUIRED per SEP-1686 final spec (line 430)
|
||||
# Per spec lines 447-448: SHOULD NOT include related-task metadata in tasks/cancel
|
||||
return CancelTaskResult(
|
||||
task_id=client_task_id,
|
||||
status="cancelled",
|
||||
created_at=_normalize_iso_timestamp(created_at),
|
||||
last_updated_at=datetime.now(timezone.utc).isoformat(),
|
||||
ttl=DEFAULT_TTL_MS,
|
||||
poll_interval=poll_interval_ms,
|
||||
status_message="Task cancelled",
|
||||
)
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
"""Task routing helper for MCP components.
|
||||
|
||||
Provides unified task mode enforcement and docket routing logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import mcp_types
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp_types import METHOD_NOT_FOUND
|
||||
|
||||
from fastmcp.server.tasks.config import TaskMeta
|
||||
from fastmcp.server.tasks.handlers import submit_to_docket
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.prompts.base import Prompt
|
||||
from fastmcp.resources.base import Resource
|
||||
from fastmcp.resources.template import ResourceTemplate
|
||||
from fastmcp.tools.base import Tool
|
||||
|
||||
TaskType = Literal["tool", "resource", "template", "prompt"]
|
||||
|
||||
|
||||
async def check_background_task(
|
||||
component: Tool | Resource | ResourceTemplate | Prompt,
|
||||
task_type: TaskType,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
task_meta: TaskMeta | None = None,
|
||||
) -> mcp_types.CreateTaskResult | None:
|
||||
"""Check task mode and submit to background if requested.
|
||||
|
||||
Args:
|
||||
component: The MCP component
|
||||
task_type: Type of task ("tool", "resource", "template", "prompt")
|
||||
arguments: Arguments for tool/prompt/template execution
|
||||
task_meta: Task execution metadata. If provided, execute as background task.
|
||||
|
||||
Returns:
|
||||
CreateTaskResult if submitted to docket, None for sync execution
|
||||
|
||||
Raises:
|
||||
MCPError: If mode="required" but no task metadata, or mode="forbidden"
|
||||
but task metadata is present
|
||||
"""
|
||||
task_config = component.task_config
|
||||
|
||||
# Infer label from component
|
||||
entity_label = f"{type(component).__name__} '{component.title or component.key}'"
|
||||
|
||||
# Enforce mode="required" - must have task metadata
|
||||
if task_config.mode == "required" and not task_meta:
|
||||
raise MCPError(
|
||||
code=METHOD_NOT_FOUND,
|
||||
message=f"{entity_label} requires task-augmented execution",
|
||||
)
|
||||
|
||||
# Enforce mode="forbidden" - cannot be called with task metadata
|
||||
if not task_config.supports_tasks() and task_meta:
|
||||
raise MCPError(
|
||||
code=METHOD_NOT_FOUND,
|
||||
message=f"{entity_label} does not support task-augmented execution",
|
||||
)
|
||||
|
||||
# No task metadata - synchronous execution
|
||||
if not task_meta:
|
||||
return None
|
||||
|
||||
# fn_key is expected to be set; fall back to component.key for direct calls
|
||||
fn_key = task_meta.fn_key or component.key
|
||||
return await submit_to_docket(task_type, fn_key, component, arguments, task_meta)
|
||||
|
|
@ -1,282 +0,0 @@
|
|||
"""Task subscription helpers for sending MCP notifications (SEP-1686).
|
||||
|
||||
Subscribes to Docket execution state changes and sends notifications/tasks/status
|
||||
to clients when their tasks change state.
|
||||
|
||||
This module requires fastmcp[tasks] (pydocket). It is only imported when docket is available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from docket.execution import ExecutionState
|
||||
from mcp_types import TaskStatusNotification, TaskStatusNotificationParams
|
||||
|
||||
from fastmcp.server.tasks.config import DEFAULT_TTL_MS
|
||||
from fastmcp.server.tasks.keys import parse_task_key, task_redis_prefix
|
||||
from fastmcp.server.tasks.requests import DOCKET_TO_MCP_STATE
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docket import Docket
|
||||
from docket.execution import Execution
|
||||
from mcp.server.session import ServerSession
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Initial interval for reconciling execution state against Redis (seconds). The
|
||||
# interval doubles on each idle reconcile up to the task's poll_interval, so fast
|
||||
# tasks are caught within the first ~20ms checks while long-running tasks converge
|
||||
# to roughly one sync per advertised poll interval.
|
||||
_MIN_RECONCILE_INTERVAL_SECONDS = 0.02
|
||||
|
||||
|
||||
async def subscribe_to_task_updates(
|
||||
task_id: str,
|
||||
task_key: str,
|
||||
session: ServerSession,
|
||||
docket: Docket,
|
||||
poll_interval_ms: int = 5000,
|
||||
) -> None:
|
||||
"""Subscribe to Docket execution events and send MCP notifications.
|
||||
|
||||
Per SEP-1686 lines 436-444, servers MAY send notifications/tasks/status
|
||||
when task state changes. This is an optional optimization that reduces
|
||||
client polling frequency.
|
||||
|
||||
Args:
|
||||
task_id: Client-visible task ID (server-generated UUID)
|
||||
task_key: Internal Docket execution key (includes session, type, component)
|
||||
session: MCP ServerSession for sending notifications
|
||||
docket: Docket instance for subscribing to execution events
|
||||
poll_interval_ms: Poll interval in milliseconds to include in notifications
|
||||
|
||||
Note: Docket's ``execution.subscribe()`` replays the current state and a progress
|
||||
event before it subscribes to Redis pub/sub. A task that completes during that
|
||||
window has its terminal state publish lost, so no live event ever arrives — a
|
||||
common case for fast tasks. Because there is no reliable signal for when the
|
||||
subscription goes live (the replayed state event arrives two iterations early),
|
||||
we simply reconcile the execution against Redis on every idle interval until a
|
||||
terminal state is observed. The interval backs off exponentially toward the
|
||||
task's advertised poll interval, so a long-running task costs about one sync per
|
||||
poll interval while live pub/sub events still short-circuit the wait instantly.
|
||||
"""
|
||||
terminal_states = {
|
||||
ExecutionState.COMPLETED,
|
||||
ExecutionState.FAILED,
|
||||
ExecutionState.CANCELLED,
|
||||
}
|
||||
try:
|
||||
execution = await docket.get_execution(task_key)
|
||||
if execution is None:
|
||||
logger.warning(f"No execution found for task {task_id}")
|
||||
return
|
||||
|
||||
subscription = execution.subscribe()
|
||||
# Keep a single outstanding __anext__ across reconcile timeouts. asyncio.wait
|
||||
# returns on timeout without cancelling it, so the generator (and its pub/sub
|
||||
# subscription) stays intact — unlike wait_for, which would cancel mid-iteration.
|
||||
next_event = asyncio.ensure_future(subscription.__anext__())
|
||||
# Reconcile cadence backs off exponentially so a task that runs for a long
|
||||
# time (or that no worker ever claims) doesn't pin this loop at 50 syncs/sec
|
||||
# forever; the task's advertised poll interval is the natural ceiling.
|
||||
reconcile_backoff = _MIN_RECONCILE_INTERVAL_SECONDS
|
||||
reconcile_ceiling = max(
|
||||
poll_interval_ms / 1000, _MIN_RECONCILE_INTERVAL_SECONDS
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
done, _ = await asyncio.wait({next_event}, timeout=reconcile_backoff)
|
||||
if not done:
|
||||
# No live event yet: reconcile against Redis in case a
|
||||
# terminal transition was published before pub/sub went live.
|
||||
await execution.sync()
|
||||
if execution.state in terminal_states:
|
||||
await _send_status_notification(
|
||||
session=session,
|
||||
task_id=task_id,
|
||||
task_key=task_key,
|
||||
docket=docket,
|
||||
state=execution.state,
|
||||
poll_interval_ms=poll_interval_ms,
|
||||
)
|
||||
break
|
||||
reconcile_backoff = min(reconcile_backoff * 2, reconcile_ceiling)
|
||||
continue
|
||||
|
||||
try:
|
||||
event = next_event.result()
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
|
||||
if event["type"] == "state":
|
||||
state = ExecutionState(event["state"])
|
||||
# Send notifications/tasks/status when state changes
|
||||
await _send_status_notification(
|
||||
session=session,
|
||||
task_id=task_id,
|
||||
task_key=task_key,
|
||||
docket=docket,
|
||||
state=state,
|
||||
poll_interval_ms=poll_interval_ms,
|
||||
)
|
||||
# Stop subscribing once the task reaches a terminal state
|
||||
if state in terminal_states:
|
||||
break
|
||||
elif event["type"] == "progress":
|
||||
# Send notification when progress message changes
|
||||
await _send_progress_notification(
|
||||
session=session,
|
||||
task_id=task_id,
|
||||
task_key=task_key,
|
||||
docket=docket,
|
||||
execution=execution,
|
||||
poll_interval_ms=poll_interval_ms,
|
||||
)
|
||||
|
||||
next_event = asyncio.ensure_future(subscription.__anext__())
|
||||
finally:
|
||||
if not next_event.done():
|
||||
next_event.cancel()
|
||||
with suppress(asyncio.CancelledError, StopAsyncIteration):
|
||||
await next_event
|
||||
await subscription.aclose()
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Subscription task failed for {task_id}: {e}", exc_info=True)
|
||||
|
||||
|
||||
async def _send_status_notification(
|
||||
session: ServerSession,
|
||||
task_id: str,
|
||||
task_key: str,
|
||||
docket: Docket,
|
||||
state: ExecutionState,
|
||||
poll_interval_ms: int = 5000,
|
||||
) -> None:
|
||||
"""Send notifications/tasks/status to client.
|
||||
|
||||
Per SEP-1686 line 454: notification SHOULD NOT include related-task metadata
|
||||
(taskId is already in params).
|
||||
|
||||
Args:
|
||||
session: MCP ServerSession
|
||||
task_id: Client-visible task ID
|
||||
task_key: Internal task key (for metadata lookup)
|
||||
docket: Docket instance
|
||||
state: Docket execution state (enum)
|
||||
poll_interval_ms: Poll interval in milliseconds
|
||||
"""
|
||||
# Map Docket state to MCP status
|
||||
state_map = DOCKET_TO_MCP_STATE
|
||||
mcp_status = state_map.get(state, "failed")
|
||||
|
||||
# Extract task_scope from task_key for Redis lookup
|
||||
key_parts = parse_task_key(task_key)
|
||||
task_scope = key_parts["task_scope"]
|
||||
|
||||
created_at_key = docket.key(f"{task_redis_prefix(task_scope)}:{task_id}:created_at")
|
||||
async with docket.redis() as redis:
|
||||
created_at_bytes = await redis.get(created_at_key)
|
||||
|
||||
created_at = (
|
||||
created_at_bytes.decode("utf-8")
|
||||
if created_at_bytes
|
||||
else datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
|
||||
# Build status message
|
||||
status_message = None
|
||||
if state == ExecutionState.COMPLETED:
|
||||
status_message = "Task completed successfully"
|
||||
elif state == ExecutionState.FAILED:
|
||||
status_message = "Task failed"
|
||||
elif state == ExecutionState.CANCELLED:
|
||||
status_message = "Task cancelled"
|
||||
|
||||
params_dict = {
|
||||
"taskId": task_id,
|
||||
"status": mcp_status,
|
||||
"createdAt": created_at,
|
||||
"lastUpdatedAt": datetime.now(timezone.utc).isoformat(),
|
||||
"ttl": DEFAULT_TTL_MS,
|
||||
"pollInterval": poll_interval_ms,
|
||||
}
|
||||
|
||||
if status_message:
|
||||
params_dict["statusMessage"] = status_message
|
||||
|
||||
# Create notification (no related-task metadata per spec line 454)
|
||||
notification = TaskStatusNotification(
|
||||
params=TaskStatusNotificationParams.model_validate(params_dict),
|
||||
)
|
||||
|
||||
# Send notification (don't let failures break the subscription)
|
||||
with suppress(Exception):
|
||||
await session.send_notification(notification) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
|
||||
|
||||
|
||||
async def _send_progress_notification(
|
||||
session: ServerSession,
|
||||
task_id: str,
|
||||
task_key: str,
|
||||
docket: Docket,
|
||||
execution: Execution,
|
||||
poll_interval_ms: int = 5000,
|
||||
) -> None:
|
||||
"""Send notifications/tasks/status when progress updates.
|
||||
|
||||
Args:
|
||||
session: MCP ServerSession
|
||||
task_id: Client-visible task ID
|
||||
task_key: Internal task key
|
||||
docket: Docket instance
|
||||
execution: Execution object with current progress
|
||||
poll_interval_ms: Poll interval in milliseconds
|
||||
"""
|
||||
# Sync execution to get latest progress
|
||||
await execution.sync()
|
||||
|
||||
# Only send if there's a progress message
|
||||
if not execution.progress or not execution.progress.message:
|
||||
return
|
||||
|
||||
# Map Docket state to MCP status
|
||||
state_map = DOCKET_TO_MCP_STATE
|
||||
mcp_status = state_map.get(execution.state, "failed")
|
||||
|
||||
# Extract task_scope from task_key for Redis lookup
|
||||
key_parts = parse_task_key(task_key)
|
||||
task_scope = key_parts["task_scope"]
|
||||
|
||||
created_at_key = docket.key(f"{task_redis_prefix(task_scope)}:{task_id}:created_at")
|
||||
async with docket.redis() as redis:
|
||||
created_at_bytes = await redis.get(created_at_key)
|
||||
|
||||
created_at = (
|
||||
created_at_bytes.decode("utf-8")
|
||||
if created_at_bytes
|
||||
else datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
|
||||
params_dict = {
|
||||
"taskId": task_id,
|
||||
"status": mcp_status,
|
||||
"createdAt": created_at,
|
||||
"lastUpdatedAt": datetime.now(timezone.utc).isoformat(),
|
||||
"ttl": DEFAULT_TTL_MS,
|
||||
"pollInterval": poll_interval_ms,
|
||||
"statusMessage": execution.progress.message,
|
||||
}
|
||||
|
||||
# Create and send notification
|
||||
notification = TaskStatusNotification(
|
||||
params=TaskStatusNotificationParams.model_validate(params_dict),
|
||||
)
|
||||
|
||||
with suppress(Exception):
|
||||
await session.send_notification(notification) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
|
||||
|
|
@ -2,7 +2,6 @@ from __future__ import annotations as _annotations
|
|||
|
||||
import inspect
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
|
|
@ -30,109 +29,6 @@ DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
|
|||
TEN_MB_IN_BYTES = 1024 * 1024 * 10
|
||||
|
||||
|
||||
class DocketSettings(BaseSettings):
|
||||
"""Docket worker configuration."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="FASTMCP_DOCKET_",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
name: Annotated[
|
||||
str,
|
||||
Field(
|
||||
description=inspect.cleandoc(
|
||||
"""
|
||||
Name for the Docket queue. All servers/workers sharing the same name
|
||||
and backend URL will share a task queue.
|
||||
"""
|
||||
),
|
||||
),
|
||||
] = "fastmcp"
|
||||
|
||||
url: Annotated[
|
||||
str,
|
||||
Field(
|
||||
description=inspect.cleandoc(
|
||||
"""
|
||||
URL for the Docket backend. Supports:
|
||||
- memory:// - In-memory backend (single process only)
|
||||
- redis://host:port/db - Redis/Valkey backend (distributed, multi-process)
|
||||
|
||||
Example: redis://localhost:6379/0
|
||||
|
||||
Default is memory:// for single-process scenarios. Use Redis or Valkey
|
||||
when coordinating tasks across multiple processes (e.g., additional
|
||||
workers via the fastmcp tasks CLI).
|
||||
"""
|
||||
),
|
||||
),
|
||||
] = "memory://"
|
||||
|
||||
worker_name: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
description=inspect.cleandoc(
|
||||
"""
|
||||
Name for the Docket worker. If None, Docket will auto-generate
|
||||
a unique worker name.
|
||||
"""
|
||||
),
|
||||
),
|
||||
] = None
|
||||
|
||||
concurrency: Annotated[
|
||||
int,
|
||||
Field(
|
||||
description=inspect.cleandoc(
|
||||
"""
|
||||
Maximum number of tasks the worker can process concurrently.
|
||||
"""
|
||||
),
|
||||
),
|
||||
] = 10
|
||||
|
||||
redelivery_timeout: Annotated[
|
||||
timedelta,
|
||||
Field(
|
||||
description=inspect.cleandoc(
|
||||
"""
|
||||
Task redelivery timeout. If a worker doesn't complete
|
||||
a task within this time, the task will be redelivered to another
|
||||
worker.
|
||||
"""
|
||||
),
|
||||
),
|
||||
] = timedelta(seconds=300)
|
||||
|
||||
reconnection_delay: Annotated[
|
||||
timedelta,
|
||||
Field(
|
||||
description=inspect.cleandoc(
|
||||
"""
|
||||
Delay between reconnection attempts when the worker
|
||||
loses connection to the Docket backend.
|
||||
"""
|
||||
),
|
||||
),
|
||||
] = timedelta(seconds=5)
|
||||
|
||||
minimum_check_interval: Annotated[
|
||||
timedelta,
|
||||
Field(
|
||||
description=inspect.cleandoc(
|
||||
"""
|
||||
How frequently the worker polls for new tasks. Lower
|
||||
values reduce latency for task pickup at the cost of
|
||||
more CPU usage. The default of 50ms is a good balance;
|
||||
increase for high-volume production deployments where
|
||||
tasks are long-running.
|
||||
"""
|
||||
),
|
||||
),
|
||||
] = timedelta(milliseconds=50)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""FastMCP settings."""
|
||||
|
||||
|
|
@ -185,8 +81,6 @@ class Settings(BaseSettings):
|
|||
return v.upper()
|
||||
return v
|
||||
|
||||
docket: DocketSettings = DocketSettings()
|
||||
|
||||
enable_rich_logging: Annotated[
|
||||
bool,
|
||||
Field(
|
||||
|
|
@ -287,24 +181,6 @@ class Settings(BaseSettings):
|
|||
),
|
||||
] = 5
|
||||
|
||||
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"
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from typing import (
|
|||
Annotated,
|
||||
Any,
|
||||
ClassVar,
|
||||
overload,
|
||||
)
|
||||
|
||||
import mcp_types
|
||||
|
|
@ -27,7 +26,7 @@ from pydantic.json_schema import SkipJsonSchema
|
|||
from fastmcp.utilities.authorization import AuthCheck
|
||||
from fastmcp.utilities.components import FastMCPComponent
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.tasks import TaskConfig, TaskMeta
|
||||
from fastmcp.utilities.tasks import TaskConfig
|
||||
from fastmcp.utilities.types import (
|
||||
Audio,
|
||||
File,
|
||||
|
|
@ -45,9 +44,6 @@ except ImportError:
|
|||
_HAS_PREFAB = False
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docket import Docket
|
||||
from docket.execution import Execution
|
||||
|
||||
from fastmcp.tools.function_tool import FunctionTool
|
||||
from fastmcp.tools.tool_transform import ArgTransform, TransformedTool
|
||||
|
||||
|
|
@ -416,87 +412,15 @@ class Tool(FastMCPComponent):
|
|||
meta={"fastmcp": {"wrap_result": True}} if wrap_result else None,
|
||||
)
|
||||
|
||||
@overload
|
||||
async def _run(
|
||||
self,
|
||||
arguments: dict[str, Any],
|
||||
task_meta: None = None,
|
||||
) -> ToolResult: ...
|
||||
async def _run(self, arguments: dict[str, Any]) -> ToolResult:
|
||||
"""Server entry point for tool execution.
|
||||
|
||||
@overload
|
||||
async def _run(
|
||||
self,
|
||||
arguments: dict[str, Any],
|
||||
task_meta: TaskMeta,
|
||||
) -> mcp_types.CreateTaskResult: ...
|
||||
|
||||
async def _run(
|
||||
self,
|
||||
arguments: dict[str, Any],
|
||||
task_meta: TaskMeta | None = None,
|
||||
) -> ToolResult | mcp_types.CreateTaskResult:
|
||||
"""Server entry point that handles task routing.
|
||||
|
||||
This allows ANY Tool subclass to support background execution by setting
|
||||
task_config.mode to "supported" or "required". The server calls this
|
||||
method instead of run() directly.
|
||||
|
||||
Args:
|
||||
arguments: Tool arguments
|
||||
task_meta: If provided, execute as background task and return
|
||||
CreateTaskResult. If None (default), execute synchronously and
|
||||
return ToolResult.
|
||||
|
||||
Returns:
|
||||
ToolResult when task_meta is None.
|
||||
CreateTaskResult when task_meta is provided.
|
||||
|
||||
Subclasses can override this to customize task routing behavior.
|
||||
For example, FastMCPProviderTool overrides to delegate to child
|
||||
middleware without submitting to Docket.
|
||||
The server calls this method instead of ``run()`` directly so that
|
||||
subclasses can customize dispatch. For example, ``FastMCPProviderTool``
|
||||
overrides this to delegate to child-server middleware.
|
||||
"""
|
||||
from fastmcp.server.tasks.routing import check_background_task
|
||||
|
||||
task_result = await check_background_task(
|
||||
component=self,
|
||||
task_type="tool",
|
||||
arguments=arguments,
|
||||
task_meta=task_meta,
|
||||
)
|
||||
if task_result:
|
||||
return task_result
|
||||
|
||||
return await self.run(arguments)
|
||||
|
||||
def register_with_docket(self, docket: Docket) -> None:
|
||||
"""Register this tool with docket for background execution."""
|
||||
if not self.task_config.supports_tasks():
|
||||
return
|
||||
docket.register(self.run, names=[self.key])
|
||||
|
||||
async def add_to_docket( # type: ignore[override]
|
||||
self,
|
||||
docket: Docket,
|
||||
arguments: dict[str, Any],
|
||||
*,
|
||||
fn_key: str | None = None,
|
||||
task_key: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Execution:
|
||||
"""Schedule this tool for background execution via docket.
|
||||
|
||||
Args:
|
||||
docket: The Docket instance
|
||||
arguments: Tool arguments
|
||||
fn_key: Function lookup key in Docket registry (defaults to self.key)
|
||||
task_key: Redis storage key for the result
|
||||
**kwargs: Additional kwargs passed to docket.add()
|
||||
"""
|
||||
lookup_key = fn_key or self.key
|
||||
if task_key:
|
||||
kwargs["key"] = task_key
|
||||
return await docket.add(lookup_key, **kwargs)(arguments)
|
||||
|
||||
@classmethod
|
||||
def from_tool(
|
||||
cls,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ from dataclasses import dataclass, field
|
|||
from functools import lru_cache
|
||||
from types import MethodType
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Annotated,
|
||||
Any,
|
||||
Literal,
|
||||
|
|
@ -53,10 +52,6 @@ from fastmcp.utilities.types import (
|
|||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docket import Docket
|
||||
from docket.execution import Execution
|
||||
|
||||
|
||||
class _ToolBodyError(Exception):
|
||||
"""Marks a ``pydantic.ValidationError`` raised while executing a tool's body.
|
||||
|
|
@ -511,88 +506,6 @@ class FunctionTool(Tool):
|
|||
return list(result)
|
||||
return result
|
||||
|
||||
def register_with_docket(self, docket: Docket) -> None:
|
||||
"""Register this tool with docket for background execution.
|
||||
|
||||
Registers the raw function so Docket sees and resolves ALL
|
||||
dependencies — both FastMCP's (CurrentContext, Progress) and
|
||||
Docket-native ones (Retry, Timeout, ConcurrencyLimit).
|
||||
"""
|
||||
if not self.task_config.supports_tasks():
|
||||
return
|
||||
docket.register(self.fn, names=[self.key])
|
||||
|
||||
async def add_to_docket(
|
||||
self,
|
||||
docket: Docket,
|
||||
arguments: dict[str, Any],
|
||||
*,
|
||||
fn_key: str | None = None,
|
||||
task_key: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Execution:
|
||||
"""Schedule this tool for background execution via docket.
|
||||
|
||||
FunctionTool splats the arguments dict since .fn expects **kwargs.
|
||||
|
||||
Args:
|
||||
docket: The Docket instance
|
||||
arguments: Tool arguments
|
||||
fn_key: Function lookup key in Docket registry (defaults to self.key)
|
||||
task_key: Redis storage key for the result
|
||||
**kwargs: Additional kwargs passed to docket.add()
|
||||
"""
|
||||
lookup_key = fn_key or self.key
|
||||
if task_key:
|
||||
kwargs["key"] = task_key
|
||||
return await docket.add(lookup_key, **kwargs)(**arguments)
|
||||
|
||||
def coerce_task_arguments(
|
||||
self, arguments: dict[str, Any], *, strict: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Validate client arguments against their declared parameter types.
|
||||
|
||||
The synchronous ``run()`` path validates arguments through the
|
||||
function's Pydantic TypeAdapter, so a parameter typed as a model
|
||||
arrives as a model instance. The task path hands the raw arguments to
|
||||
Docket, which binds them to the function signature without coercion —
|
||||
so without this a model-typed parameter would reach the function as a
|
||||
raw dict (#4349). ``submit_to_docket`` calls this up front so coerced
|
||||
values are what get queued, and validation errors surface before any
|
||||
task state is created. Coerced values survive the trip to the worker
|
||||
because Docket serializes task arguments with cloudpickle.
|
||||
|
||||
``strict`` mirrors the synchronous path's ``strict_input_validation``
|
||||
handling: when set, arguments are validated in strict mode so lax
|
||||
coercions (e.g. the string ``"1"`` into an ``int``) are rejected at
|
||||
submission rather than silently coerced and queued.
|
||||
|
||||
Injected dependency parameters (Context, Depends()) are excluded via
|
||||
the same wrapper used by the synchronous path, so only client-supplied
|
||||
arguments are coerced and Docket's dependency resolution is untouched.
|
||||
"""
|
||||
from fastmcp.server.dependencies import without_injected_parameters
|
||||
|
||||
wrapper_fn = without_injected_parameters(
|
||||
self.fn, run_in_thread=self.run_in_thread
|
||||
)
|
||||
hints = _resolve_param_hints(wrapper_fn)
|
||||
|
||||
coerced = dict(arguments)
|
||||
for name, value in arguments.items():
|
||||
annotation = hints.get(name)
|
||||
if annotation is None:
|
||||
continue
|
||||
adapter = get_cached_typeadapter(annotation)
|
||||
try:
|
||||
coerced[name] = adapter.validate_python(value, strict=strict)
|
||||
except PydanticValidationError as e:
|
||||
# Argument coercion failure on the task path is a bad call, just
|
||||
# like the synchronous path — surface it as fastmcp's
|
||||
# ValidationError so it is classified consistently (see #4128).
|
||||
raise ValidationError(str(e), log_level=logging.WARNING) from e
|
||||
return coerced
|
||||
|
||||
|
||||
@overload
|
||||
def tool(fn: F) -> F: ...
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, TypedDict, cast
|
||||
from typing import Annotated, Any, ClassVar, TypedDict, cast
|
||||
|
||||
from mcp_types import Icon
|
||||
from pydantic import BeforeValidator, Field
|
||||
|
|
@ -10,10 +10,6 @@ from typing_extensions import Self, TypeVar
|
|||
from fastmcp.utilities.tasks import TaskConfig
|
||||
from fastmcp.utilities.types import FastMCPBaseModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docket import Docket
|
||||
from docket.execution import Execution
|
||||
|
||||
T = TypeVar("T", default=Any)
|
||||
|
||||
|
||||
|
|
@ -118,7 +114,11 @@ class FastMCPComponent(FastMCPBaseModel):
|
|||
)
|
||||
task_config: Annotated[
|
||||
TaskConfig,
|
||||
Field(description="Background task execution configuration (SEP-1686)."),
|
||||
Field(
|
||||
description="Background task execution configuration (SEP-2663). "
|
||||
"Only tools support task execution; other component types always "
|
||||
"carry the default 'forbidden' config."
|
||||
),
|
||||
] = Field(default_factory=lambda: TaskConfig(mode="forbidden"))
|
||||
|
||||
@classmethod
|
||||
|
|
@ -224,56 +224,6 @@ class FastMCPComponent(FastMCPBaseModel):
|
|||
"""Create a copy of the component."""
|
||||
return self.model_copy()
|
||||
|
||||
def register_with_docket(self, docket: Docket) -> None:
|
||||
"""Register this component with docket for background execution.
|
||||
|
||||
No-ops if task_config.mode is "forbidden". Subclasses override to
|
||||
register their callable (self.run, self.read, self.render, or self.fn).
|
||||
"""
|
||||
# Base implementation: no-op (subclasses override)
|
||||
|
||||
def coerce_task_arguments(
|
||||
self, arguments: dict[str, Any], *, strict: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Validate and coerce task arguments before any task state is created.
|
||||
|
||||
Called by ``submit_to_docket`` up front, so invalid inputs raise before
|
||||
the task's Redis metadata and initial status notification exist —
|
||||
otherwise a coercion failure during queueing would orphan a task the
|
||||
client has already observed. The base implementation is a no-op;
|
||||
components that splat arguments into a typed Python callable (e.g.
|
||||
``FunctionTool``) override this to mirror the synchronous validation
|
||||
path.
|
||||
|
||||
When ``strict`` is set (server-level ``strict_input_validation``),
|
||||
overrides validate in strict mode so the task path rejects lax
|
||||
coercions (e.g. the string ``"1"`` into an ``int``) exactly as the
|
||||
synchronous call path does.
|
||||
"""
|
||||
return arguments
|
||||
|
||||
async def add_to_docket(
|
||||
self, docket: Docket, *args: Any, **kwargs: Any
|
||||
) -> Execution:
|
||||
"""Schedule this component for background execution via docket.
|
||||
|
||||
Subclasses override this to handle their specific calling conventions:
|
||||
- Tool: add_to_docket(docket, arguments: dict, **kwargs)
|
||||
- Resource: add_to_docket(docket, **kwargs)
|
||||
- ResourceTemplate: add_to_docket(docket, params: dict, **kwargs)
|
||||
- Prompt: add_to_docket(docket, arguments: dict | None, **kwargs)
|
||||
|
||||
The **kwargs are passed through to docket.add() (e.g., key=task_key).
|
||||
"""
|
||||
if not self.task_config.supports_tasks():
|
||||
raise RuntimeError(
|
||||
f"Cannot add {self.__class__.__name__} '{self.name}' to docket: "
|
||||
f"task execution not supported"
|
||||
)
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} does not implement add_to_docket()"
|
||||
)
|
||||
|
||||
def get_span_attributes(self) -> dict[str, Any]:
|
||||
"""Return span attributes for telemetry.
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,13 @@ from fastmcp.utilities.async_utils import is_coroutine_function
|
|||
|
||||
TaskMode = Literal["forbidden", "optional", "required"]
|
||||
|
||||
#: Reverse-DNS identifier of the SEP-2663 tasks extension. A tool declared with
|
||||
#: ``task=True`` requires an extension with this identifier to be registered on
|
||||
#: the server (``mcp.add_extension(...)``); the ``fastmcp-tasks`` package
|
||||
#: provides it. Kept here as pure declaration so core can check for the
|
||||
#: extension without importing the tasks package.
|
||||
TASKS_EXTENSION_ID = "io.modelcontextprotocol/tasks"
|
||||
|
||||
DEFAULT_POLL_INTERVAL = timedelta(seconds=5)
|
||||
DEFAULT_POLL_INTERVAL_MS = int(DEFAULT_POLL_INTERVAL.total_seconds() * 1000)
|
||||
DEFAULT_TTL_MS = 60_000
|
||||
|
|
@ -59,10 +66,6 @@ class TaskConfig:
|
|||
if not self.supports_tasks():
|
||||
return
|
||||
|
||||
from fastmcp.server.dependencies import require_docket
|
||||
|
||||
require_docket(f"`task=True` on function '{name}'")
|
||||
|
||||
fn_to_check = fn
|
||||
if (
|
||||
not inspect.isroutine(fn)
|
||||
|
|
|
|||
|
|
@ -108,4 +108,3 @@ server = [
|
|||
"watchfiles>=1.0.0",
|
||||
"websockets>=15.0.1",
|
||||
]
|
||||
tasks = ["pydocket>=0.20.0"]
|
||||
|
|
|
|||
83
fastmcp_tasks/README.md
Normal file
83
fastmcp_tasks/README.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# fastmcp-tasks
|
||||
|
||||
A complete implementation of background tasks for the Model Context Protocol — the `io.modelcontextprotocol/tasks` extension defined in [SEP-2663](https://github.com/modelcontextprotocol/ext-tasks).
|
||||
|
||||
The MCP tasks extension is a Final SEP, but as of this writing it ships in the ecosystem as a schema and a prose specification — no language SDK provides a working runtime for it. `fastmcp-tasks` is, to our knowledge, the first: a full server-side implementation of the protocol, built on the durable execution engine ([docket](https://github.com/chrisguidry/docket)) that FastMCP has run in production since v3. If you want to actually *run* MCP background tasks today, this is the implementation.
|
||||
|
||||
## What background tasks are
|
||||
|
||||
Most tool calls are synchronous: the client sends `tools/call` and holds the request open until the tool returns. That breaks down for work that takes minutes or hours — a long analysis, a batch job, a slow external API. The tasks extension lets a server answer such a call *immediately* with a durable task handle, then run the work in the background while the client polls for completion on its own schedule.
|
||||
|
||||
The model is poll-based and stateless by construction, which is what makes it survive disconnects, server restarts, and load balancers:
|
||||
|
||||
1. A client that supports tasks issues a normal `tools/call` with a per-request opt-in.
|
||||
2. The server decides whether to run it as a task. If it does, it returns a `CreateTaskResult` carrying a server-generated task id — right away, before the work starts.
|
||||
3. The client polls `tasks/get` until the task reaches a terminal state, then reads the result inlined in the response.
|
||||
4. `tasks/cancel` requests cancellation; `tasks/update` answers any input the task asks for mid-run.
|
||||
|
||||
The server owns the task's durable state, so the client can poll across independent requests — from any process, after a crash, through any replica — with no session affinity required.
|
||||
|
||||
## Usage
|
||||
|
||||
Install it as the `tasks` extra on FastMCP:
|
||||
|
||||
```bash
|
||||
uv pip install "fastmcp[tasks]"
|
||||
```
|
||||
|
||||
Register the extension on your server and mark the tools that may run as tasks. The extension is where the backend is configured — point it at Redis for a distributed deployment, or leave it on the in-memory default for a single process:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp_tasks import TasksExtension
|
||||
|
||||
mcp = FastMCP("Analytics")
|
||||
mcp.add_extension(TasksExtension(url="redis://localhost:6379/0"))
|
||||
|
||||
|
||||
@mcp.tool(task=True)
|
||||
async def analyze(dataset: str) -> str:
|
||||
# Long-running work. The client gets a task handle immediately and
|
||||
# polls for the result; this runs in a background worker.
|
||||
...
|
||||
```
|
||||
|
||||
`task=True` is a declaration of intent — this tool *may* run as a task — while the server, per the spec, decides per call whether to actually task it. Use `TaskConfig` for finer control:
|
||||
|
||||
```python
|
||||
from fastmcp.utilities.tasks import TaskConfig
|
||||
|
||||
|
||||
@mcp.tool(task=TaskConfig(mode="required"))
|
||||
async def must_run_async(n: int) -> int:
|
||||
# Always runs as a task; a client that has not opted in is told so.
|
||||
...
|
||||
```
|
||||
|
||||
Registering `TasksExtension` is required to serve `task=True` tools — the tool declares intent, the extension provides the engine. A `task=True` tool on a server with no tasks extension registered fails loudly at startup rather than silently running inline.
|
||||
|
||||
### Running out-of-process workers
|
||||
|
||||
For distributed deployments backed by Redis, run dedicated worker processes alongside your server:
|
||||
|
||||
```bash
|
||||
python -m fastmcp_tasks.worker_cli worker server.py
|
||||
```
|
||||
|
||||
Workers and servers that share a backend URL and queue name share a task queue, so you can scale execution independently of your request-serving frontends.
|
||||
|
||||
## Configuration
|
||||
|
||||
The backend is configured on the extension. Every option also has a `FASTMCP_DOCKET_*` environment variable, so an env-configured deployment can construct `TasksExtension()` with no arguments:
|
||||
|
||||
| Option | Env var | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `url` | `FASTMCP_DOCKET_URL` | `memory://` | Backend URL. `memory://` for single-process; `redis://host:port/db` for distributed workers. |
|
||||
| `name` | `FASTMCP_DOCKET_NAME` | `fastmcp` | Queue name. Servers and workers sharing a name and URL share a queue. |
|
||||
| `concurrency` | `FASTMCP_DOCKET_CONCURRENCY` | `10` | Maximum concurrent tasks per worker. |
|
||||
|
||||
See the [FastMCP task documentation](https://gofastmcp.com/servers/tasks) for the full reference.
|
||||
|
||||
## Status
|
||||
|
||||
The tasks extension is an experimental MCP extension, and `fastmcp-tasks` tracks its draft schema. The protocol's shape is settled — SEP-2663 is Final — but field-level details may still move; this package versions independently so it can follow the schema without waiting on a FastMCP release.
|
||||
20
fastmcp_tasks/fastmcp_tasks/__init__.py
Normal file
20
fastmcp_tasks/fastmcp_tasks/__init__.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""Background task execution for FastMCP via the SEP-2663 tasks extension."""
|
||||
|
||||
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:
|
||||
__version__ = version("fastmcp-tasks")
|
||||
except PackageNotFoundError:
|
||||
__version__ = "0.0.0"
|
||||
|
||||
# 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__"]
|
||||
567
fastmcp_tasks/fastmcp_tasks/client.py
Normal file
567
fastmcp_tasks/fastmcp_tasks/client.py
Normal file
|
|
@ -0,0 +1,567 @@
|
|||
"""SEP-2663 client task support: the tasks extension, resolver, and handle.
|
||||
|
||||
FastMCP drives a server's background tasks transparently. When a `task=True`
|
||||
tool runs a call as a task, the server answers `tools/call` with a claimed
|
||||
`CreateTaskResult` (SEP-2133) instead of the tool's result. This module supplies
|
||||
the client half:
|
||||
|
||||
- `TasksClientExtension` advertises the tasks capability (so the server *may*
|
||||
task the call) and declares a `ResultClaim` for `resultType: "task"`. It is
|
||||
registered on every FastMCP `Client` automatically, so the caller opts in to
|
||||
nothing.
|
||||
- The claim's resolver polls `tasks/get` to completion under the hood and returns
|
||||
the tool's real result as a `CallToolResult` — the caller of `call_tool` never
|
||||
learns the call was tasked. A task that pauses for input is answered through the
|
||||
client's `elicitation_handler` via `tasks/update`, then polling resumes.
|
||||
- `ToolTask` is the explicit handle for callers who want to return immediately and
|
||||
drive the task themselves (`status`/`wait`/`result`/`cancel`), built via
|
||||
`call_tool_task`.
|
||||
|
||||
Tasks are modern-protocol only: on a legacy connection the SDK strips the
|
||||
capability ad, the server never tasks, and this extension is inert.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import mcp_types
|
||||
from mcp.client.extension import ClaimContext, ClientExtension, ResultClaim
|
||||
from mcp.client.session import ClientRequestContext, ClientSession, ElicitationFnT
|
||||
from mcp_types import CallToolResult
|
||||
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
|
||||
|
||||
from fastmcp.client.telemetry import client_span
|
||||
from fastmcp.exceptions import ToolError
|
||||
from fastmcp.telemetry import inject_trace_context
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.tasks import TASKS_EXTENSION_ID
|
||||
from fastmcp.utilities.timeout import normalize_timeout_to_seconds
|
||||
from fastmcp_tasks.client_models import (
|
||||
CancelTaskRequest,
|
||||
CancelTaskRequestParams,
|
||||
ClientCreateTaskResult,
|
||||
ClientGetTaskResult,
|
||||
GetTaskRequest,
|
||||
GetTaskRequestParams,
|
||||
UpdateTaskRequest,
|
||||
UpdateTaskRequestParams,
|
||||
)
|
||||
from fastmcp_tasks.settings import client_settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.client.client import CallToolResult as FastMCPCallToolResult
|
||||
from fastmcp.client.client import Client
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
#: Floor for the fallback poll interval (seconds). When the server does not
|
||||
#: advertise a `pollIntervalMs`, each drive starts its backoff ramp here so quick
|
||||
#: tasks resolve fast; when it does advertise one, this floors it so a server
|
||||
#: sending `0` cannot spin the client in a tight loop.
|
||||
MIN_POLL_INTERVAL = 0.02
|
||||
|
||||
_TERMINAL_STATES = frozenset({"completed", "failed", "cancelled"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wire senders (tasks/get, tasks/update, tasks/cancel) over a ClientSession
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _trace_meta() -> mcp_types.RequestParamsMeta | None:
|
||||
"""Trace context for a task management request, for the current client span.
|
||||
|
||||
Task management calls (`tasks/get`/`update`/`cancel`) use ordinary
|
||||
client-to-server trace propagation, so their server-side spans nest under
|
||||
the client span rather than becoming disconnected trace roots.
|
||||
"""
|
||||
return cast("mcp_types.RequestParamsMeta | None", inject_trace_context(None))
|
||||
|
||||
|
||||
async def _send_get(
|
||||
session: ClientSession,
|
||||
task_id: str,
|
||||
read_timeout_seconds: float | None = None,
|
||||
) -> ClientGetTaskResult:
|
||||
"""Send `tasks/get` and parse the detailed task response."""
|
||||
with client_span("tasks/get", "tasks/get", task_id):
|
||||
request = GetTaskRequest(
|
||||
params=GetTaskRequestParams(task_id=task_id, meta=_trace_meta())
|
||||
)
|
||||
return await session.send_request(
|
||||
request,
|
||||
ClientGetTaskResult,
|
||||
request_read_timeout_seconds=read_timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
async def _send_update(
|
||||
session: ClientSession,
|
||||
task_id: str,
|
||||
input_responses: dict[str, Any],
|
||||
read_timeout_seconds: float | None = None,
|
||||
) -> None:
|
||||
"""Send `tasks/update` delivering the caller's answers to a parked task."""
|
||||
with client_span("tasks/update", "tasks/update", task_id):
|
||||
request = UpdateTaskRequest(
|
||||
params=UpdateTaskRequestParams(
|
||||
task_id=task_id, input_responses=input_responses, meta=_trace_meta()
|
||||
)
|
||||
)
|
||||
await session.send_request(
|
||||
request, mcp_types.Result, request_read_timeout_seconds=read_timeout_seconds
|
||||
)
|
||||
|
||||
|
||||
async def _send_cancel(
|
||||
session: ClientSession,
|
||||
task_id: str,
|
||||
read_timeout_seconds: float | None = None,
|
||||
) -> None:
|
||||
"""Send `tasks/cancel` to cooperatively cancel a task."""
|
||||
with client_span("tasks/cancel", "tasks/cancel", task_id):
|
||||
request = CancelTaskRequest(
|
||||
params=CancelTaskRequestParams(task_id=task_id, meta=_trace_meta())
|
||||
)
|
||||
await session.send_request(
|
||||
request, mcp_types.Result, request_read_timeout_seconds=read_timeout_seconds
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Poll cadence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _poll_ceiling(poll_interval_ms: float | None) -> float:
|
||||
"""The upper bound for the poll backoff, in seconds.
|
||||
|
||||
A server-advertised `pollIntervalMs` is a deliberate statement about how much
|
||||
load the server wants to take, so it caps the backoff. A zero, negative, or
|
||||
absent value falls back to the `poll_interval` client setting; the ceiling is
|
||||
never below `MIN_POLL_INTERVAL` so a hostile `0` cannot spin the client.
|
||||
"""
|
||||
if poll_interval_ms is not None and poll_interval_ms > 0:
|
||||
return max(poll_interval_ms / 1000, MIN_POLL_INTERVAL)
|
||||
return client_settings.poll_interval
|
||||
|
||||
|
||||
def _next_poll_delay(
|
||||
poll_interval_ms: float | None, backoff: float
|
||||
) -> tuple[float, float]:
|
||||
"""Delay before the next poll, plus the backoff for the round after.
|
||||
|
||||
With no status notifications on the modern protocol, polling is the only
|
||||
signal, so a fixed cadence at the server's advertised interval would make a
|
||||
quick task take that full interval to observe as done. Instead the backoff
|
||||
ramps from `MIN_POLL_INTERVAL`, doubling each round up to the ceiling
|
||||
(`_poll_ceiling`): a quick task resolves in ~20ms while a long one settles to
|
||||
the server's advertised cadence, hammering neither.
|
||||
"""
|
||||
ceiling = _poll_ceiling(poll_interval_ms)
|
||||
return min(backoff, ceiling), min(backoff * 2, ceiling)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-task input: answer a parked task's requests via the elicitation handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _answer_input_requests(
|
||||
session: ClientSession,
|
||||
task_id: str,
|
||||
input_requests: dict[str, Any],
|
||||
elicitation_callback: ElicitationFnT | None,
|
||||
read_timeout_seconds: float | None = None,
|
||||
) -> None:
|
||||
"""Answer a task's outstanding input requests, then deliver via `tasks/update`.
|
||||
|
||||
Each request is surfaced by a server-minted key and carries a serialized
|
||||
`ElicitRequest`. The client's elicitation handler produces each answer; the
|
||||
keyed answers are sent back with `tasks/update`, which re-enters the task.
|
||||
Sampling and roots requests are not supported on the modern protocol.
|
||||
"""
|
||||
if elicitation_callback is None:
|
||||
raise ToolError(
|
||||
f"Task {task_id} requires input but the client has no elicitation "
|
||||
"handler; pass elicitation_handler= to Client() to drive tasks that "
|
||||
"ask for input."
|
||||
)
|
||||
|
||||
# Bound the whole answer phase — elicitation callbacks included — by the
|
||||
# call's remaining budget: a stalled handler must not outlast `timeout=N`
|
||||
# any more than a stalled poll does, matching the synchronous path.
|
||||
loop = asyncio.get_event_loop()
|
||||
deadline = (
|
||||
None if read_timeout_seconds is None else loop.time() + read_timeout_seconds
|
||||
)
|
||||
|
||||
def _remaining() -> float | None:
|
||||
if deadline is None:
|
||||
return None
|
||||
left = deadline - loop.time()
|
||||
if left <= 0:
|
||||
raise TimeoutError(f"Task {task_id} timed out awaiting input")
|
||||
return left
|
||||
|
||||
responses: dict[str, Any] = {}
|
||||
for surfaced_key, payload in input_requests.items():
|
||||
method = payload.get("method") if isinstance(payload, dict) else None
|
||||
if method != "elicitation/create":
|
||||
raise ToolError(
|
||||
f"Task {task_id} requested in-task input via {method!r}, which the "
|
||||
"client cannot answer; only elicitation is supported on the modern "
|
||||
"protocol (sampling and roots are deprecated)."
|
||||
)
|
||||
request = mcp_types.ElicitRequest.model_validate(payload)
|
||||
context = ClientRequestContext(
|
||||
session=session, request_id=f"task-{task_id}-{surfaced_key}"
|
||||
)
|
||||
budget = _remaining()
|
||||
call = elicitation_callback(context, request.params)
|
||||
try:
|
||||
answer = await (
|
||||
asyncio.wait_for(call, budget) if budget is not None else call
|
||||
)
|
||||
except asyncio.TimeoutError as exc:
|
||||
# Normalize to the builtin: on Python 3.10 `asyncio.wait_for` raises
|
||||
# `asyncio.TimeoutError`, a distinct type from the builtin the rest of
|
||||
# the drive raises (they were unified in 3.11).
|
||||
raise TimeoutError(f"Task {task_id} timed out awaiting input") from exc
|
||||
if isinstance(answer, mcp_types.ErrorData):
|
||||
raise ToolError(f"Elicitation for task {task_id} failed: {answer.message}")
|
||||
responses[surfaced_key] = answer.model_dump(
|
||||
by_alias=True, mode="json", exclude_none=True
|
||||
)
|
||||
|
||||
await _send_update(session, task_id, responses, _remaining())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The shared poll loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _drive_to_terminal(
|
||||
session: ClientSession,
|
||||
task_id: str,
|
||||
elicitation_callback: ElicitationFnT | None,
|
||||
timeout_seconds: float | None = None,
|
||||
) -> ClientGetTaskResult:
|
||||
"""Poll `tasks/get` until the task reaches a terminal state.
|
||||
|
||||
`working` sleeps and polls again; `input_required` answers the outstanding
|
||||
requests through the elicitation handler and re-enters; a terminal state
|
||||
(completed / failed / cancelled) is returned. Shared by the transparent
|
||||
resolver and `ToolTask.result()`.
|
||||
|
||||
`timeout_seconds`, when set, is one deadline for the *entire* drive — not a
|
||||
per-request timeout. The synchronous path aborts a `tools/call` once total
|
||||
execution exceeds the call's timeout, so the tasked path must too: each poll
|
||||
and sleep is bounded by the time remaining, and a `TimeoutError` is raised
|
||||
once the deadline passes. `None` drives to completion unbounded (the default
|
||||
for `ToolTask.result()`, whose caller bounds waiting via `wait(timeout=...)`).
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
deadline = None if timeout_seconds is None else loop.time() + timeout_seconds
|
||||
backoff = MIN_POLL_INTERVAL
|
||||
|
||||
def remaining() -> float | None:
|
||||
return None if deadline is None else deadline - loop.time()
|
||||
|
||||
while True:
|
||||
budget = remaining()
|
||||
if budget is not None and budget <= 0:
|
||||
raise TimeoutError(
|
||||
f"Task {task_id} did not finish within {timeout_seconds}s"
|
||||
)
|
||||
|
||||
current = await _send_get(session, task_id, budget)
|
||||
if current.status in _TERMINAL_STATES:
|
||||
return current
|
||||
if current.status == "input_required":
|
||||
await _answer_input_requests(
|
||||
session,
|
||||
task_id,
|
||||
current.input_requests or {},
|
||||
elicitation_callback,
|
||||
remaining(),
|
||||
)
|
||||
backoff = MIN_POLL_INTERVAL
|
||||
continue
|
||||
# working
|
||||
delay, backoff = _next_poll_delay(current.poll_interval_ms, backoff)
|
||||
budget = remaining()
|
||||
if budget is not None:
|
||||
delay = min(delay, budget)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
|
||||
def _inlined_call_tool_result(result: dict[str, Any] | None) -> CallToolResult:
|
||||
"""Parse a completed task's inlined result dict into a `CallToolResult`."""
|
||||
return CallToolResult.model_validate(result or {})
|
||||
|
||||
|
||||
def _terminal_error_message(final: ClientGetTaskResult) -> str:
|
||||
"""The best available error message for a failed task."""
|
||||
if isinstance(final.error, dict):
|
||||
message = final.error.get("message")
|
||||
if isinstance(message, str) and message:
|
||||
return message
|
||||
if final.status_message:
|
||||
return final.status_message
|
||||
return f"Task {final.task_id} failed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The tasks client extension and its claim resolver
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TasksClientExtension(ClientExtension):
|
||||
"""The client half of the `io.modelcontextprotocol/tasks` extension (SEP-2663).
|
||||
|
||||
Advertising this extension tells the server the client can drive tasks, so a
|
||||
`task=True` tool may run as a task; the declared `ResultClaim` then resolves
|
||||
the `CreateTaskResult` the server returns by polling `tasks/get` to the real
|
||||
result. Registered automatically on every FastMCP `Client`.
|
||||
"""
|
||||
|
||||
identifier = TASKS_EXTENSION_ID
|
||||
|
||||
def __init__(self, elicitation_callback: ElicitationFnT | None = None) -> None:
|
||||
self._elicitation_callback = elicitation_callback
|
||||
|
||||
def settings(self) -> dict[str, Any]:
|
||||
"""The tasks extension advertises no per-extension settings."""
|
||||
return {}
|
||||
|
||||
def claims(self) -> Sequence[ResultClaim[Any]]:
|
||||
return (
|
||||
ResultClaim(
|
||||
result_type="task",
|
||||
model=ClientCreateTaskResult,
|
||||
resolve=self._resolve_task,
|
||||
protocol_versions=frozenset(MODERN_PROTOCOL_VERSIONS),
|
||||
),
|
||||
)
|
||||
|
||||
async def _resolve_task(
|
||||
self, create_result: ClientCreateTaskResult, ctx: ClaimContext
|
||||
) -> CallToolResult:
|
||||
"""Finish a tasked `tools/call` by polling `tasks/get` to completion.
|
||||
|
||||
Returns the tool's real result on completion; a failed or cancelled task
|
||||
becomes an error `CallToolResult` so the ordinary `call_tool` error path
|
||||
(raise `ToolError`) applies uniformly, and the completed inlined result is
|
||||
schema-valid so the SDK's output-schema revalidation passes.
|
||||
"""
|
||||
final = await _drive_to_terminal(
|
||||
ctx.session,
|
||||
create_result.task_id,
|
||||
self._elicitation_callback,
|
||||
ctx.read_timeout_seconds,
|
||||
)
|
||||
if final.status == "completed":
|
||||
return _inlined_call_tool_result(final.result)
|
||||
if final.status == "failed":
|
||||
message = _terminal_error_message(final)
|
||||
else:
|
||||
message = f"Task {final.task_id} was cancelled"
|
||||
return CallToolResult(
|
||||
content=[mcp_types.TextContent(type="text", text=message)],
|
||||
is_error=True,
|
||||
)
|
||||
|
||||
|
||||
def _build_tasks_client_extension(
|
||||
elicitation_callback: ElicitationFnT | None,
|
||||
) -> ClientExtension:
|
||||
"""Factory registered with core so every `Client` folds in task support."""
|
||||
return TasksClientExtension(elicitation_callback)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The explicit task handle (return-quickly surface)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ToolTask:
|
||||
"""A handle to a tool call the server is running as a background task.
|
||||
|
||||
Returned by `call_tool_task`. Lets a caller return immediately and then drive
|
||||
the task: check `status`, `wait` for a state, get the finished `result`
|
||||
(answering any input prompts through the client's elicitation handler), or
|
||||
`cancel`. Awaiting the handle is shorthand for `result()`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: Client,
|
||||
tool_name: str,
|
||||
create_result: ClientCreateTaskResult,
|
||||
*,
|
||||
raise_on_error: bool = True,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._tool_name = tool_name
|
||||
self._create_result = create_result
|
||||
self._raise_on_error = raise_on_error
|
||||
self._cached_result: FastMCPCallToolResult | None = None
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
"""The server-generated task id."""
|
||||
return self._create_result.task_id
|
||||
|
||||
@property
|
||||
def create_result(self) -> ClientCreateTaskResult:
|
||||
"""The raw `CreateTaskResult` the server returned for the tasked call."""
|
||||
return self._create_result
|
||||
|
||||
@property
|
||||
def _session(self) -> ClientSession:
|
||||
return self._client.session
|
||||
|
||||
@property
|
||||
def _elicitation_callback(self) -> ElicitationFnT | None:
|
||||
return self._client._elicitation_callback
|
||||
|
||||
async def status(self) -> ClientGetTaskResult:
|
||||
"""Fetch the task's current status via `tasks/get`."""
|
||||
return await _send_get(self._session, self.task_id)
|
||||
|
||||
async def wait(
|
||||
self, *, state: str | None = None, timeout: float = 300.0
|
||||
) -> ClientGetTaskResult:
|
||||
"""Poll until the task reaches `state` (or any terminal state if `None`).
|
||||
|
||||
Does not answer input prompts: a caller that wants automatic answering
|
||||
should use `result()`. `wait(state="input_required")` lets a caller
|
||||
observe the parked state and answer it manually.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
deadline = loop.time() + timeout
|
||||
backoff = MIN_POLL_INTERVAL
|
||||
while True:
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError(
|
||||
f"Task {self.task_id} did not reach "
|
||||
f"{state or 'a terminal state'} within {timeout}s"
|
||||
)
|
||||
# Bound the request itself by the remaining deadline: a stalled
|
||||
# `tasks/get` must not block past the caller's timeout waiting for
|
||||
# the session-wide default before the deadline is next checked.
|
||||
current = await _send_get(
|
||||
self._session, self.task_id, read_timeout_seconds=remaining
|
||||
)
|
||||
if state is not None:
|
||||
if current.status == state:
|
||||
return current
|
||||
elif current.status in _TERMINAL_STATES:
|
||||
return current
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError(
|
||||
f"Task {self.task_id} did not reach "
|
||||
f"{state or 'a terminal state'} within {timeout}s"
|
||||
)
|
||||
delay, backoff = _next_poll_delay(current.poll_interval_ms, backoff)
|
||||
# Never sleep past the deadline, so `wait` returns on time rather
|
||||
# than up to one poll interval late.
|
||||
await asyncio.sleep(min(delay, remaining))
|
||||
|
||||
async def result(self) -> FastMCPCallToolResult:
|
||||
"""Drive the task to completion and return its parsed result.
|
||||
|
||||
Answers any input prompts through the client's elicitation handler.
|
||||
Raises `ToolError` on a failed or cancelled task when `raise_on_error`
|
||||
(the default); otherwise returns an error result. The result is cached, so
|
||||
repeated calls return the same object.
|
||||
"""
|
||||
if self._cached_result is not None:
|
||||
return self._cached_result
|
||||
|
||||
final = await _drive_to_terminal(
|
||||
self._session, self.task_id, self._elicitation_callback
|
||||
)
|
||||
if final.status == "completed":
|
||||
mcp_result = _inlined_call_tool_result(final.result)
|
||||
else:
|
||||
if final.status == "failed":
|
||||
message = _terminal_error_message(final)
|
||||
else:
|
||||
message = f"Task {self.task_id} was cancelled"
|
||||
if self._raise_on_error:
|
||||
raise ToolError(message)
|
||||
mcp_result = CallToolResult(
|
||||
content=[mcp_types.TextContent(type="text", text=message)],
|
||||
is_error=True,
|
||||
)
|
||||
|
||||
parsed = await self._client._parse_call_tool_result(
|
||||
self._tool_name, mcp_result, raise_on_error=self._raise_on_error
|
||||
)
|
||||
self._cached_result = parsed
|
||||
return parsed
|
||||
|
||||
async def cancel(self) -> None:
|
||||
"""Request cooperative cancellation of the task via `tasks/cancel`."""
|
||||
await _send_cancel(self._session, self.task_id)
|
||||
|
||||
def __await__(self):
|
||||
return self.result().__await__()
|
||||
|
||||
|
||||
async def call_tool_task(
|
||||
client: Client,
|
||||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
timeout: float | int | None = None,
|
||||
raise_on_error: bool = True,
|
||||
version: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> ToolTask:
|
||||
"""Call a tool as a background task and return a `ToolTask` handle immediately.
|
||||
|
||||
Unlike `client.call_tool` (which polls to completion transparently), this
|
||||
returns as soon as the server accepts the task, so the caller can do other
|
||||
work and drive the task through the handle. Requires the server to run the
|
||||
call as a task (a `task=True` tool on a task-serving backend); a call the
|
||||
server runs synchronously raises `ToolError`.
|
||||
|
||||
`version` targets a specific component version, the same as
|
||||
`client.call_tool(..., version=...)`: the server tasks that version rather
|
||||
than the highest. It is carried in the request metadata FastMCP reads.
|
||||
"""
|
||||
read_timeout_seconds = normalize_timeout_to_seconds(timeout)
|
||||
combined_meta: dict[str, Any] = dict(meta) if meta else {}
|
||||
if version is not None:
|
||||
fastmcp_meta = dict(combined_meta.get("fastmcp") or {})
|
||||
fastmcp_meta["version"] = version
|
||||
combined_meta["fastmcp"] = fastmcp_meta
|
||||
with client_span("tools/call", "tools/call", name, tool_name=name):
|
||||
# Propagate the trace into the tasked submission, like a foreground call.
|
||||
propagated = inject_trace_context(combined_meta)
|
||||
request_meta = cast("mcp_types.RequestParamsMeta | None", propagated or None)
|
||||
raw = await client._await_with_session_monitoring(
|
||||
client.session.call_tool(
|
||||
name=name,
|
||||
arguments=arguments or {},
|
||||
read_timeout_seconds=read_timeout_seconds,
|
||||
meta=request_meta,
|
||||
allow_claimed=True,
|
||||
)
|
||||
)
|
||||
if isinstance(raw, ClientCreateTaskResult):
|
||||
return ToolTask(client, name, raw, raise_on_error=raise_on_error)
|
||||
raise ToolError(
|
||||
f"Tool {name!r} did not run as a task: the server returned a "
|
||||
f"{type(raw).__name__} instead of a task. Ensure the tool is declared "
|
||||
"task=True and the connection is modern (mode='auto')."
|
||||
)
|
||||
130
fastmcp_tasks/fastmcp_tasks/client_models.py
Normal file
130
fastmcp_tasks/fastmcp_tasks/client_models.py
Normal 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
|
||||
182
fastmcp_tasks/fastmcp_tasks/components.py
Normal file
182
fastmcp_tasks/fastmcp_tasks/components.py
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
"""Docket-touching component logic relocated from core component classes.
|
||||
|
||||
During the SEP-1686 -> SEP-2663 migration the ``register_with_docket`` /
|
||||
``add_to_docket`` / ``coerce_task_arguments`` methods were removed from the core
|
||||
``FastMCPComponent`` classes (Tool, Resource, ResourceTemplate, Prompt). Their
|
||||
bodies live here as type-dispatched functions that ``TasksExtension`` wires into
|
||||
the Docket engine, preserving each type's calling convention.
|
||||
|
||||
The functions dispatch on the concrete component type because each type splats
|
||||
its arguments differently into the Docket-registered callable:
|
||||
|
||||
- ``FunctionTool``/``FunctionResource``/``FunctionResourceTemplate``/``FunctionPrompt``
|
||||
register the raw ``fn`` so Docket resolves ALL dependencies (FastMCP's and
|
||||
Docket-native), and splat their arguments (``**kwargs``) into it.
|
||||
- Base ``Tool``/``Resource``/``ResourceTemplate``/``Prompt`` register their
|
||||
``run``/``read``/``render`` entry point and pass arguments positionally.
|
||||
|
||||
Only tools carry a task-capable ``task_config`` (SEP-2663 is tools-only); the
|
||||
resource/prompt/template branches are retained for engine completeness, not
|
||||
because core still declares them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
|
||||
from fastmcp.exceptions import ValidationError
|
||||
from fastmcp.prompts.base import Prompt
|
||||
from fastmcp.prompts.function_prompt import FunctionPrompt
|
||||
from fastmcp.resources.base import Resource
|
||||
from fastmcp.resources.function_resource import FunctionResource
|
||||
from fastmcp.resources.template import FunctionResourceTemplate, ResourceTemplate
|
||||
from fastmcp.tools.base import Tool
|
||||
from fastmcp.tools.function_tool import FunctionTool, _resolve_param_hints
|
||||
from fastmcp.utilities.components import FastMCPComponent
|
||||
from fastmcp.utilities.types import get_cached_typeadapter
|
||||
from fastmcp_tasks.input_loop import reentrant_task_fn
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docket import Docket
|
||||
from docket.execution import Execution
|
||||
|
||||
|
||||
def register_component_with_docket(component: FastMCPComponent, docket: Docket) -> None:
|
||||
"""Register a component's callable with Docket for background execution.
|
||||
|
||||
No-ops if ``task_config.mode`` is ``forbidden``. Function-backed components
|
||||
register their raw ``fn`` (so Docket resolves all dependencies); base
|
||||
components register their ``run``/``read``/``render`` entry point.
|
||||
"""
|
||||
if not component.task_config.supports_tasks():
|
||||
return
|
||||
|
||||
if isinstance(component, FunctionTool):
|
||||
# Run the tool through the guard loop so a body that returns an
|
||||
# InputRequiredResult drives the reentrant in-task input cycle. The
|
||||
# wrapper is signature-preserving, so Docket's dependency injection is
|
||||
# unchanged for a body that never asks for input.
|
||||
docket.register(
|
||||
reentrant_task_fn(component.fn, component.name), names=[component.key]
|
||||
)
|
||||
elif isinstance(component, Tool):
|
||||
# Custom Tool subclasses route through the same wrapper so a raised
|
||||
# error becomes a masked, completed `is_error` result — matching the
|
||||
# synchronous `tools/call` path — rather than a Docket `FAILED` task
|
||||
# that leaks the raw exception text past the server's masking policy.
|
||||
docket.register(
|
||||
reentrant_task_fn(component.run, component.name), names=[component.key]
|
||||
)
|
||||
elif isinstance(component, FunctionResource):
|
||||
docket.register(component.fn, names=[component.key])
|
||||
elif isinstance(component, FunctionResourceTemplate):
|
||||
docket.register(component.fn, names=[component.key])
|
||||
elif isinstance(component, ResourceTemplate):
|
||||
docket.register(component.read, names=[component.key])
|
||||
elif isinstance(component, Resource):
|
||||
docket.register(component.read, names=[component.key])
|
||||
elif isinstance(component, FunctionPrompt):
|
||||
docket.register(component.fn, names=[component.key])
|
||||
elif isinstance(component, Prompt):
|
||||
docket.register(component.render, names=[component.key])
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
f"{type(component).__name__} does not support Docket registration"
|
||||
)
|
||||
|
||||
|
||||
async def add_component_to_docket(
|
||||
component: FastMCPComponent,
|
||||
docket: Docket,
|
||||
arguments: dict[str, Any] | None,
|
||||
*,
|
||||
fn_key: str | None = None,
|
||||
task_key: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Execution:
|
||||
"""Schedule a component for background execution via Docket.
|
||||
|
||||
Handles each component type's calling convention:
|
||||
|
||||
- ``FunctionTool``: splats the arguments dict (``.fn`` expects ``**kwargs``).
|
||||
- base ``Tool``: passes the arguments dict positionally.
|
||||
- ``Resource`` (any): no arguments.
|
||||
- ``FunctionResourceTemplate``: splats the params dict.
|
||||
- base ``ResourceTemplate``: passes params positionally.
|
||||
- ``FunctionPrompt``: splats the arguments dict (or empty).
|
||||
- base ``Prompt``: passes arguments positionally.
|
||||
"""
|
||||
if not component.task_config.supports_tasks():
|
||||
raise RuntimeError(
|
||||
f"Cannot add {type(component).__name__} '{component.name}' to docket: "
|
||||
f"task execution not supported"
|
||||
)
|
||||
|
||||
lookup_key = fn_key or component.key
|
||||
if task_key:
|
||||
kwargs["key"] = task_key
|
||||
adder = docket.add(lookup_key, **kwargs)
|
||||
|
||||
if isinstance(component, FunctionTool):
|
||||
return await adder(**(arguments or {}))
|
||||
elif isinstance(component, Tool):
|
||||
return await adder(arguments)
|
||||
elif isinstance(component, Resource):
|
||||
return await adder()
|
||||
elif isinstance(component, FunctionResourceTemplate):
|
||||
return await adder(**(arguments or {}))
|
||||
elif isinstance(component, ResourceTemplate):
|
||||
return await adder(arguments)
|
||||
elif isinstance(component, FunctionPrompt):
|
||||
return await adder(**(arguments or {}))
|
||||
elif isinstance(component, Prompt):
|
||||
return await adder(arguments)
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
f"{type(component).__name__} does not implement add_to_docket()"
|
||||
)
|
||||
|
||||
|
||||
def coerce_task_arguments(
|
||||
component: FastMCPComponent,
|
||||
arguments: dict[str, Any],
|
||||
*,
|
||||
strict: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate and coerce task arguments before any task state is created.
|
||||
|
||||
Called by ``submit_to_docket`` up front, so invalid inputs raise before the
|
||||
task's Redis metadata and initial status notification exist — otherwise a
|
||||
coercion failure during queueing would orphan a task the client has already
|
||||
observed. Only ``FunctionTool`` splats arguments into a typed Python callable
|
||||
and therefore mirrors the synchronous validation path; every other component
|
||||
type is a no-op passthrough.
|
||||
|
||||
When ``strict`` is set (server-level ``strict_input_validation``), arguments
|
||||
are validated in strict mode so the task path rejects lax coercions (e.g. the
|
||||
string ``"1"`` into an ``int``) exactly as the synchronous call path does.
|
||||
"""
|
||||
if not isinstance(component, FunctionTool):
|
||||
return arguments
|
||||
|
||||
from fastmcp.server.dependencies import without_injected_parameters
|
||||
|
||||
wrapper_fn = without_injected_parameters(
|
||||
component.fn, run_in_thread=component.run_in_thread
|
||||
)
|
||||
hints = _resolve_param_hints(wrapper_fn)
|
||||
|
||||
coerced = dict(arguments)
|
||||
for name, value in arguments.items():
|
||||
annotation = hints.get(name)
|
||||
if annotation is None:
|
||||
continue
|
||||
adapter = get_cached_typeadapter(annotation)
|
||||
try:
|
||||
coerced[name] = adapter.validate_python(value, strict=strict)
|
||||
except PydanticValidationError as e:
|
||||
raise ValidationError(str(e), log_level=logging.WARNING) from e
|
||||
return coerced
|
||||
|
|
@ -16,7 +16,11 @@ from contextvars import ContextVar
|
|||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastmcp.server.tasks.keys import parse_task_key, task_redis_prefix
|
||||
from fastmcp_tasks.keys import (
|
||||
leg_number_from_key,
|
||||
parse_task_key,
|
||||
task_redis_prefix,
|
||||
)
|
||||
|
||||
try:
|
||||
from docket import TaskKey
|
||||
|
|
@ -34,6 +38,7 @@ if TYPE_CHECKING:
|
|||
from docket import Docket
|
||||
from mcp.server.session import ServerSession
|
||||
|
||||
from fastmcp.server.context import Context
|
||||
from fastmcp.server.server import FastMCP
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
|
@ -88,7 +93,7 @@ def get_task_context() -> TaskContextInfo | None:
|
|||
Returns:
|
||||
TaskContextInfo with task_id and task_scope, or None if not in a task.
|
||||
"""
|
||||
from fastmcp.server.dependencies import is_docket_available
|
||||
from fastmcp_tasks.dependencies import is_docket_available
|
||||
|
||||
if not is_docket_available():
|
||||
return None
|
||||
|
|
@ -108,6 +113,26 @@ def get_task_context() -> TaskContextInfo | None:
|
|||
return None
|
||||
|
||||
|
||||
def get_task_leg_number() -> int:
|
||||
"""Return the current leg number of the running task (1 outside a re-entry).
|
||||
|
||||
Each re-entry after client input runs as a fresh Docket execution under a
|
||||
per-leg key; the capture wrapper reads this to scope a leg's outstanding
|
||||
input requests so successive legs never collide in Redis.
|
||||
"""
|
||||
from fastmcp_tasks.dependencies import is_docket_available
|
||||
|
||||
if not is_docket_available():
|
||||
return 1
|
||||
|
||||
from docket.dependencies import current_execution
|
||||
|
||||
try:
|
||||
return leg_number_from_key(current_execution.get().key)
|
||||
except LookupError:
|
||||
return 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TaskContextSnapshot:
|
||||
"""All context data snapshotted at task-submission time.
|
||||
|
|
@ -119,10 +144,24 @@ class TaskContextSnapshot:
|
|||
http_headers: dict[str, str] | None = None
|
||||
origin_request_id: str | None = None
|
||||
session_id: str | None = None
|
||||
owning_tool_name: str | None = None
|
||||
owning_tool_version: str | None = None
|
||||
|
||||
@classmethod
|
||||
def capture(cls) -> TaskContextSnapshot:
|
||||
"""Capture current context for background task execution."""
|
||||
def capture(
|
||||
cls,
|
||||
owning_tool_name: str | None = None,
|
||||
owning_tool_version: str | None = None,
|
||||
) -> TaskContextSnapshot:
|
||||
"""Capture current context for background task execution.
|
||||
|
||||
``owning_tool_name``/``owning_tool_version`` identify the exact tool the
|
||||
call targeted. A remote worker (separate process) cannot reach the
|
||||
submitting process's server map, so it re-resolves the owning (child)
|
||||
server from this name and version against the root — see
|
||||
``make_task_context``. The version matters when two versions of the same
|
||||
mounted tool name live on different child servers.
|
||||
"""
|
||||
from fastmcp.server.dependencies import (
|
||||
get_access_token,
|
||||
get_context,
|
||||
|
|
@ -145,6 +184,8 @@ class TaskContextSnapshot:
|
|||
str(request_context.request_id) if request_context is not None else None
|
||||
),
|
||||
session_id=session_id,
|
||||
owning_tool_name=owning_tool_name,
|
||||
owning_tool_version=owning_tool_version,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -161,6 +202,8 @@ class TaskContextSnapshot:
|
|||
http_headers=headers,
|
||||
origin_request_id=parsed.get("origin_request_id"),
|
||||
session_id=parsed.get("session_id"),
|
||||
owning_tool_name=parsed.get("owning_tool_name"),
|
||||
owning_tool_version=parsed.get("owning_tool_version"),
|
||||
)
|
||||
|
||||
def to_json(self) -> str:
|
||||
|
|
@ -171,6 +214,8 @@ class TaskContextSnapshot:
|
|||
"http_headers": self.http_headers,
|
||||
"origin_request_id": self.origin_request_id,
|
||||
"session_id": self.session_id,
|
||||
"owning_tool_name": self.owning_tool_name,
|
||||
"owning_tool_version": self.owning_tool_version,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -247,7 +292,8 @@ async def restore_task_snapshot(key: str = TaskKey()) -> None:
|
|||
# Non-fastmcp key (e.g. docket scheduler internals) — nothing to do.
|
||||
return
|
||||
|
||||
from fastmcp.server.dependencies import _current_docket, get_server
|
||||
from fastmcp.server.dependencies import get_server
|
||||
from fastmcp_tasks.dependencies import _current_docket
|
||||
|
||||
try:
|
||||
docket = get_server()._docket
|
||||
|
|
@ -267,7 +313,12 @@ async def restore_task_snapshot(key: str = TaskKey()) -> None:
|
|||
)
|
||||
if raw is None:
|
||||
return
|
||||
_remember_snapshot(task_id, TaskContextSnapshot.from_json(raw))
|
||||
snapshot = TaskContextSnapshot.from_json(raw)
|
||||
_remember_snapshot(task_id, snapshot)
|
||||
# Restore the ambient request context (auth token, headers) so core's
|
||||
# get_access_token()/get_http_headers() see the submitting caller inside
|
||||
# the worker, exactly as a normal request would.
|
||||
_apply_snapshot_to_context(snapshot)
|
||||
except Exception:
|
||||
_logger.warning("Failed to restore task snapshot for %s", key, exc_info=True)
|
||||
|
||||
|
|
@ -361,3 +412,160 @@ def get_task_server(task_id: str) -> FastMCP | None:
|
|||
if server is None:
|
||||
_task_server_map.pop(task_id, None)
|
||||
return server
|
||||
|
||||
|
||||
def resolve_worker_server() -> FastMCP | None:
|
||||
"""Return the server owning the current task's tool, or None outside a task.
|
||||
|
||||
Installed as core's worker-server resolver by ``TasksExtension`` so
|
||||
``get_server()``/``CurrentFastMCP()`` inside a worker resolve to the (child)
|
||||
server the task was submitted against, not the root that runs the worker.
|
||||
The map is populated at submission (same process) and, for a remote worker,
|
||||
by ``make_task_context`` re-resolving from the snapshot before the tool runs.
|
||||
"""
|
||||
task_info = get_task_context()
|
||||
if task_info is None:
|
||||
return None
|
||||
return get_task_server(task_info.task_id)
|
||||
|
||||
|
||||
async def _resolve_owning_server(
|
||||
snapshot: TaskContextSnapshot | None,
|
||||
) -> FastMCP | None:
|
||||
"""Re-resolve a mounted task's owning child server from the root (remote worker).
|
||||
|
||||
A separate worker process cannot reach the submitting process's server map,
|
||||
so the owning server is recovered by looking the snapshotted tool name up on
|
||||
the root: a mounted tool resolves to a ``FastMCPProviderTool`` referencing
|
||||
its child server. Returns ``None`` for an unmounted tool (the root owns it)
|
||||
or when the name no longer resolves, so the caller falls back to the root.
|
||||
"""
|
||||
if snapshot is None or snapshot.owning_tool_name is None:
|
||||
return None
|
||||
from fastmcp.exceptions import NotFoundError
|
||||
from fastmcp.server.dependencies import get_server
|
||||
from fastmcp.server.providers.fastmcp_provider import FastMCPProviderTool
|
||||
from fastmcp.utilities.versions import VersionSpec
|
||||
|
||||
root = get_server()
|
||||
# Resolve the exact version the call targeted: two versions of the same
|
||||
# mounted tool name can live on different child servers, so omitting the
|
||||
# version could pick the wrong server's state and masking policy.
|
||||
version = (
|
||||
VersionSpec(eq=snapshot.owning_tool_version)
|
||||
if snapshot.owning_tool_version
|
||||
else None
|
||||
)
|
||||
try:
|
||||
tool = await root.get_tool(snapshot.owning_tool_name, version)
|
||||
except NotFoundError:
|
||||
return None
|
||||
if isinstance(tool, FastMCPProviderTool):
|
||||
return tool._server
|
||||
return None
|
||||
|
||||
|
||||
def _apply_snapshot_to_context(snapshot: TaskContextSnapshot) -> None:
|
||||
"""Populate the ambient request context a worker's tool body reads.
|
||||
|
||||
A Docket worker has no live request or SDK auth context — especially a
|
||||
Redis-backed worker in a separate process. This restores the context vars a
|
||||
tool reads so ``get_access_token()`` / ``get_http_headers()`` work unchanged:
|
||||
the SDK auth context var (from the snapshotted token) and core's background
|
||||
task-headers var (from the snapshotted headers). It deliberately does *not*
|
||||
fabricate a live ``Request``, so ``get_http_request()`` / ``CurrentRequest()``
|
||||
still raise inside a task — there is no request. Runs inside
|
||||
``restore_task_snapshot`` (a Docket dependency), whose context vars propagate
|
||||
to the tool the same way the snapshot var already does.
|
||||
|
||||
Both vars are set unconditionally to *this* snapshot's state (``None`` when
|
||||
it carries no token/headers), never left as-is: a Docket worker may reuse an
|
||||
asyncio context across tasks, so an anonymous task following an authenticated
|
||||
one must not inherit the prior caller's identity or headers.
|
||||
"""
|
||||
import time
|
||||
|
||||
from mcp.server.auth.middleware.auth_context import auth_context_var
|
||||
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
|
||||
|
||||
from fastmcp.server.auth import AccessToken
|
||||
from fastmcp.server.dependencies import (
|
||||
_background_task_headers,
|
||||
_background_task_session_id,
|
||||
)
|
||||
|
||||
user: AuthenticatedUser | None = None
|
||||
if snapshot.access_token_json is not None:
|
||||
token = AccessToken.model_validate_json(snapshot.access_token_json)
|
||||
# A task may sit queued past its submitter's token expiry. Install it
|
||||
# only if still valid — mirroring the SDK's bearer check — so a delayed
|
||||
# task never runs under credentials a live request would reject (401).
|
||||
# An expired token leaves the worker unauthenticated, the honest state.
|
||||
if token.expires_at is None or token.expires_at >= int(time.time()):
|
||||
user = AuthenticatedUser(token)
|
||||
auth_context_var.set(user)
|
||||
|
||||
_background_task_headers.set(
|
||||
dict(snapshot.http_headers) if snapshot.http_headers else None
|
||||
)
|
||||
_background_task_session_id.set(snapshot.session_id)
|
||||
|
||||
|
||||
async def make_task_context() -> Context | None:
|
||||
"""Build and enter a worker ``Context`` for the current background task.
|
||||
|
||||
Installed as core's background-context factory by ``TasksExtension`` so a
|
||||
``ctx: Context`` parameter resolves inside a Docket worker. Returns ``None``
|
||||
when not running in a task (so core falls through to its usual error). The
|
||||
snapshot restored by ``restore_task_snapshot`` supplies the origin request
|
||||
id; the server prefers the one registered at submission time so mounted
|
||||
tasks resolve to the child server. No live session is attached — SEP-2663
|
||||
input and status are polled, so the worker needs no back-channel.
|
||||
|
||||
For a re-entered leg (after the client answered a guard ask), the accumulated
|
||||
per-leg state is loaded and injected so the tool reads ``ctx.input_responses``
|
||||
/ ``ctx.request_state`` identically to the foreground guard contract. Leg 1
|
||||
loads nothing (both ``None``).
|
||||
"""
|
||||
from fastmcp.server.context import Context
|
||||
from fastmcp.server.dependencies import get_server
|
||||
|
||||
task_info = get_task_context()
|
||||
if task_info is None:
|
||||
return None
|
||||
|
||||
snapshot = _recall_snapshot(task_info.task_id)
|
||||
server = get_task_server(task_info.task_id)
|
||||
if server is None:
|
||||
# In-process submission map missed — this is a remote worker (separate
|
||||
# process). Re-resolve the owning (child) server from the root using the
|
||||
# snapshotted tool name, and register it so `CurrentFastMCP()` mid-tool
|
||||
# resolves the child too. Falls back to the root when unmounted or
|
||||
# unresolvable.
|
||||
server = await _resolve_owning_server(snapshot) or get_server()
|
||||
register_task_server(task_info.task_id, server)
|
||||
origin_request_id = snapshot.origin_request_id if snapshot else None
|
||||
|
||||
ctx = Context(
|
||||
fastmcp=server,
|
||||
session=None,
|
||||
task_id=task_info.task_id,
|
||||
origin_request_id=origin_request_id,
|
||||
)
|
||||
await ctx.__aenter__()
|
||||
|
||||
docket = server._docket
|
||||
if docket is None:
|
||||
from fastmcp_tasks.dependencies import _current_docket
|
||||
|
||||
docket = _current_docket.get()
|
||||
if docket is not None:
|
||||
from fastmcp_tasks.input_store import load_pending_input
|
||||
|
||||
request_state, input_responses = await load_pending_input(
|
||||
docket, task_info.task_scope, task_info.task_id
|
||||
)
|
||||
ctx._task_request_state = request_state
|
||||
ctx._task_input_responses = input_responses
|
||||
|
||||
return ctx
|
||||
245
fastmcp_tasks/fastmcp_tasks/creation.py
Normal file
245
fastmcp_tasks/fastmcp_tasks/creation.py
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
"""SEP-2663 task creation: enqueue an augmented tool call to Docket.
|
||||
|
||||
Adapted from the SEP-1686 ``submit_to_docket`` path. The wire surface changed
|
||||
(a flat ``CreateTaskResult`` with ``ttlMs``/``pollIntervalMs``, no client-supplied
|
||||
task id or ttl) and the SEP-1686 push machinery — the initial status
|
||||
notification, the per-task subscription, and the notification subscriber — is
|
||||
gone, because SEP-2663 in-task input and status are polled, not pushed. The
|
||||
operational core is preserved: strict argument coercion up front, a
|
||||
server-generated high-entropy task id, the auth-scoped compound key, the context
|
||||
snapshot restored in the worker, and durable creation (metadata is written
|
||||
before the result is returned, so a subsequent ``tasks/get`` always resolves).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp_types import INTERNAL_ERROR
|
||||
|
||||
from fastmcp.tools.base import Tool
|
||||
from fastmcp.tools.function_tool import _strict_input_validation
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp_tasks.components import add_component_to_docket, coerce_task_arguments
|
||||
from fastmcp_tasks.context import (
|
||||
TaskContextSnapshot,
|
||||
get_task_scope,
|
||||
register_task_server,
|
||||
)
|
||||
from fastmcp_tasks.dependencies import _current_docket
|
||||
from fastmcp_tasks.input_store import save_current_leg, save_task_args
|
||||
from fastmcp_tasks.keys import build_task_key, task_redis_prefix
|
||||
from fastmcp_tasks.models import CreateTaskResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docket import Docket
|
||||
|
||||
from fastmcp.server.context import Context
|
||||
from fastmcp.server.server import FastMCP
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Redis mapping TTL buffer: keep task metadata a little longer than the Docket
|
||||
# execution TTL so a client polling right at the edge still resolves the task.
|
||||
TASK_MAPPING_TTL_BUFFER_SECONDS = 15 * 60
|
||||
|
||||
# Bounded read-your-writes wait so durable creation holds on distributed
|
||||
# backends where the enqueued execution may not be immediately visible.
|
||||
_DURABLE_CREATE_TIMEOUT_SECONDS = 5.0
|
||||
_DURABLE_CREATE_POLL_SECONDS = 0.02
|
||||
|
||||
|
||||
async def create_task(
|
||||
tool: Tool,
|
||||
arguments: dict[str, object] | None,
|
||||
context: Context,
|
||||
) -> CreateTaskResult:
|
||||
"""Run an augmented ``tools/call`` as a background task (SEP-2663).
|
||||
|
||||
Coerces and validates arguments (honoring strict input validation), mints a
|
||||
server-generated task id, snapshots the request context, enqueues the tool's
|
||||
callable on Docket under the auth-scoped compound key, and returns a
|
||||
``CreateTaskResult`` in ``working`` status. Does not return until the task's
|
||||
metadata is durably written and its execution is visible, so an immediately
|
||||
following ``tasks/get`` resolves.
|
||||
"""
|
||||
# The interceptor resolves the tool via get_tool(), which for a mounted tool
|
||||
# returns a provider wrapper — but Docket registered the underlying component
|
||||
# from get_tasks() under the same key, with that component's calling
|
||||
# convention (a FunctionTool splats **kwargs; a base Tool takes the dict
|
||||
# positionally). Execute against the registered component so coercion and
|
||||
# argument-splatting match what the worker will invoke.
|
||||
component = await _registered_task_component(context, tool)
|
||||
|
||||
raw_arguments = dict(arguments or {})
|
||||
coerced = coerce_task_arguments(
|
||||
component, raw_arguments, strict=_strict_input_validation()
|
||||
)
|
||||
|
||||
task_id = secrets.token_urlsafe(32)
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
task_scope = get_task_scope()
|
||||
|
||||
docket = context.fastmcp._docket or _current_docket.get()
|
||||
if docket is None:
|
||||
raise MCPError(
|
||||
code=INTERNAL_ERROR,
|
||||
message="Background tasks require a running tasks extension (Docket).",
|
||||
)
|
||||
|
||||
# Resolve mounted tasks to the owning (child) server in the worker, so
|
||||
# CurrentFastMCP()/ctx.fastmcp inside the task point at the server the tool
|
||||
# lives on rather than the root the interceptor ran on (#3571).
|
||||
register_task_server(task_id, _owning_server(tool, context.fastmcp))
|
||||
|
||||
key = component.key
|
||||
task_key = build_task_key(task_scope, task_id, "tool", key)
|
||||
|
||||
ttl_ms = int(docket.execution_ttl.total_seconds() * 1000)
|
||||
ttl_seconds = int(ttl_ms / 1000) + TASK_MAPPING_TTL_BUFFER_SECONDS
|
||||
poll_interval_ms = int(component.task_config.poll_interval.total_seconds() * 1000)
|
||||
|
||||
prefix = task_redis_prefix(task_scope)
|
||||
task_meta_key = docket.key(f"{prefix}:{task_id}")
|
||||
created_at_key = docket.key(f"{prefix}:{task_id}:created_at")
|
||||
poll_interval_key = docket.key(f"{prefix}:{task_id}:poll_interval")
|
||||
|
||||
snapshot = TaskContextSnapshot.capture(
|
||||
owning_tool_name=tool.name, owning_tool_version=tool.version
|
||||
)
|
||||
|
||||
async with docket.redis() as redis:
|
||||
await redis.set(task_meta_key, task_key, ex=ttl_seconds)
|
||||
await redis.set(created_at_key, created_at, ex=ttl_seconds)
|
||||
await redis.set(poll_interval_key, str(poll_interval_ms), ex=ttl_seconds)
|
||||
|
||||
# End-and-reenter state: the raw (wire) arguments feed every leg, re-coerced
|
||||
# per leg, and the leg pointer starts at leg 1 (the base task key). A guard
|
||||
# return re-enters by enqueuing the next leg with these same arguments (see
|
||||
# handlers.enqueue_next_leg).
|
||||
await save_task_args(docket, task_scope, task_id, raw_arguments, ttl_seconds)
|
||||
await save_current_leg(docket, task_scope, task_id, task_key, 1, ttl_seconds)
|
||||
|
||||
await snapshot.save(docket, task_scope, task_id, ttl_seconds)
|
||||
|
||||
await add_component_to_docket(
|
||||
component, docket, coerced, fn_key=key, task_key=task_key
|
||||
)
|
||||
|
||||
await _await_durable_creation(docket, task_key)
|
||||
|
||||
return CreateTaskResult(
|
||||
task_id=task_id,
|
||||
status="working",
|
||||
created_at=created_at,
|
||||
last_updated_at=created_at,
|
||||
ttl_ms=ttl_ms,
|
||||
poll_interval_ms=poll_interval_ms,
|
||||
)
|
||||
|
||||
|
||||
def _owning_server(tool: Tool, fallback: FastMCP) -> FastMCP:
|
||||
"""The server a mounted tool lives on, for worker context resolution.
|
||||
|
||||
A mounted tool is a ``FastMCPProviderTool`` that references the child server
|
||||
it came from, so ``CurrentFastMCP()``/``ctx.fastmcp`` inside the task point at
|
||||
that server rather than the root the interceptor ran on (#3571). Resolution
|
||||
is single-level: a tool reached through several nested mounts resolves to the
|
||||
outermost mounted child (the mount point the call arrived through), which
|
||||
still reaches deeper components through its own mounts. A non-mounted tool
|
||||
falls back to the server the call arrived on.
|
||||
"""
|
||||
from fastmcp.server.providers.fastmcp_provider import FastMCPProviderTool
|
||||
|
||||
if isinstance(tool, FastMCPProviderTool):
|
||||
return tool._server
|
||||
return fallback
|
||||
|
||||
|
||||
async def registered_component_for_key(server: FastMCP, component_key: str) -> Tool:
|
||||
"""Return the Docket-registered task component matching ``component_key``.
|
||||
|
||||
``get_tasks()`` yields the same components registered with Docket (the
|
||||
underlying ``FunctionTool`` for a mounted tool, not a provider wrapper), so
|
||||
matching by ``key`` recovers the component whose calling convention agrees
|
||||
with the worker. Used when re-entering a task leg, where only the stored
|
||||
compound key (not the original ``Tool`` object) is available.
|
||||
"""
|
||||
for component in await server.get_tasks():
|
||||
if component.key == component_key and isinstance(component, Tool):
|
||||
return component
|
||||
raise MCPError(
|
||||
code=INTERNAL_ERROR,
|
||||
message=f"No task-enabled component found for {component_key!r}.",
|
||||
)
|
||||
|
||||
|
||||
async def enqueue_task_leg(
|
||||
server: FastMCP,
|
||||
docket: Docket,
|
||||
component: Tool,
|
||||
raw_arguments: dict[str, object],
|
||||
leg_key: str,
|
||||
) -> None:
|
||||
"""Enqueue a fresh Docket execution (the next leg) for a re-entered task.
|
||||
|
||||
Re-coerces the stored wire arguments (each leg validates independently, as a
|
||||
foreground retry would) and adds the component's registered callable — the
|
||||
capture wrapper — under ``leg_key``. Waits for the execution to become
|
||||
durable so a ``tasks/get`` immediately after ``tasks/update`` resolves.
|
||||
"""
|
||||
coerced = coerce_task_arguments(
|
||||
component, dict(raw_arguments), strict=_strict_input_validation()
|
||||
)
|
||||
await add_component_to_docket(
|
||||
component, docket, coerced, fn_key=component.key, task_key=leg_key
|
||||
)
|
||||
await _await_durable_creation(docket, leg_key)
|
||||
|
||||
|
||||
async def _registered_task_component(context: Context, tool: Tool) -> Tool:
|
||||
"""Return the component Docket registered for ``tool``'s key.
|
||||
|
||||
``get_tasks()`` yields the same components that were registered with Docket
|
||||
(the underlying ``FunctionTool`` for a mounted tool, not the provider
|
||||
wrapper the interceptor's ``get_tool`` returns). Matching by ``key`` recovers
|
||||
the registered component so the calling convention agrees with the worker.
|
||||
Falls back to the interceptor's tool if no match is found (e.g. a dynamically
|
||||
added tool not present at registration time).
|
||||
"""
|
||||
for component in await context.fastmcp.get_tasks():
|
||||
if component.key == tool.key and isinstance(component, Tool):
|
||||
return component
|
||||
return tool
|
||||
|
||||
|
||||
async def _await_durable_creation(docket: Docket, task_key: str) -> None:
|
||||
"""Block until the enqueued execution is visible (durable-create MUST).
|
||||
|
||||
The metadata write above already makes ``tasks/get`` resolvable; this extra
|
||||
check guards distributed backends where the execution record propagates
|
||||
slightly behind the enqueue. Bounded so a backend hiccup can't hang creation.
|
||||
"""
|
||||
deadline = asyncio.get_event_loop().time() + _DURABLE_CREATE_TIMEOUT_SECONDS
|
||||
while True:
|
||||
execution = await docket.get_execution(task_key)
|
||||
if execution is not None:
|
||||
return
|
||||
if asyncio.get_event_loop().time() >= deadline:
|
||||
# SEP-2663 durable-create: a CreateTaskResult MUST NOT be returned
|
||||
# unless a subsequent tasks/get would resolve. Returning a handle
|
||||
# that can 404 is the exact failure the requirement forbids, so a
|
||||
# backend that never surfaces the execution is a create error.
|
||||
raise MCPError(
|
||||
code=INTERNAL_ERROR,
|
||||
message=(
|
||||
"Task creation did not become durable in time; the task "
|
||||
"backend did not surface the enqueued execution."
|
||||
),
|
||||
)
|
||||
await asyncio.sleep(_DURABLE_CREATE_POLL_SECONDS)
|
||||
184
fastmcp_tasks/fastmcp_tasks/dependencies.py
Normal file
184
fastmcp_tasks/fastmcp_tasks/dependencies.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
"""Docket-specific dependency injection for FastMCP background tasks.
|
||||
|
||||
Moved out of ``fastmcp.server.dependencies`` during the SEP-1686 -> SEP-2663
|
||||
migration. These helpers are all docket-touching: the ``require_docket``
|
||||
install-hint, the docket/worker ContextVars, and the ``CurrentDocket`` /
|
||||
``CurrentWorker`` dependencies. Everything here is wire-agnostic engine plumbing
|
||||
that ``TasksExtension`` drives.
|
||||
|
||||
The generic ``is_docket_available`` probe stays in ``fastmcp.server.dependencies``
|
||||
(core's ``Context``/``Progress`` still use it) and is re-exported here for the
|
||||
tasks package's callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
from contextvars import ContextVar
|
||||
from types import TracebackType
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from uncalled_for import Dependency
|
||||
|
||||
from fastmcp.server.dependencies import (
|
||||
_MIN_DOCKET_VERSION,
|
||||
get_server,
|
||||
is_docket_available,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docket import Docket
|
||||
from docket.worker import Worker
|
||||
|
||||
__all__ = [
|
||||
"CurrentDocket",
|
||||
"CurrentWorker",
|
||||
"is_docket_available",
|
||||
"require_docket",
|
||||
]
|
||||
|
||||
|
||||
_current_docket: ContextVar[Docket | None] = ContextVar("docket", default=None)
|
||||
_current_worker: ContextVar[Worker | None] = ContextVar("worker", default=None)
|
||||
|
||||
|
||||
def require_docket(feature: str) -> None:
|
||||
"""Raise ImportError with install instructions if docket not available.
|
||||
|
||||
Args:
|
||||
feature: Description of what requires docket (e.g., "`task=True`",
|
||||
"CurrentDocket()"). Will be included in the error message.
|
||||
"""
|
||||
if is_docket_available():
|
||||
return
|
||||
|
||||
try:
|
||||
installed = importlib.metadata.version("pydocket")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
installed = None
|
||||
|
||||
if installed is None:
|
||||
detail = (
|
||||
"FastMCP background tasks require the `tasks` extra. "
|
||||
"Install with: pip install 'fastmcp[tasks]'."
|
||||
)
|
||||
else:
|
||||
detail = (
|
||||
f"FastMCP background tasks require pydocket>={_MIN_DOCKET_VERSION}, "
|
||||
f"but pydocket {installed} is installed (likely pulled in by another "
|
||||
f"package). Upgrade with: pip install -U 'pydocket>={_MIN_DOCKET_VERSION}'."
|
||||
)
|
||||
|
||||
raise ImportError(f"{detail} (Triggered by {feature})")
|
||||
|
||||
|
||||
class _CurrentDocket(Dependency["Docket"]):
|
||||
"""Async context manager for Docket dependency."""
|
||||
|
||||
async def __aenter__(self) -> Docket:
|
||||
require_docket("CurrentDocket()")
|
||||
# Check server instance first, fall back to ContextVar for mounted children
|
||||
# whose parent owns the Docket
|
||||
try:
|
||||
docket = get_server()._docket
|
||||
except RuntimeError:
|
||||
docket = None
|
||||
if docket is None:
|
||||
docket = _current_docket.get()
|
||||
if docket is None:
|
||||
raise RuntimeError(
|
||||
"No Docket instance found. Docket is only initialized when there are "
|
||||
"task-enabled components (task=True). Add task=True to a component "
|
||||
"to enable Docket infrastructure."
|
||||
)
|
||||
return docket
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_value: BaseException | None,
|
||||
traceback: TracebackType | None,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def CurrentDocket() -> Docket:
|
||||
"""Get the current Docket instance managed by FastMCP.
|
||||
|
||||
This dependency provides access to the Docket instance that FastMCP
|
||||
automatically creates for background task scheduling.
|
||||
|
||||
Returns:
|
||||
A dependency that resolves to the active Docket instance
|
||||
|
||||
Raises:
|
||||
RuntimeError: If not within a FastMCP server context
|
||||
ImportError: If fastmcp[tasks] not installed
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp_tasks.dependencies import CurrentDocket
|
||||
|
||||
@mcp.tool()
|
||||
async def schedule_task(docket: Docket = CurrentDocket()) -> str:
|
||||
await docket.add(some_function)(arg1, arg2)
|
||||
return "Scheduled"
|
||||
```
|
||||
"""
|
||||
require_docket("CurrentDocket()")
|
||||
return cast("Docket", _CurrentDocket())
|
||||
|
||||
|
||||
class _CurrentWorker(Dependency["Worker"]):
|
||||
"""Async context manager for Worker dependency."""
|
||||
|
||||
async def __aenter__(self) -> Worker:
|
||||
require_docket("CurrentWorker()")
|
||||
# Check server instance first, fall back to ContextVar for mounted children
|
||||
try:
|
||||
worker = get_server()._worker
|
||||
except RuntimeError:
|
||||
worker = None
|
||||
if worker is None:
|
||||
worker = _current_worker.get()
|
||||
if worker is None:
|
||||
raise RuntimeError(
|
||||
"No Worker instance found. Worker is only initialized when there are "
|
||||
"task-enabled components (task=True). Add task=True to a component "
|
||||
"to enable Docket infrastructure."
|
||||
)
|
||||
return worker
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_value: BaseException | None,
|
||||
traceback: TracebackType | None,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def CurrentWorker() -> Worker:
|
||||
"""Get the current Docket Worker instance managed by FastMCP.
|
||||
|
||||
This dependency provides access to the Worker instance that FastMCP
|
||||
automatically creates for background task processing.
|
||||
|
||||
Returns:
|
||||
A dependency that resolves to the active Worker instance
|
||||
|
||||
Raises:
|
||||
RuntimeError: If not within a FastMCP server context
|
||||
ImportError: If fastmcp[tasks] not installed
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp_tasks.dependencies import CurrentWorker
|
||||
|
||||
@mcp.tool()
|
||||
async def check_worker_status(worker: Worker = CurrentWorker()) -> str:
|
||||
return f"Worker: {worker.name}"
|
||||
```
|
||||
"""
|
||||
require_docket("CurrentWorker()")
|
||||
return cast("Worker", _CurrentWorker())
|
||||
299
fastmcp_tasks/fastmcp_tasks/extension.py
Normal file
299
fastmcp_tasks/fastmcp_tasks/extension.py
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
"""The SEP-2663 tasks extension: `io.modelcontextprotocol/tasks`.
|
||||
|
||||
`TasksExtension` is the wire adapter that turns FastMCP's task engine into an
|
||||
`io.modelcontextprotocol/tasks` server extension. Registering it enables
|
||||
`task=True` tools:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp_tasks import TasksExtension
|
||||
|
||||
mcp = FastMCP("Server")
|
||||
mcp.add_extension(TasksExtension(url="redis://localhost:6379/0"))
|
||||
|
||||
|
||||
@mcp.tool(task=True)
|
||||
async def crunch(dataset: str) -> str:
|
||||
...
|
||||
```
|
||||
|
||||
The extension contributes the negotiated capability, the three additive request
|
||||
methods (`tasks/get`, `tasks/update`, `tasks/cancel`), a `tools/call` interceptor
|
||||
that decides whether to run a call as a task, and a lifespan that starts the
|
||||
Docket backend/worker and installs the worker-side `Context` hooks core exposes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
|
||||
|
||||
from fastmcp.exceptions import NotFoundError
|
||||
from fastmcp.server.dependencies import extract_version_spec
|
||||
from fastmcp.server.extensions import (
|
||||
MethodBinding,
|
||||
ServerExtension,
|
||||
read_client_extension_settings,
|
||||
)
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.tasks import TASKS_EXTENSION_ID
|
||||
from fastmcp.utilities.versions import VersionSpec
|
||||
from fastmcp_tasks.creation import create_task
|
||||
from fastmcp_tasks.handlers import tasks_cancel, tasks_get, tasks_update
|
||||
from fastmcp_tasks.models import (
|
||||
MISSING_REQUIRED_CLIENT_CAPABILITY,
|
||||
CancelTaskParams,
|
||||
CancelTaskResult,
|
||||
GetTaskParams,
|
||||
GetTaskResult,
|
||||
UpdateTaskParams,
|
||||
UpdateTaskResult,
|
||||
missing_capability_error_data,
|
||||
)
|
||||
from fastmcp_tasks.settings import DocketSettings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import mcp_types
|
||||
|
||||
from fastmcp.server.context import Context
|
||||
from fastmcp.server.extensions import ToolCallContinuation, ToolCallOutcome
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# SEP-2663's request methods exist only at the 2026-07-28 era (the extensions
|
||||
# mechanism itself is era-gated). Off that era the methods report as not found.
|
||||
_TASK_METHOD_VERSIONS = frozenset(MODERN_PROTOCOL_VERSIONS)
|
||||
|
||||
|
||||
class TasksExtension(ServerExtension):
|
||||
"""FastMCP server extension implementing SEP-2663 background tasks.
|
||||
|
||||
Construct with backend/worker configuration; anything omitted falls back to
|
||||
the ``FASTMCP_DOCKET_*`` environment defaults (unchanged from FastMCP 3), so
|
||||
``TasksExtension()`` works out of the box on an env-configured deployment.
|
||||
"""
|
||||
|
||||
identifier = TASKS_EXTENSION_ID
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
url: str | None = None,
|
||||
name: str | None = None,
|
||||
worker_name: str | None = None,
|
||||
concurrency: int | None = None,
|
||||
redelivery_timeout: timedelta | None = None,
|
||||
reconnection_delay: timedelta | None = None,
|
||||
minimum_check_interval: timedelta | None = None,
|
||||
) -> None:
|
||||
overrides: dict[str, Any] = {
|
||||
"url": url,
|
||||
"name": name,
|
||||
"worker_name": worker_name,
|
||||
"concurrency": concurrency,
|
||||
"redelivery_timeout": redelivery_timeout,
|
||||
"reconnection_delay": reconnection_delay,
|
||||
"minimum_check_interval": minimum_check_interval,
|
||||
}
|
||||
self._settings = DocketSettings(
|
||||
**{k: v for k, v in overrides.items() if v is not None}
|
||||
)
|
||||
|
||||
@property
|
||||
def docket_settings(self) -> DocketSettings:
|
||||
"""The resolved Docket settings (backend URL, worker options)."""
|
||||
return self._settings
|
||||
|
||||
def settings(self) -> dict[str, Any]:
|
||||
"""The tasks extension advertises no per-extension settings."""
|
||||
return {}
|
||||
|
||||
def methods(self) -> Sequence[MethodBinding]:
|
||||
return [
|
||||
MethodBinding(
|
||||
method="tasks/get",
|
||||
params_type=GetTaskParams,
|
||||
handler=self._handle_get,
|
||||
protocol_versions=_TASK_METHOD_VERSIONS,
|
||||
),
|
||||
MethodBinding(
|
||||
method="tasks/update",
|
||||
params_type=UpdateTaskParams,
|
||||
handler=self._handle_update,
|
||||
protocol_versions=_TASK_METHOD_VERSIONS,
|
||||
),
|
||||
MethodBinding(
|
||||
method="tasks/cancel",
|
||||
params_type=CancelTaskParams,
|
||||
handler=self._handle_cancel,
|
||||
protocol_versions=_TASK_METHOD_VERSIONS,
|
||||
),
|
||||
]
|
||||
|
||||
def _require_tasks_capability(self, ctx: ServerRequestContext[Any, Any]) -> None:
|
||||
"""Reject a task method from a client that did not declare the extension.
|
||||
|
||||
SEP-2663: a client issuing `tasks/get`/`tasks/update`/`tasks/cancel`
|
||||
without the tasks capability in the request's `_meta` gets -32003. A
|
||||
client normally only holds a taskId because it declared the capability
|
||||
on the creating `tools/call`, but the method-level check is an explicit
|
||||
MUST, so enforce it here rather than assume.
|
||||
"""
|
||||
if read_client_extension_settings(ctx, TASKS_EXTENSION_ID) is None:
|
||||
raise MCPError(
|
||||
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
|
||||
message=(
|
||||
"This request targets the tasks extension "
|
||||
f"({TASKS_EXTENSION_ID}); the client did not declare it for "
|
||||
"this request."
|
||||
),
|
||||
data=missing_capability_error_data(),
|
||||
)
|
||||
|
||||
async def _handle_get(
|
||||
self, ctx: ServerRequestContext[Any, Any], params: GetTaskParams
|
||||
) -> GetTaskResult:
|
||||
self._require_tasks_capability(ctx)
|
||||
return await tasks_get(self.server, params.task_id)
|
||||
|
||||
async def _handle_update(
|
||||
self, ctx: ServerRequestContext[Any, Any], params: UpdateTaskParams
|
||||
) -> UpdateTaskResult:
|
||||
self._require_tasks_capability(ctx)
|
||||
return await tasks_update(self.server, params.task_id, params.input_responses)
|
||||
|
||||
async def _handle_cancel(
|
||||
self, ctx: ServerRequestContext[Any, Any], params: CancelTaskParams
|
||||
) -> CancelTaskResult:
|
||||
self._require_tasks_capability(ctx)
|
||||
return await tasks_cancel(self.server, params.task_id)
|
||||
|
||||
async def intercept_tool_call(
|
||||
self,
|
||||
params: mcp_types.CallToolRequestParams,
|
||||
context: Context,
|
||||
call_next: ToolCallContinuation,
|
||||
) -> ToolCallOutcome:
|
||||
"""Decide whether to run this ``tools/call`` as a task.
|
||||
|
||||
Consults the tool's ``TaskConfig`` mode and the client's per-request
|
||||
opt-in: ``required`` always tasks (raising -32003 if the client did not
|
||||
opt in), ``optional`` tasks only when the client opted in, ``forbidden``
|
||||
never tasks. A non-task call passes straight through to the tool body.
|
||||
"""
|
||||
# Resolve the same version core would dispatch: a versioned tools/call
|
||||
# carries its VersionSpec in the request _meta, so omitting it here would
|
||||
# task the highest version even when the client targeted an older one
|
||||
# (which may differ in task mode or implementation).
|
||||
version_str = extract_version_spec(params.meta)
|
||||
version = VersionSpec(eq=version_str) if version_str else None
|
||||
try:
|
||||
tool = await context.fastmcp.get_tool(params.name, version)
|
||||
except NotFoundError:
|
||||
tool = None
|
||||
if tool is None or not tool.task_config.supports_tasks():
|
||||
return await call_next()
|
||||
|
||||
# Extension negotiation exists only on the modern era: the SDK strips
|
||||
# `capabilities.extensions` from pre-2026 handshakes, so a legacy client
|
||||
# cannot have negotiated this extension — a `_meta` opt-in arriving on a
|
||||
# handshake-era connection is treated as absent. This also keeps a
|
||||
# `CreateTaskResult` off legacy connections, whose result validation
|
||||
# does not admit it.
|
||||
rc = context.request_context
|
||||
on_modern_era = (
|
||||
rc is not None and rc.protocol_version in MODERN_PROTOCOL_VERSIONS
|
||||
)
|
||||
opted_in = (
|
||||
on_modern_era
|
||||
and context.client_extension_settings(TASKS_EXTENSION_ID) is not None
|
||||
)
|
||||
mode = tool.task_config.mode
|
||||
|
||||
if mode == "required":
|
||||
if not opted_in:
|
||||
raise MCPError(
|
||||
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
|
||||
message=(
|
||||
f"Tool {tool.name!r} requires the tasks extension "
|
||||
f"({TASKS_EXTENSION_ID}); the client did not declare it "
|
||||
"for this request."
|
||||
),
|
||||
data=missing_capability_error_data(),
|
||||
)
|
||||
return await create_task(tool, params.arguments, context)
|
||||
|
||||
if mode == "optional" and opted_in:
|
||||
return await create_task(tool, params.arguments, context)
|
||||
|
||||
return await call_next()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(self) -> AsyncIterator[None]:
|
||||
"""Start the Docket backend/worker and install the worker-side hooks.
|
||||
|
||||
Installs core's background-context factory and worker-server resolver for
|
||||
the duration so a worker's ``ctx`` (progress, server resolution) works,
|
||||
then runs the Docket lifespan. The hooks are process-global and
|
||||
refcounted: with several servers in one process (each its own
|
||||
runtime-tree root), the hooks stay installed until the last tasks
|
||||
extension shuts down, so one server's exit cannot strand another
|
||||
server's in-flight workers.
|
||||
"""
|
||||
from fastmcp_tasks.lifespan import docket_lifespan
|
||||
|
||||
_install_worker_hooks()
|
||||
try:
|
||||
async with docket_lifespan(self.server, self._settings):
|
||||
yield
|
||||
finally:
|
||||
_release_worker_hooks()
|
||||
|
||||
|
||||
# The worker-side hooks core exposes are process-global, but several servers in
|
||||
# one process may each run a TasksExtension (sibling roots in tests, or two
|
||||
# apps sharing an interpreter). Refcount the installs so the hooks are cleared
|
||||
# only when the last active extension lifespan exits. The installed callables
|
||||
# are stateless module functions that resolve their target per task, so
|
||||
# repeated installs are idempotent.
|
||||
_active_worker_hook_holds: int = 0
|
||||
|
||||
|
||||
def _install_worker_hooks() -> None:
|
||||
from fastmcp.server.dependencies import (
|
||||
set_background_context_factory,
|
||||
set_worker_server_resolver,
|
||||
)
|
||||
from fastmcp_tasks import wire_production
|
||||
from fastmcp_tasks.context import make_task_context, resolve_worker_server
|
||||
|
||||
global _active_worker_hook_holds
|
||||
_active_worker_hook_holds += 1
|
||||
set_background_context_factory(make_task_context)
|
||||
set_worker_server_resolver(resolve_worker_server)
|
||||
# Enable server-side production of the claimed CreateTaskResult on tools/call
|
||||
# (the SDK ships only claim consumption). Refcounted independently but
|
||||
# installed/released in lockstep with the worker hooks.
|
||||
wire_production.install()
|
||||
|
||||
|
||||
def _release_worker_hooks() -> None:
|
||||
from fastmcp.server.dependencies import (
|
||||
set_background_context_factory,
|
||||
set_worker_server_resolver,
|
||||
)
|
||||
from fastmcp_tasks import wire_production
|
||||
|
||||
global _active_worker_hook_holds
|
||||
_active_worker_hook_holds -= 1
|
||||
if _active_worker_hook_holds <= 0:
|
||||
_active_worker_hook_holds = 0
|
||||
set_worker_server_resolver(None)
|
||||
set_background_context_factory(None)
|
||||
wire_production.uninstall()
|
||||
433
fastmcp_tasks/fastmcp_tasks/handlers.py
Normal file
433
fastmcp_tasks/fastmcp_tasks/handlers.py
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
"""SEP-2663 task query/management handlers: tasks/get, tasks/update, tasks/cancel.
|
||||
|
||||
Adapted from the SEP-1686 ``requests.py``. The three CRUD-ish handlers survive,
|
||||
reshaped to the new wire:
|
||||
|
||||
- ``tasks/get`` merges the old ``tasks/get`` and ``tasks/result``: the finished
|
||||
result is *inlined* into the response for a completed task, a JSON-RPC-shaped
|
||||
``error`` for a failed one, and the outstanding ``inputRequests`` for a task
|
||||
waiting on input.
|
||||
- ``tasks/update`` is new: it delivers ``inputResponses`` to the in-task input
|
||||
store, resuming a parked worker.
|
||||
- ``tasks/cancel`` returns an empty ack (SEP-2663) instead of a task snapshot.
|
||||
- ``tasks/list`` and ``tasks/result`` are gone (removed by SEP-2663).
|
||||
|
||||
The auth-scoped compound key is the authorization boundary: a request resolves a
|
||||
task only under its own scope's Redis prefix, so a scope mismatch is
|
||||
indistinguishable from a missing task (both raise -32602 "Task not found"),
|
||||
which avoids leaking task existence across callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import mcp_types
|
||||
from docket.execution import ExecutionState
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp_types import INVALID_PARAMS
|
||||
|
||||
from fastmcp.exceptions import NotFoundError
|
||||
from fastmcp.tools.base import InputRequiredToolResult, Tool, ToolResult
|
||||
from fastmcp.utilities.tasks import DEFAULT_POLL_INTERVAL_MS
|
||||
from fastmcp.utilities.versions import VersionSpec
|
||||
from fastmcp_tasks.context import get_task_scope
|
||||
from fastmcp_tasks.creation import (
|
||||
TASK_MAPPING_TTL_BUFFER_SECONDS,
|
||||
enqueue_task_leg,
|
||||
registered_component_for_key,
|
||||
)
|
||||
from fastmcp_tasks.input_store import (
|
||||
acquire_update_lock,
|
||||
acquire_update_lock_blocking,
|
||||
clear_outstanding,
|
||||
is_cancelled,
|
||||
load_current_leg,
|
||||
load_task_args,
|
||||
mark_cancelled,
|
||||
read_outstanding_inputs,
|
||||
refresh_current_leg_ttl,
|
||||
release_update_lock,
|
||||
save_current_leg,
|
||||
store_input_responses,
|
||||
translate_responses,
|
||||
)
|
||||
from fastmcp_tasks.keys import (
|
||||
leg_execution_key,
|
||||
parse_task_key,
|
||||
task_redis_prefix,
|
||||
)
|
||||
from fastmcp_tasks.models import (
|
||||
CancelTaskResult,
|
||||
GetTaskResult,
|
||||
UpdateTaskResult,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docket import Docket
|
||||
|
||||
from fastmcp.server.server import FastMCP
|
||||
|
||||
# Docket execution state -> SEP-2663 task status. `input_required` is not a
|
||||
# Docket state; it is derived from the in-task input store (see tasks_get).
|
||||
DOCKET_TO_MCP_STATE: dict[ExecutionState, str] = {
|
||||
ExecutionState.SCHEDULED: "working",
|
||||
ExecutionState.QUEUED: "working",
|
||||
ExecutionState.RUNNING: "working",
|
||||
ExecutionState.COMPLETED: "completed",
|
||||
ExecutionState.FAILED: "failed",
|
||||
ExecutionState.CANCELLED: "cancelled",
|
||||
}
|
||||
|
||||
|
||||
def _task_not_found(task_id: str) -> MCPError:
|
||||
"""The single "not found" error for missing, expired, or cross-scope ids.
|
||||
|
||||
Uses one message for all three so a caller cannot probe another scope's task
|
||||
ids by distinguishing "not yours" from "does not exist".
|
||||
"""
|
||||
return MCPError(code=INVALID_PARAMS, message=f"Task {task_id} not found")
|
||||
|
||||
|
||||
def _normalize_iso_timestamp(stored: str | None) -> str:
|
||||
"""Return an ISO 8601 timestamp for createdAt, tolerating a missing value."""
|
||||
if stored:
|
||||
try:
|
||||
return datetime.fromisoformat(stored.replace("Z", "+00:00")).isoformat()
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _parse_key_version(key_suffix: str) -> tuple[str, str | None]:
|
||||
"""Split a component key suffix into (name, version) on the last ``@``."""
|
||||
if "@" not in key_suffix:
|
||||
return key_suffix, None
|
||||
name, version = key_suffix.rsplit("@", 1)
|
||||
return name, version if version else None
|
||||
|
||||
|
||||
def _ttl_ms(docket: Docket) -> int:
|
||||
"""The task TTL in milliseconds, from Docket's execution TTL (server-set)."""
|
||||
return int(docket.execution_ttl.total_seconds() * 1000)
|
||||
|
||||
|
||||
def _task_key_ttl_seconds(docket: Docket) -> int:
|
||||
"""Wall-clock TTL for a task's Redis metadata keys.
|
||||
|
||||
Docket's ``execution_ttl`` plus a buffer (matching task creation), so a key
|
||||
written or refreshed now comfortably outlives the execution-retention
|
||||
window. Sliding expiration on each poll keeps it alive for long legs.
|
||||
"""
|
||||
return int(docket.execution_ttl.total_seconds()) + TASK_MAPPING_TTL_BUFFER_SECONDS
|
||||
|
||||
|
||||
async def _lookup_task(
|
||||
docket: Docket, task_scope: str | None, task_id: str
|
||||
) -> tuple[Any, str, int, str | None, int]:
|
||||
"""Resolve a task's current-leg execution and metadata within the scope.
|
||||
|
||||
Returns ``(execution, base_task_key, leg_number, created_at,
|
||||
poll_interval_ms)``. The execution is the *current leg* (the latest Docket
|
||||
execution), which for a re-entered task differs from the base task key.
|
||||
Raises the shared "not found" error when the scope-prefixed metadata is
|
||||
absent or the current leg's execution has expired.
|
||||
"""
|
||||
prefix = task_redis_prefix(task_scope)
|
||||
meta_key = docket.key(f"{prefix}:{task_id}")
|
||||
created_at_key = docket.key(f"{prefix}:{task_id}:created_at")
|
||||
poll_key = docket.key(f"{prefix}:{task_id}:poll_interval")
|
||||
|
||||
async with docket.redis() as redis:
|
||||
# Docket's Redis client mirrors redis-py's variadic ``mget(*keys)`` at
|
||||
# runtime; its type stub declares a single ``Sequence`` arg, so the
|
||||
# positional form is correct but needs a targeted ignore.
|
||||
values = await redis.mget(meta_key, created_at_key, poll_key) # ty: ignore[too-many-positional-arguments]
|
||||
task_key_bytes, created_at_bytes, poll_bytes = values
|
||||
|
||||
base_task_key = task_key_bytes.decode("utf-8") if task_key_bytes else None
|
||||
if not base_task_key:
|
||||
raise _task_not_found(task_id)
|
||||
|
||||
current_leg_key, leg_number = await load_current_leg(docket, task_scope, task_id)
|
||||
execution_key = current_leg_key or base_task_key
|
||||
execution = await docket.get_execution(execution_key)
|
||||
if not execution:
|
||||
raise _task_not_found(task_id)
|
||||
|
||||
# Sliding expiration: an actively-polled task refreshes its routing keys so
|
||||
# they never expire mid-execution — a resumed leg that runs longer than the
|
||||
# keys' wall-clock TTL would otherwise strand `_lookup_task` on the base leg.
|
||||
refresh_ttl = _task_key_ttl_seconds(docket)
|
||||
async with docket.redis() as redis:
|
||||
await redis.expire(meta_key, refresh_ttl)
|
||||
await redis.expire(created_at_key, refresh_ttl)
|
||||
await redis.expire(poll_key, refresh_ttl)
|
||||
await refresh_current_leg_ttl(docket, task_scope, task_id, refresh_ttl)
|
||||
|
||||
created_at = created_at_bytes.decode("utf-8") if created_at_bytes else None
|
||||
|
||||
try:
|
||||
poll_interval_ms = (
|
||||
int(poll_bytes.decode("utf-8")) if poll_bytes else DEFAULT_POLL_INTERVAL_MS
|
||||
)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
poll_interval_ms = DEFAULT_POLL_INTERVAL_MS
|
||||
|
||||
return execution, base_task_key, leg_number, created_at, poll_interval_ms
|
||||
|
||||
|
||||
async def _resolve_tool(server: FastMCP, task_key: str) -> Tool:
|
||||
"""Resolve the Tool a task ran, from its compound key (tools-only surface)."""
|
||||
component_key = parse_task_key(task_key)["component_identifier"]
|
||||
if not component_key.startswith("tool:"):
|
||||
raise MCPError(
|
||||
code=mcp_types.INTERNAL_ERROR,
|
||||
message=f"Task component is not a tool: {component_key}",
|
||||
)
|
||||
name, version_str = _parse_key_version(component_key[len("tool:") :])
|
||||
version = VersionSpec(eq=version_str) if version_str else None
|
||||
try:
|
||||
tool = await server.get_tool(name, version)
|
||||
except NotFoundError:
|
||||
tool = None
|
||||
if tool is None:
|
||||
raise MCPError(
|
||||
code=mcp_types.INTERNAL_ERROR,
|
||||
message=f"Component not found for task: {component_key}",
|
||||
)
|
||||
return tool
|
||||
|
||||
|
||||
def _inline_result(tool: Tool, raw_value: Any) -> dict[str, Any]:
|
||||
"""Convert a completed task's raw return into an inlined CallToolResult dict.
|
||||
|
||||
A completed task should never carry an ``InputRequiredResult``: a function
|
||||
tool's guard returns are captured by the end-and-reenter wrapper (see
|
||||
``input_loop.py``), which records the leg's outstanding requests and ends the
|
||||
leg (returning ``None``), so ``tasks/get`` reports ``input_required`` rather
|
||||
than inlining. Reaching here with a guard result means a component type the
|
||||
wrapper does not wrap (e.g. a base ``Tool``) returned one, which the task
|
||||
path cannot drive — a safety net, not an expected path.
|
||||
"""
|
||||
if isinstance(raw_value, mcp_types.InputRequiredResult | InputRequiredToolResult):
|
||||
raise MCPError(
|
||||
code=mcp_types.INTERNAL_ERROR,
|
||||
message=(
|
||||
f"Tool {tool.name!r} returned an input-required result as a task, "
|
||||
"but its component type is not driven by the in-task guard loop. "
|
||||
"Guard-pattern tasks are supported for function tools."
|
||||
),
|
||||
)
|
||||
# A raised tool error arrives as an is_error ToolResult the wrapper built
|
||||
# (end-and-reenter G2); use it directly so isError round-trips. A normal
|
||||
# return is converted through the tool's own result coercion.
|
||||
if isinstance(raw_value, ToolResult):
|
||||
mcp_result = raw_value.to_mcp_result()
|
||||
else:
|
||||
mcp_result = tool.convert_result(raw_value).to_mcp_result()
|
||||
if isinstance(mcp_result, mcp_types.CallToolResult):
|
||||
call_tool_result = mcp_result
|
||||
elif isinstance(mcp_result, tuple):
|
||||
content, structured_content = mcp_result
|
||||
call_tool_result = mcp_types.CallToolResult(
|
||||
content=content, structured_content=structured_content
|
||||
)
|
||||
else:
|
||||
call_tool_result = mcp_types.CallToolResult(content=mcp_result)
|
||||
return call_tool_result.model_dump(by_alias=True, mode="json", exclude_none=True)
|
||||
|
||||
|
||||
async def tasks_get(server: FastMCP, task_id: str) -> GetTaskResult:
|
||||
"""Handle ``tasks/get``: the detailed task with its result/error/inputs inlined."""
|
||||
docket = server._docket
|
||||
if docket is None:
|
||||
raise _task_not_found(task_id)
|
||||
|
||||
task_scope = get_task_scope()
|
||||
(
|
||||
execution,
|
||||
base_task_key,
|
||||
leg_number,
|
||||
created_at,
|
||||
poll_interval_ms,
|
||||
) = await _lookup_task(docket, task_scope, task_id)
|
||||
await execution.sync()
|
||||
|
||||
created_at_iso = _normalize_iso_timestamp(created_at)
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
ttl_ms = _ttl_ms(docket)
|
||||
|
||||
def build(
|
||||
status: Literal[
|
||||
"working", "input_required", "completed", "failed", "cancelled"
|
||||
],
|
||||
**payload: Any,
|
||||
) -> GetTaskResult:
|
||||
return GetTaskResult(
|
||||
task_id=task_id,
|
||||
status=status,
|
||||
created_at=created_at_iso,
|
||||
last_updated_at=now_iso,
|
||||
ttl_ms=ttl_ms,
|
||||
poll_interval_ms=poll_interval_ms,
|
||||
**payload,
|
||||
)
|
||||
|
||||
# A logical cancellation wins over the underlying execution state: a task
|
||||
# parked on input has a COMPLETED execution, so without this the branches
|
||||
# below would report input_required (or completed) for a cancelled task.
|
||||
if await is_cancelled(docket, task_scope, task_id):
|
||||
return build("cancelled")
|
||||
|
||||
if execution.state == ExecutionState.COMPLETED:
|
||||
# A guard leg ends its Docket execution and records outstanding input
|
||||
# requests to Redis: a completed leg with outstanding requests is the
|
||||
# task waiting for tasks/update (input_required), not a finished task.
|
||||
outstanding = await read_outstanding_inputs(
|
||||
docket, task_scope, task_id, leg_number
|
||||
)
|
||||
if outstanding:
|
||||
return build("input_required", input_requests=outstanding)
|
||||
raw_value = await execution.get_result(timeout=timedelta(seconds=0))
|
||||
tool = await _resolve_tool(server, base_task_key)
|
||||
return build("completed", result=_inline_result(tool, raw_value))
|
||||
|
||||
if execution.state == ExecutionState.FAILED:
|
||||
message = "Task failed"
|
||||
try:
|
||||
await execution.get_result(timeout=timedelta(seconds=0))
|
||||
# On a FAILED execution, get_result re-raises the exception the task
|
||||
# itself raised — an arbitrary user-defined type, so no narrower catch
|
||||
# exists. Its message becomes the task's error payload.
|
||||
except Exception as error:
|
||||
message = str(error)
|
||||
return build(
|
||||
"failed",
|
||||
status_message=message,
|
||||
error={"code": mcp_types.INTERNAL_ERROR, "message": message},
|
||||
)
|
||||
|
||||
if execution.state == ExecutionState.CANCELLED:
|
||||
return build("cancelled")
|
||||
|
||||
status_message = None
|
||||
if execution.progress and execution.progress.message:
|
||||
status_message = execution.progress.message
|
||||
return build("working", status_message=status_message)
|
||||
|
||||
|
||||
async def tasks_update(
|
||||
server: FastMCP, task_id: str, input_responses: dict[str, Any]
|
||||
) -> UpdateTaskResult:
|
||||
"""Handle ``tasks/update``: answer a guard leg and re-enter the task.
|
||||
|
||||
The responses are keyed by the surfaced keys ``tasks/get`` reported. Unknown
|
||||
or already-satisfied keys are ignored (SEP-2663). When at least one answer
|
||||
matches the current leg's outstanding requests, they are translated to the
|
||||
tool's own keys, stored for the next leg, and a fresh Docket execution (the
|
||||
next leg) is enqueued with the task's arguments. The worker is never blocked;
|
||||
re-entry is the whole mechanism. A stale or empty update is an idempotent
|
||||
no-op.
|
||||
"""
|
||||
docket = server._docket
|
||||
if docket is None:
|
||||
raise _task_not_found(task_id)
|
||||
|
||||
task_scope = get_task_scope()
|
||||
# Resolve within scope so a cross-scope update is a "not found", not a no-op.
|
||||
_execution, base_task_key, leg_number, _created_at, _poll = await _lookup_task(
|
||||
docket, task_scope, task_id
|
||||
)
|
||||
|
||||
# Serialize concurrent updates for this task so two racing answers cannot
|
||||
# each enqueue a next leg (double execution). A loser is an idempotent no-op.
|
||||
if not await acquire_update_lock(docket, task_scope, task_id):
|
||||
return UpdateTaskResult()
|
||||
try:
|
||||
# A cancelled task never re-enters: clearing outstanding on cancel makes
|
||||
# translate return None already, but check explicitly so a cancel that
|
||||
# races between this update's lookup and lock acquisition still wins.
|
||||
if await is_cancelled(docket, task_scope, task_id):
|
||||
return UpdateTaskResult()
|
||||
|
||||
translated = await translate_responses(
|
||||
docket, task_scope, task_id, leg_number, input_responses
|
||||
)
|
||||
if translated is None:
|
||||
# Nothing matched the current leg's outstanding requests: the leg was
|
||||
# already answered, or the keys are unknown. Idempotent no-op.
|
||||
return UpdateTaskResult()
|
||||
|
||||
# Store the answers for the next leg to read, then enqueue that leg.
|
||||
# Ordering matters: the answers must be in Redis before the next leg's
|
||||
# worker context loads them, and current_leg must not advance to an
|
||||
# execution that is not yet durable — so enqueue (with its durable wait)
|
||||
# precedes the pointer swap.
|
||||
await store_input_responses(docket, task_scope, task_id, translated)
|
||||
|
||||
component = await registered_component_for_key(
|
||||
server, parse_task_key(base_task_key)["component_identifier"]
|
||||
)
|
||||
raw_arguments = await load_task_args(docket, task_scope, task_id)
|
||||
next_leg = leg_number + 1
|
||||
next_leg_key = leg_execution_key(base_task_key, next_leg)
|
||||
|
||||
await enqueue_task_leg(server, docket, component, raw_arguments, next_leg_key)
|
||||
await save_current_leg(
|
||||
docket,
|
||||
task_scope,
|
||||
task_id,
|
||||
next_leg_key,
|
||||
next_leg,
|
||||
_task_key_ttl_seconds(docket),
|
||||
)
|
||||
# The answered leg's surfaced keys are now superseded; drop them so they
|
||||
# are never reused (SEP-2663 L350).
|
||||
await clear_outstanding(docket, task_scope, task_id, leg_number)
|
||||
return UpdateTaskResult()
|
||||
finally:
|
||||
await release_update_lock(docket, task_scope, task_id)
|
||||
|
||||
|
||||
async def tasks_cancel(server: FastMCP, task_id: str) -> CancelTaskResult:
|
||||
"""Handle ``tasks/cancel``: cooperatively cancel the task, empty ack.
|
||||
|
||||
A durable cancellation marker is recorded so the logical task reports
|
||||
``cancelled`` and refuses re-entry even when it is parked on input — whose
|
||||
current Docket execution is already ``COMPLETED``, making ``docket.cancel``
|
||||
on it a no-op. The current leg's outstanding requests are cleared so a
|
||||
racing ``tasks/update`` naming them finds nothing, and the running
|
||||
execution is still cancelled cooperatively for the ``working`` case.
|
||||
|
||||
Cancellation runs under the per-task update lock and re-resolves the leg
|
||||
once held, so it never cancels a stale leg while ``tasks/update`` is
|
||||
concurrently enqueuing the next one: whichever wins the lock runs to
|
||||
completion before the other, and the update rechecks the marker under the
|
||||
same lock. If the lock is wedged past its timeout, cancel proceeds
|
||||
best-effort rather than hang.
|
||||
"""
|
||||
docket = server._docket
|
||||
if docket is None:
|
||||
raise _task_not_found(task_id)
|
||||
|
||||
task_scope = get_task_scope()
|
||||
# Validate the task exists within scope before taking the lock.
|
||||
await _lookup_task(docket, task_scope, task_id)
|
||||
|
||||
got_lock = await acquire_update_lock_blocking(docket, task_scope, task_id)
|
||||
try:
|
||||
# Re-resolve under the lock: an update that ran first has advanced the
|
||||
# current leg, so this cancels the leg that is actually live now.
|
||||
execution, _base_task_key, leg_number, _created_at, _poll = await _lookup_task(
|
||||
docket, task_scope, task_id
|
||||
)
|
||||
ttl_seconds = int(docket.execution_ttl.total_seconds())
|
||||
await mark_cancelled(docket, task_scope, task_id, ttl_seconds)
|
||||
await clear_outstanding(docket, task_scope, task_id, leg_number)
|
||||
await docket.cancel(execution.key)
|
||||
finally:
|
||||
if got_lock:
|
||||
await release_update_lock(docket, task_scope, task_id)
|
||||
return CancelTaskResult()
|
||||
200
fastmcp_tasks/fastmcp_tasks/input_loop.py
Normal file
200
fastmcp_tasks/fastmcp_tasks/input_loop.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
"""The end-and-reenter capture wrapper for guard-pattern task tools.
|
||||
|
||||
A guard tool asks for input by *returning* an `InputRequiredResult` rather than
|
||||
awaiting `ctx.elicit()`. Foreground, each such return is one leg of a
|
||||
multi-round-trip: the tool returns, the client answers, the framework re-invokes
|
||||
the tool with the answers on `ctx.input_responses`. The tool body is written
|
||||
once and is oblivious to how many legs it takes.
|
||||
|
||||
As a background task the leg boundary is a *worker* boundary. This wrapper runs
|
||||
the tool body exactly once. If the body returns a real value, it is the leg's
|
||||
result. If the body returns an `InputRequiredResult`, the wrapper records the
|
||||
leg's outstanding requests (and any carried `request_state`) to Redis and
|
||||
returns — the Docket execution then completes and the worker is freed. The task
|
||||
sits in `input_required` as durable state until the client answers via
|
||||
`tasks/update`, which enqueues a fresh Docket execution (the next leg) that
|
||||
re-runs this wrapper with the accumulated state injected onto `ctx`. No worker
|
||||
is ever blocked awaiting input.
|
||||
|
||||
The wrapper preserves the wrapped callable's signature so Docket's dependency
|
||||
injection still resolves the tool's parameters (its own args, `ctx`, and any
|
||||
Docket-native dependencies) exactly as it would for the raw callable. The
|
||||
per-leg state (`ctx.input_responses` / `ctx.request_state`) is injected by the
|
||||
worker `Context` factory (`make_task_context`) before the body runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import mcp_types
|
||||
|
||||
from fastmcp.exceptions import FastMCPError
|
||||
from fastmcp.tools.base import InputRequiredToolResult, ToolResult
|
||||
from fastmcp_tasks.context import get_task_context, get_task_leg_number
|
||||
from fastmcp_tasks.input_store import store_outstanding
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from docket import Docket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _as_input_required(result: Any) -> mcp_types.InputRequiredResult | None:
|
||||
"""Return the `InputRequiredResult` a guard leg produced, or None.
|
||||
|
||||
A tool body may return the bare `InputRequiredResult` or the
|
||||
`InputRequiredToolResult` wrapper FastMCP uses foreground; both mean the same
|
||||
ask.
|
||||
"""
|
||||
if isinstance(result, InputRequiredToolResult):
|
||||
return result.input_required
|
||||
if isinstance(result, mcp_types.InputRequiredResult):
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
def _serialize_requests(
|
||||
input_requests: mcp_types.InputRequests,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Dump each request to the wire payload surfaced for the client to answer."""
|
||||
return {
|
||||
key: request.model_dump(by_alias=True, mode="json", exclude_none=True)
|
||||
for key, request in input_requests.items()
|
||||
}
|
||||
|
||||
|
||||
def _resolve_docket() -> Docket | None:
|
||||
"""Resolve the active Docket from the current context or worker default."""
|
||||
from fastmcp.server.dependencies import get_context
|
||||
from fastmcp_tasks.dependencies import _current_docket
|
||||
|
||||
try:
|
||||
docket = get_context().fastmcp._docket
|
||||
except RuntimeError:
|
||||
docket = None
|
||||
if docket is None:
|
||||
docket = _current_docket.get()
|
||||
return docket
|
||||
|
||||
|
||||
def _mask_error_details() -> bool:
|
||||
"""The worker server's error-masking policy, mirroring the sync call path.
|
||||
|
||||
Resolves the owning server through ``get_server()`` (the worker-server
|
||||
resolver) rather than ``get_context()``: a tool that raises without ever
|
||||
requesting a ``ctx`` parameter has no active ``Context``, so reading the
|
||||
policy off the context would silently fall back to the global default and
|
||||
leak unmasked error text.
|
||||
"""
|
||||
import fastmcp
|
||||
from fastmcp.server.dependencies import get_server
|
||||
|
||||
try:
|
||||
return get_server()._mask_error_details
|
||||
except RuntimeError:
|
||||
return fastmcp.settings.mask_error_details
|
||||
|
||||
|
||||
def _error_result(tool_name: str, exc: Exception) -> ToolResult:
|
||||
"""An ``is_error`` result for a task tool that raised, mirroring foreground.
|
||||
|
||||
A raised tool error is a *completed* task carrying an error result, never a
|
||||
``failed`` task (SEP-2663 reserves ``failed`` for protocol faults, and a live
|
||||
``tools/call`` returns the same `isError` result). A `FastMCPError` (e.g.
|
||||
``ToolError``) reaches the client verbatim, as the synchronous path re-raises
|
||||
it unmasked; any other exception is masked per the server's policy.
|
||||
"""
|
||||
if isinstance(exc, FastMCPError):
|
||||
message = str(exc)
|
||||
elif _mask_error_details():
|
||||
message = f"Error calling tool {tool_name!r}"
|
||||
else:
|
||||
message = f"Error calling tool {tool_name!r}: {exc}"
|
||||
return ToolResult(
|
||||
content=[mcp_types.TextContent(type="text", text=message)], is_error=True
|
||||
)
|
||||
|
||||
|
||||
def reentrant_task_fn(
|
||||
fn: Callable[..., Awaitable[Any]],
|
||||
tool_name: str,
|
||||
) -> Callable[..., Awaitable[Any]]:
|
||||
"""Wrap a task tool's callable to capture a guard leg's ask (end-and-reenter).
|
||||
|
||||
Signature-preserving, so Docket injects the wrapped callable's parameters
|
||||
unchanged. The body runs exactly once: a real return is the leg's result; an
|
||||
`InputRequiredResult` is captured to Redis (outstanding requests + carried
|
||||
state) and the wrapper returns, ending the leg without blocking. The next
|
||||
leg is enqueued by ``tasks/update`` when the client answers. A raised tool
|
||||
error becomes a completed `is_error` result (not a failed task), matching the
|
||||
synchronous `tools/call` path.
|
||||
"""
|
||||
|
||||
@functools.wraps(fn)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
result = await fn(*args, **kwargs)
|
||||
except FastMCPError as exc:
|
||||
return _error_result(tool_name, exc)
|
||||
except Exception as exc:
|
||||
logger.exception("background task tool %r raised", tool_name)
|
||||
return _error_result(tool_name, exc)
|
||||
input_required = _as_input_required(result)
|
||||
if input_required is None:
|
||||
return result
|
||||
|
||||
requests = input_required.input_requests or {}
|
||||
request_state = input_required.request_state
|
||||
if not requests:
|
||||
if request_state is None:
|
||||
# Asks nothing and carries nothing — terminal, not a park.
|
||||
return result
|
||||
# State-only round: foreground re-invokes the tool after a backoff,
|
||||
# carrying `request_state` forward with no client interaction. The
|
||||
# tasked path has no self-continuation for that yet, so parking it
|
||||
# (with no requests for the client to answer) would strand the task.
|
||||
# Fail loudly rather than silently report a wrong completed result.
|
||||
return _error_result(
|
||||
tool_name,
|
||||
FastMCPError(
|
||||
"A background task returned a state-only "
|
||||
"InputRequiredResult (request_state with no input_requests). "
|
||||
"Checkpoint-style rounds that carry state without asking the "
|
||||
"client anything are not yet supported for tasks; include at "
|
||||
"least one input request, or run the tool synchronously."
|
||||
),
|
||||
)
|
||||
|
||||
task_context = get_task_context()
|
||||
docket = _resolve_docket()
|
||||
if task_context is None or docket is None:
|
||||
logger.warning(
|
||||
"guard leg produced an ask outside a task worker; returning it"
|
||||
)
|
||||
return result
|
||||
|
||||
await store_outstanding(
|
||||
docket,
|
||||
task_context.task_scope,
|
||||
task_context.task_id,
|
||||
get_task_leg_number(),
|
||||
_serialize_requests(requests),
|
||||
request_state,
|
||||
)
|
||||
# The leg ends here: the Docket execution completes and the worker is
|
||||
# freed. The task is now input_required until tasks/update enqueues the
|
||||
# next leg. Return None so the completed leg carries no stray result.
|
||||
return None
|
||||
|
||||
# `functools.wraps` copies `__wrapped__`, so `inspect.signature` already
|
||||
# unwraps to `fn`; set it explicitly too, so a dependency injector reading
|
||||
# `__signature__` directly (rather than following `__wrapped__`) still sees
|
||||
# the tool's real parameters.
|
||||
wrapper.__signature__ = inspect.signature(fn) # ty: ignore[unresolved-attribute]
|
||||
return wrapper
|
||||
505
fastmcp_tasks/fastmcp_tasks/input_store.py
Normal file
505
fastmcp_tasks/fastmcp_tasks/input_store.py
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
"""Per-task Redis state for SEP-2663 end-and-reenter input gathering.
|
||||
|
||||
A background task gathers client input by *ending a leg* and re-entering, never
|
||||
by blocking a worker. When a `task=True` tool returns an `InputRequiredResult`,
|
||||
the leg's Docket execution completes and the worker is freed; the task's state
|
||||
lives here in Redis as `input_required`. When the client answers via
|
||||
`tasks/update`, a fresh Docket execution (the next leg) re-runs the tool with the
|
||||
accumulated state injected onto its `Context`. No worker ever waits for input.
|
||||
|
||||
This module owns the durable state each task carries between legs:
|
||||
|
||||
- **args** — the original tool arguments, re-supplied to every leg.
|
||||
- **current_leg / leg** — the latest leg's Docket execution key and its number.
|
||||
- **request_state** — the opaque string a leg carried forward (SEP-2322).
|
||||
- **input_responses** — the typed answers the last `tasks/update` delivered,
|
||||
translated to the tool's own request keys.
|
||||
- **input:requests / input:map** — the current leg's outstanding requests, keyed
|
||||
by a server-minted surfaced key, plus the surfaced-key → tool-key mapping.
|
||||
|
||||
Each surfaced request key is minted fresh with high-entropy suffix and never
|
||||
reused after its response is delivered (SEP-2663 L350): a task that asks twice,
|
||||
or a leg that requests several inputs at once, surfaces distinct, independently
|
||||
answerable keys, and the tool reads its *own* keys on the next leg via the
|
||||
translated `input_responses`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import mcp_types
|
||||
|
||||
from fastmcp_tasks.keys import task_redis_prefix
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docket import Docket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# How long a task's input state (outstanding requests and delivered responses)
|
||||
# lives before expiring. With end-and-reenter no worker is held while a task is
|
||||
# input_required, so this bounds only how long durable input state survives, not
|
||||
# any worker slot.
|
||||
INPUT_TTL_SECONDS = 3600
|
||||
|
||||
# Reconstruct a typed response from its stored `{"type": name, "data": dump}`
|
||||
# form so a re-entered leg reads a real `ElicitResult` (etc.) on
|
||||
# `ctx.input_responses`, matching the foreground guard contract.
|
||||
_RESULT_TYPE_BY_NAME: dict[str, type[mcp_types.Result]] = {
|
||||
"ElicitResult": mcp_types.ElicitResult,
|
||||
"CreateMessageResult": mcp_types.CreateMessageResult,
|
||||
"CreateMessageResultWithTools": mcp_types.CreateMessageResultWithTools,
|
||||
"ListRootsResult": mcp_types.ListRootsResult,
|
||||
}
|
||||
|
||||
# Map an outstanding request's wire method to the result type its answer
|
||||
# validates into. Elicitation is the supported in-task input; the others are
|
||||
# kept complete so a client that answers one is parsed rather than dropped.
|
||||
_RESULT_TYPE_BY_METHOD: dict[str, type[mcp_types.Result]] = {
|
||||
"elicitation/create": mcp_types.ElicitResult,
|
||||
"sampling/createMessage": mcp_types.CreateMessageResult,
|
||||
"roots/list": mcp_types.ListRootsResult,
|
||||
}
|
||||
|
||||
|
||||
def result_type_for_method(method: str) -> type[mcp_types.Result]:
|
||||
"""The result type an outstanding request's answer validates into."""
|
||||
return _RESULT_TYPE_BY_METHOD.get(method, mcp_types.ElicitResult)
|
||||
|
||||
|
||||
def _prefix(docket: Docket, task_scope: str | None, task_id: str) -> str:
|
||||
return f"{task_redis_prefix(task_scope)}:{task_id}"
|
||||
|
||||
|
||||
def _args_key(docket: Docket, task_scope: str | None, task_id: str) -> str:
|
||||
return docket.key(f"{_prefix(docket, task_scope, task_id)}:args")
|
||||
|
||||
|
||||
def _current_leg_key(docket: Docket, task_scope: str | None, task_id: str) -> str:
|
||||
return docket.key(f"{_prefix(docket, task_scope, task_id)}:current_leg")
|
||||
|
||||
|
||||
def _leg_number_key(docket: Docket, task_scope: str | None, task_id: str) -> str:
|
||||
return docket.key(f"{_prefix(docket, task_scope, task_id)}:leg")
|
||||
|
||||
|
||||
def _request_state_key(docket: Docket, task_scope: str | None, task_id: str) -> str:
|
||||
return docket.key(f"{_prefix(docket, task_scope, task_id)}:request_state")
|
||||
|
||||
|
||||
def _input_responses_key(docket: Docket, task_scope: str | None, task_id: str) -> str:
|
||||
return docket.key(f"{_prefix(docket, task_scope, task_id)}:input_responses")
|
||||
|
||||
|
||||
def _requests_key(
|
||||
docket: Docket, task_scope: str | None, task_id: str, leg: int
|
||||
) -> str:
|
||||
"""Redis hash of a leg's outstanding input requests, keyed by surfaced key.
|
||||
|
||||
Scoped by leg number so a re-entered leg's fresh requests never collide with
|
||||
the answered leg's stale ones in the shared keyspace.
|
||||
"""
|
||||
return docket.key(f"{_prefix(docket, task_scope, task_id)}:input:{leg}:requests")
|
||||
|
||||
|
||||
def _map_key(docket: Docket, task_scope: str | None, task_id: str, leg: int) -> str:
|
||||
"""Redis hash mapping a leg's surfaced keys back to the tool's own keys."""
|
||||
return docket.key(f"{_prefix(docket, task_scope, task_id)}:input:{leg}:map")
|
||||
|
||||
|
||||
def _mint_surfaced_key(task_id: str) -> str:
|
||||
"""Mint a unique surfaced key for one outstanding request (SEP-2663 L350).
|
||||
|
||||
Namespaced by the task id and suffixed with fresh entropy so no two
|
||||
requests — across legs or within one leg — ever collide, and a key is never
|
||||
reused after its response is delivered.
|
||||
"""
|
||||
return f"{task_id}:{secrets.token_hex(8)}"
|
||||
|
||||
|
||||
def _decode(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8")
|
||||
return str(value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task arguments and leg pointer (written at create, advanced at tasks/update)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def save_task_args(
|
||||
docket: Docket,
|
||||
task_scope: str | None,
|
||||
task_id: str,
|
||||
arguments: dict[str, Any],
|
||||
ttl_seconds: int,
|
||||
) -> None:
|
||||
"""Store the original tool arguments, re-supplied to every leg."""
|
||||
async with docket.redis() as redis:
|
||||
await redis.set(
|
||||
_args_key(docket, task_scope, task_id),
|
||||
json.dumps(arguments),
|
||||
ex=ttl_seconds,
|
||||
)
|
||||
|
||||
|
||||
async def load_task_args(
|
||||
docket: Docket, task_scope: str | None, task_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""Load the stored tool arguments for a task's next leg."""
|
||||
async with docket.redis() as redis:
|
||||
raw = await redis.get(_args_key(docket, task_scope, task_id))
|
||||
decoded = _decode(raw)
|
||||
if not decoded:
|
||||
return {}
|
||||
parsed = json.loads(decoded)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
async def save_current_leg(
|
||||
docket: Docket,
|
||||
task_scope: str | None,
|
||||
task_id: str,
|
||||
leg_key: str,
|
||||
leg_number: int,
|
||||
ttl_seconds: int,
|
||||
) -> None:
|
||||
"""Record the latest leg's Docket execution key and its number."""
|
||||
async with docket.redis() as redis:
|
||||
await redis.set(
|
||||
_current_leg_key(docket, task_scope, task_id), leg_key, ex=ttl_seconds
|
||||
)
|
||||
await redis.set(
|
||||
_leg_number_key(docket, task_scope, task_id),
|
||||
str(leg_number),
|
||||
ex=ttl_seconds,
|
||||
)
|
||||
|
||||
|
||||
async def refresh_current_leg_ttl(
|
||||
docket: Docket, task_scope: str | None, task_id: str, ttl_seconds: int
|
||||
) -> None:
|
||||
"""Extend the current-leg pointer's TTL (sliding expiration).
|
||||
|
||||
The pointer is written with a wall-clock TTL, but a leg's execution can run
|
||||
longer than that — a resumed guard leg especially. Refreshing on each poll
|
||||
keeps the routing pointer alive for an actively-polled task no matter how
|
||||
long the leg runs, so ``_lookup_task`` never falls back to the base leg
|
||||
while the current leg is still executing.
|
||||
"""
|
||||
async with docket.redis() as redis:
|
||||
await redis.expire(_current_leg_key(docket, task_scope, task_id), ttl_seconds)
|
||||
await redis.expire(_leg_number_key(docket, task_scope, task_id), ttl_seconds)
|
||||
|
||||
|
||||
async def load_current_leg(
|
||||
docket: Docket, task_scope: str | None, task_id: str
|
||||
) -> tuple[str | None, int]:
|
||||
"""Return the current leg's execution key and number (defaults to 1)."""
|
||||
async with docket.redis() as redis:
|
||||
leg_key = _decode(
|
||||
await redis.get(_current_leg_key(docket, task_scope, task_id))
|
||||
)
|
||||
leg_raw = _decode(await redis.get(_leg_number_key(docket, task_scope, task_id)))
|
||||
try:
|
||||
leg_number = int(leg_raw) if leg_raw else 1
|
||||
except ValueError:
|
||||
leg_number = 1
|
||||
return leg_key, leg_number
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outstanding requests (written by the capture wrapper, read by tasks/get)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def store_outstanding(
|
||||
docket: Docket,
|
||||
task_scope: str | None,
|
||||
task_id: str,
|
||||
leg: int,
|
||||
serialized_requests: dict[str, dict[str, Any]],
|
||||
request_state: str | None,
|
||||
ttl_seconds: int = INPUT_TTL_SECONDS,
|
||||
) -> None:
|
||||
"""Persist a leg's outstanding input requests plus its carried state.
|
||||
|
||||
``serialized_requests`` maps the tool's own request keys to serialized
|
||||
``InputRequest`` payloads. Each is stored under a freshly minted surfaced
|
||||
key, with the surfaced-key → tool-key mapping recorded alongside so
|
||||
``tasks/update`` can translate answers back. ``request_state`` is written
|
||||
when the leg carried one and cleared otherwise, so it travels to the next
|
||||
leg verbatim.
|
||||
"""
|
||||
requests_key = _requests_key(docket, task_scope, task_id, leg)
|
||||
map_key = _map_key(docket, task_scope, task_id, leg)
|
||||
state_key = _request_state_key(docket, task_scope, task_id)
|
||||
|
||||
async with docket.redis() as redis:
|
||||
for tool_key, payload in serialized_requests.items():
|
||||
surfaced = _mint_surfaced_key(task_id)
|
||||
await redis.hset(requests_key, surfaced, json.dumps(payload))
|
||||
await redis.hset(map_key, surfaced, tool_key)
|
||||
await redis.expire(requests_key, ttl_seconds)
|
||||
await redis.expire(map_key, ttl_seconds)
|
||||
if request_state is not None:
|
||||
await redis.set(state_key, request_state, ex=ttl_seconds)
|
||||
else:
|
||||
await redis.delete(state_key)
|
||||
|
||||
|
||||
async def read_outstanding_inputs(
|
||||
docket: Docket, task_scope: str | None, task_id: str, leg: int
|
||||
) -> dict[str, Any]:
|
||||
"""Return a leg's outstanding input requests, keyed by surfaced key.
|
||||
|
||||
Empty when the leg is not waiting on input. Consumed by ``tasks/get`` to
|
||||
build the ``input_required`` status and its ``inputRequests`` snapshot.
|
||||
"""
|
||||
async with docket.redis() as redis:
|
||||
raw = await redis.hgetall(_requests_key(docket, task_scope, task_id, leg))
|
||||
outstanding: dict[str, Any] = {}
|
||||
for key, value in raw.items():
|
||||
key_str = _decode(key)
|
||||
value_str = _decode(value)
|
||||
if key_str is None or value_str is None:
|
||||
continue
|
||||
try:
|
||||
outstanding[key_str] = json.loads(value_str)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return outstanding
|
||||
|
||||
|
||||
async def _read_outstanding_map(
|
||||
docket: Docket, task_scope: str | None, task_id: str, leg: int
|
||||
) -> dict[str, str]:
|
||||
"""Return the surfaced-key → tool-key mapping for a leg."""
|
||||
async with docket.redis() as redis:
|
||||
raw = await redis.hgetall(_map_key(docket, task_scope, task_id, leg))
|
||||
mapping: dict[str, str] = {}
|
||||
for key, value in raw.items():
|
||||
key_str = _decode(key)
|
||||
value_str = _decode(value)
|
||||
if key_str is None or value_str is None:
|
||||
continue
|
||||
mapping[key_str] = value_str
|
||||
return mapping
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Responses (written by tasks/update, read by the next leg's context factory)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def translate_responses(
|
||||
docket: Docket,
|
||||
task_scope: str | None,
|
||||
task_id: str,
|
||||
leg: int,
|
||||
responses: dict[str, Any],
|
||||
) -> dict[str, mcp_types.Result] | None:
|
||||
"""Translate a ``tasks/update`` payload into typed, tool-keyed responses.
|
||||
|
||||
``responses`` is keyed by the surfaced keys the client received for ``leg``.
|
||||
Unknown or already-satisfied keys are ignored (SEP-2663). Each recognized
|
||||
answer is validated into the result type its request maps to and re-keyed to
|
||||
the tool's own request key. Returns ``None`` when nothing matched, so the
|
||||
caller can treat a stale or empty update as an idempotent no-op.
|
||||
"""
|
||||
outstanding = await read_outstanding_inputs(docket, task_scope, task_id, leg)
|
||||
if not outstanding:
|
||||
return None
|
||||
mapping = await _read_outstanding_map(docket, task_scope, task_id, leg)
|
||||
|
||||
translated: dict[str, mcp_types.Result] = {}
|
||||
for surfaced_key, raw in responses.items():
|
||||
payload = outstanding.get(surfaced_key)
|
||||
if payload is None:
|
||||
continue
|
||||
tool_key = mapping.get(surfaced_key)
|
||||
if tool_key is None:
|
||||
continue
|
||||
method = payload.get("method", "elicitation/create")
|
||||
result_type = result_type_for_method(method)
|
||||
translated[tool_key] = result_type.model_validate(raw)
|
||||
|
||||
return translated or None
|
||||
|
||||
|
||||
async def store_input_responses(
|
||||
docket: Docket,
|
||||
task_scope: str | None,
|
||||
task_id: str,
|
||||
translated: dict[str, mcp_types.Result],
|
||||
ttl_seconds: int = INPUT_TTL_SECONDS,
|
||||
) -> None:
|
||||
"""Store translated responses for the next leg to read via ``ctx``.
|
||||
|
||||
The responses are stored typed-but-serialized (``{"type", "data"}``) so the
|
||||
next leg's context factory reconstructs real result objects keyed by the
|
||||
tool's own request keys.
|
||||
"""
|
||||
stored = {
|
||||
tool_key: {
|
||||
"type": type(result).__name__,
|
||||
"data": result.model_dump(by_alias=True, mode="json"),
|
||||
}
|
||||
for tool_key, result in translated.items()
|
||||
}
|
||||
async with docket.redis() as redis:
|
||||
await redis.set(
|
||||
_input_responses_key(docket, task_scope, task_id),
|
||||
json.dumps(stored),
|
||||
ex=ttl_seconds,
|
||||
)
|
||||
|
||||
|
||||
async def clear_outstanding(
|
||||
docket: Docket, task_scope: str | None, task_id: str, leg: int
|
||||
) -> None:
|
||||
"""Drop a leg's outstanding requests and mapping once it has been answered.
|
||||
|
||||
The answered surfaced keys are never reused (a later leg mints its own), so
|
||||
a duplicate ``tasks/update`` naming them finds nothing and is a no-op.
|
||||
"""
|
||||
async with docket.redis() as redis:
|
||||
await redis.delete(_requests_key(docket, task_scope, task_id, leg))
|
||||
await redis.delete(_map_key(docket, task_scope, task_id, leg))
|
||||
|
||||
|
||||
def _cancelled_key(docket: Docket, task_scope: str | None, task_id: str) -> str:
|
||||
return docket.key(f"{_prefix(docket, task_scope, task_id)}:cancelled")
|
||||
|
||||
|
||||
async def mark_cancelled(
|
||||
docket: Docket, task_scope: str | None, task_id: str, ttl_seconds: int
|
||||
) -> None:
|
||||
"""Record that a task was cancelled at the logical (not per-leg) level.
|
||||
|
||||
An ``input_required`` task's current Docket execution is already
|
||||
``COMPLETED`` — the outstanding-input record is what keeps it parked — so
|
||||
``docket.cancel`` on that execution is a no-op. This durable marker lets
|
||||
``tasks/get`` report ``cancelled`` and ``tasks/update`` refuse to resume,
|
||||
regardless of the underlying execution state. Expires with the task's TTL.
|
||||
"""
|
||||
async with docket.redis() as redis:
|
||||
await redis.set(
|
||||
_cancelled_key(docket, task_scope, task_id), b"1", ex=max(1, ttl_seconds)
|
||||
)
|
||||
|
||||
|
||||
async def is_cancelled(docket: Docket, task_scope: str | None, task_id: str) -> bool:
|
||||
"""Whether the task was logically cancelled (see ``mark_cancelled``)."""
|
||||
async with docket.redis() as redis:
|
||||
return bool(await redis.exists(_cancelled_key(docket, task_scope, task_id)))
|
||||
|
||||
|
||||
# How long the per-task update lock lives if its holder dies mid-update. A
|
||||
# generous ceiling: a single tasks/update is fast, so the lock is normally held
|
||||
# for milliseconds; the TTL only guards against a crashed holder.
|
||||
_UPDATE_LOCK_TTL_SECONDS = 30
|
||||
|
||||
|
||||
def _update_lock_key(docket: Docket, task_scope: str | None, task_id: str) -> str:
|
||||
return docket.key(f"{_prefix(docket, task_scope, task_id)}:update_lock")
|
||||
|
||||
|
||||
async def acquire_update_lock(
|
||||
docket: Docket, task_scope: str | None, task_id: str
|
||||
) -> bool:
|
||||
"""Take the per-task update lock, or return False if one is already held.
|
||||
|
||||
Serializes concurrent ``tasks/update`` calls for a task so two racing
|
||||
answers cannot each enqueue a next leg (double execution). A well-behaved
|
||||
client polls sequentially and never contends; a loser is an idempotent
|
||||
no-op, matching SEP-2663's "ignore already-satisfied" rule.
|
||||
"""
|
||||
async with docket.redis() as redis:
|
||||
got = await redis.set(
|
||||
_update_lock_key(docket, task_scope, task_id),
|
||||
b"1",
|
||||
nx=True,
|
||||
ex=_UPDATE_LOCK_TTL_SECONDS,
|
||||
)
|
||||
return bool(got)
|
||||
|
||||
|
||||
async def acquire_update_lock_blocking(
|
||||
docket: Docket,
|
||||
task_scope: str | None,
|
||||
task_id: str,
|
||||
*,
|
||||
timeout: float = 5.0,
|
||||
poll: float = 0.02,
|
||||
) -> bool:
|
||||
"""Wait for the per-task update lock, up to ``timeout`` seconds.
|
||||
|
||||
``tasks/cancel`` uses this to serialize with an in-flight ``tasks/update``:
|
||||
it must not cancel a stale leg while an update concurrently enqueues the
|
||||
next one. A single update is fast (milliseconds), so contention is brief;
|
||||
returns False if the lock is still held at the deadline (a wedged holder),
|
||||
letting the caller proceed best-effort rather than hang.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
deadline = loop.time() + timeout
|
||||
while True:
|
||||
if await acquire_update_lock(docket, task_scope, task_id):
|
||||
return True
|
||||
if loop.time() >= deadline:
|
||||
return False
|
||||
await asyncio.sleep(poll)
|
||||
|
||||
|
||||
async def release_update_lock(
|
||||
docket: Docket, task_scope: str | None, task_id: str
|
||||
) -> None:
|
||||
"""Release the per-task update lock."""
|
||||
async with docket.redis() as redis:
|
||||
await redis.delete(_update_lock_key(docket, task_scope, task_id))
|
||||
|
||||
|
||||
async def load_pending_input(
|
||||
docket: Docket, task_scope: str | None, task_id: str
|
||||
) -> tuple[str | None, mcp_types.InputResponses | None]:
|
||||
"""Load the per-leg state a re-entered leg reads via ``ctx``.
|
||||
|
||||
Returns ``(request_state, input_responses)``: the opaque state carried
|
||||
forward and the typed answers keyed by the tool's own request keys. Both are
|
||||
``None`` on the first leg (nothing has been asked yet).
|
||||
"""
|
||||
async with docket.redis() as redis:
|
||||
state_raw = _decode(
|
||||
await redis.get(_request_state_key(docket, task_scope, task_id))
|
||||
)
|
||||
responses_raw = _decode(
|
||||
await redis.get(_input_responses_key(docket, task_scope, task_id))
|
||||
)
|
||||
|
||||
responses: dict[str, mcp_types.Result] | None = None
|
||||
if responses_raw:
|
||||
parsed = json.loads(responses_raw)
|
||||
if isinstance(parsed, dict):
|
||||
responses = {}
|
||||
for tool_key, entry in parsed.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
result_type = _RESULT_TYPE_BY_NAME.get(entry.get("type", ""))
|
||||
if result_type is None:
|
||||
continue
|
||||
responses[tool_key] = result_type.model_validate(entry.get("data"))
|
||||
|
||||
# The reconstructed values are the concrete result types the tool asked for;
|
||||
# `InputResponses` is that union keyed by request key. The `Result` element
|
||||
# type erases that for the checker, so narrow at the return.
|
||||
if responses is None:
|
||||
return state_raw, None
|
||||
return state_raw, cast("mcp_types.InputResponses", responses)
|
||||
|
|
@ -37,6 +37,44 @@ _AUTH_TAG = "auth"
|
|||
_ANON_TAG = "anon"
|
||||
_VALID_TAGS = (_AUTH_TAG, _ANON_TAG)
|
||||
|
||||
# Delimiter separating the stable base task key from a per-leg suffix. A single
|
||||
# background task runs as a sequence of Docket executions (legs): the first leg
|
||||
# uses the base key, and each re-entry (after the client answers input) enqueues
|
||||
# a fresh execution under `{base}{_LEG_DELIMITER}{n}`. The base key encodes every
|
||||
# segment with `quote(safe="")`, which percent-encodes `#` to `%23`, so a literal
|
||||
# `#` never appears inside the base key and is an unambiguous leg boundary. All
|
||||
# task-identity parsing strips the leg suffix, so the scope/task-id/component a
|
||||
# leg resolves to are identical across every leg of the same task.
|
||||
_LEG_DELIMITER = "#"
|
||||
|
||||
|
||||
def leg_execution_key(base_task_key: str, leg: int) -> str:
|
||||
"""Build the Docket execution key for a given leg of a task.
|
||||
|
||||
Leg 1 uses the bare base key (so existing single-leg behavior is unchanged);
|
||||
later legs append `#leg{n}` so each re-entry is a distinct Docket execution
|
||||
while still parsing back to the same task scope, id, and component.
|
||||
"""
|
||||
if leg <= 1:
|
||||
return base_task_key
|
||||
return f"{base_task_key}{_LEG_DELIMITER}leg{leg}"
|
||||
|
||||
|
||||
def base_task_key(execution_key: str) -> str:
|
||||
"""Strip any per-leg suffix, returning the stable base task key."""
|
||||
return execution_key.split(_LEG_DELIMITER, 1)[0]
|
||||
|
||||
|
||||
def leg_number_from_key(execution_key: str) -> int:
|
||||
"""Return the leg number a Docket execution key encodes (leg 1 = base key)."""
|
||||
_base, sep, suffix = execution_key.partition(_LEG_DELIMITER)
|
||||
if not sep:
|
||||
return 1
|
||||
try:
|
||||
return int(suffix.removeprefix("leg"))
|
||||
except ValueError:
|
||||
return 1
|
||||
|
||||
|
||||
def build_task_key(
|
||||
task_scope: str | None,
|
||||
|
|
@ -97,6 +135,9 @@ def parse_task_key(task_key: str) -> TaskKeyParts:
|
|||
>>> parse_task_key("anon:task456:tool:my_tool")
|
||||
`{'task_scope': None, 'client_task_id': 'task456', 'task_type': 'tool', 'component_identifier': 'my_tool'}`
|
||||
"""
|
||||
# A per-leg execution key (`{base}#leg{n}`) parses to the same identity as
|
||||
# its base: every leg of a task shares one scope, id, and component.
|
||||
task_key = base_task_key(task_key)
|
||||
tag, _, rest = task_key.partition(":")
|
||||
if tag not in _VALID_TAGS or not rest:
|
||||
raise ValueError(
|
||||
108
fastmcp_tasks/fastmcp_tasks/lifespan.py
Normal file
108
fastmcp_tasks/fastmcp_tasks/lifespan.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""Docket lifecycle for FastMCP background tasks.
|
||||
|
||||
Extracted from the SEP-1686 ``LifespanMixin._docket_lifespan`` and driven by
|
||||
``TasksExtension.lifespan()``. Core's ``_extensions_lifespan`` already enters
|
||||
this once per runtime tree at the root and defers on mounted children, and
|
||||
``SharedContext`` plus the server ContextVar are established before extension
|
||||
lifespans run — so this no longer manages either. It starts Docket and a Worker
|
||||
when there are task-enabled components, registers those components' callables,
|
||||
and runs the worker (with the snapshot-restore dependency) until shutdown.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.server.server import FastMCP
|
||||
from fastmcp_tasks.settings import DocketSettings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def docket_lifespan(
|
||||
server: FastMCP, settings: DocketSettings
|
||||
) -> AsyncIterator[None]:
|
||||
"""Manage the Docket instance and Worker for background task execution.
|
||||
|
||||
Sets ``server._docket`` / ``server._worker`` for the duration and registers
|
||||
each task-enabled component's callable, then runs the worker until the
|
||||
context exits. A no-op if pydocket is unavailable or the server declares no
|
||||
task-enabled components.
|
||||
"""
|
||||
from docket import Depends, Docket, Worker
|
||||
|
||||
import fastmcp
|
||||
from fastmcp_tasks.components import register_component_with_docket
|
||||
from fastmcp_tasks.context import restore_task_snapshot
|
||||
from fastmcp_tasks.dependencies import (
|
||||
_current_docket,
|
||||
_current_worker,
|
||||
is_docket_available,
|
||||
)
|
||||
|
||||
if not is_docket_available():
|
||||
yield
|
||||
return
|
||||
|
||||
try:
|
||||
candidates = list(await server.get_tasks())
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to collect task components: {e}")
|
||||
if fastmcp.settings.mounted_components_raise_on_load_error:
|
||||
raise
|
||||
candidates = []
|
||||
|
||||
# get_tasks() applies server-level transforms that can inject non-task tools;
|
||||
# re-filter by the actual task config (the recorded landmine).
|
||||
task_components = [c for c in candidates if c.task_config.supports_tasks()]
|
||||
if not task_components:
|
||||
yield
|
||||
return
|
||||
|
||||
async with Docket(name=settings.name, url=settings.url) as docket:
|
||||
server._docket = docket
|
||||
for component in task_components:
|
||||
register_component_with_docket(component, docket)
|
||||
|
||||
docket_token = _current_docket.set(docket)
|
||||
try:
|
||||
worker_kwargs: dict[str, Any] = {
|
||||
"concurrency": settings.concurrency,
|
||||
"redelivery_timeout": settings.redelivery_timeout,
|
||||
"reconnection_delay": settings.reconnection_delay,
|
||||
"minimum_check_interval": settings.minimum_check_interval,
|
||||
}
|
||||
if settings.worker_name:
|
||||
worker_kwargs["name"] = settings.worker_name
|
||||
|
||||
async with Worker(
|
||||
docket,
|
||||
dependencies=[Depends(restore_task_snapshot)],
|
||||
**worker_kwargs,
|
||||
) as worker:
|
||||
server._worker = worker
|
||||
worker_token = _current_worker.set(worker)
|
||||
try:
|
||||
worker_task = asyncio.create_task(worker.run_forever())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# End-and-reenter never parks a worker on input, so a
|
||||
# task waiting for input holds no worker slot: cancelling
|
||||
# run_forever drains promptly regardless of task state.
|
||||
worker_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await worker_task
|
||||
finally:
|
||||
_current_worker.reset(worker_token)
|
||||
server._worker = None
|
||||
finally:
|
||||
_current_docket.reset(docket_token)
|
||||
server._docket = None
|
||||
197
fastmcp_tasks/fastmcp_tasks/models.py
Normal file
197
fastmcp_tasks/fastmcp_tasks/models.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""SEP-2663 tasks-extension wire models.
|
||||
|
||||
The `io.modelcontextprotocol/tasks` extension (SEP-2663) defines its own wire
|
||||
shapes, distinct from the SEP-1686 task types the MCP SDK still ships
|
||||
(`mcp_types.Task` uses `ttl`/`pollInterval`; SEP-2663 uses `ttlMs`/`pollIntervalMs`
|
||||
and a *flat* `CreateTaskResult` rather than a nested `{task: ...}`). These models
|
||||
serialize to the SEP-2663 shapes and are validated against the vendored draft
|
||||
JSON schema in the test suite.
|
||||
|
||||
A note on `_meta`: the draft schema composes result shapes as
|
||||
`allOf[Result, Task]`, and the `Task` arm carries `additionalProperties: false`
|
||||
without listing `_meta`. A `_meta` key therefore fails schema validation on those
|
||||
results. These models leave `_meta` unset and rely on the runner's
|
||||
`exclude_none=True` dump to omit it, so serialized instances validate cleanly.
|
||||
`ttlMs` is required-but-nullable in the schema; in practice the engine always
|
||||
emits a numeric value (Docket carries a default execution TTL), so the
|
||||
`exclude_none` dump never drops it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from mcp_types import RequestParams, Result
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
__all__ = [
|
||||
"MISSING_REQUIRED_CLIENT_CAPABILITY",
|
||||
"TaskStatus",
|
||||
"CreateTaskResult",
|
||||
"GetTaskResult",
|
||||
"UpdateTaskResult",
|
||||
"CancelTaskResult",
|
||||
"GetTaskParams",
|
||||
"UpdateTaskParams",
|
||||
"CancelTaskParams",
|
||||
"GetTaskRequest",
|
||||
"UpdateTaskRequest",
|
||||
"CancelTaskRequest",
|
||||
"missing_capability_error_data",
|
||||
]
|
||||
|
||||
#: JSON-RPC error code for "Missing Required Client Capability" (SEP-2663). A
|
||||
#: tool whose task mode is `required` returns this when the client did not opt
|
||||
#: the tasks extension in for the request.
|
||||
MISSING_REQUIRED_CLIENT_CAPABILITY = -32003
|
||||
|
||||
TaskStatus = Literal["working", "input_required", "completed", "failed", "cancelled"]
|
||||
|
||||
|
||||
class _TaskFields(BaseModel):
|
||||
"""The flat task fields shared by every SEP-2663 task result shape.
|
||||
|
||||
Serializes to the schema's `Task` object (camelCase aliases, `ttlMs`
|
||||
required-but-nullable). No `_meta`: the schema's `additionalProperties:
|
||||
false` on the task arm forbids it (see module docstring).
|
||||
"""
|
||||
|
||||
# Serialization aliases: the engine constructs these by field name and the
|
||||
# runner dumps them to camelCase (`model_dump(by_alias=True)`). The
|
||||
# claim-production wrap returns that dump unchanged, so no input alias is
|
||||
# needed.
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
task_id: str = Field(serialization_alias="taskId")
|
||||
status: TaskStatus
|
||||
created_at: str = Field(serialization_alias="createdAt")
|
||||
last_updated_at: str = Field(serialization_alias="lastUpdatedAt")
|
||||
ttl_ms: float | None = Field(serialization_alias="ttlMs")
|
||||
status_message: str | None = Field(
|
||||
default=None, serialization_alias="statusMessage"
|
||||
)
|
||||
poll_interval_ms: float | None = Field(
|
||||
default=None, serialization_alias="pollIntervalMs"
|
||||
)
|
||||
|
||||
|
||||
class CreateTaskResult(_TaskFields):
|
||||
"""Result of an augmented `tools/call` that the server ran as a task.
|
||||
|
||||
A flat merge of `Result` and `Task` (SEP-2663): the finished task stub the
|
||||
client polls with `tasks/get`. Status is typically `working`.
|
||||
|
||||
`resultType` is the wire discriminator that distinguishes this from a
|
||||
`CallToolResult` on the shared `tools/call` method: the modern result union
|
||||
carries a required `resultType`, and the SDK's client-side `ResultClaim`
|
||||
for tasks requires this model to pin it to `Literal["task"]`. The vendored
|
||||
draft schema omits `resultType` from the task arm (its
|
||||
`additionalProperties: false` forbids it) — a schema-vs-protocol
|
||||
contradiction reported upstream. Protocol interop requires the field, so we
|
||||
emit it; only this shape needs it (the `tasks/*` methods each have a single
|
||||
result type and bypass the discriminated union).
|
||||
"""
|
||||
|
||||
result_type: Literal["task"] = Field(
|
||||
default="task", serialization_alias="resultType"
|
||||
)
|
||||
|
||||
|
||||
class GetTaskResult(_TaskFields):
|
||||
"""Result of `tasks/get`: the detailed task (`Result & DetailedTask`).
|
||||
|
||||
Carries exactly one of `result` (completed), `error` (failed), or
|
||||
`input_requests` (input_required) alongside the flat task fields, matching
|
||||
the schema's 5-status union. The three payload fields default to `None` and
|
||||
are dropped from the wire dump for the statuses that do not use them.
|
||||
|
||||
`resultType` is `"complete"` (SEP-2663 L338): `tasks/get` itself completes
|
||||
normally, whatever the task's own status. As with `CreateTaskResult`, the
|
||||
draft schema's `additionalProperties: false` omits this field — a
|
||||
contradiction reported upstream; protocol interop requires emitting it.
|
||||
"""
|
||||
|
||||
result_type: Literal["complete"] = Field(
|
||||
default="complete", serialization_alias="resultType"
|
||||
)
|
||||
result: dict[str, Any] | None = None
|
||||
error: dict[str, Any] | None = None
|
||||
input_requests: dict[str, Any] | None = Field(
|
||||
default=None, serialization_alias="inputRequests"
|
||||
)
|
||||
|
||||
|
||||
class UpdateTaskResult(Result):
|
||||
"""Acknowledgement for `tasks/update` (SEP-2663 `Result`, `resultType: "complete"`)."""
|
||||
|
||||
result_type: Literal["complete"] = Field(
|
||||
default="complete", serialization_alias="resultType"
|
||||
)
|
||||
|
||||
|
||||
class CancelTaskResult(Result):
|
||||
"""Acknowledgement for `tasks/cancel` (SEP-2663 `Result`, `resultType: "complete"`)."""
|
||||
|
||||
result_type: Literal["complete"] = Field(
|
||||
default="complete", serialization_alias="resultType"
|
||||
)
|
||||
|
||||
|
||||
class GetTaskParams(RequestParams):
|
||||
"""Params for `tasks/get` / `tasks/cancel`: the target task id."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
task_id: str = Field(alias="taskId")
|
||||
|
||||
|
||||
# `tasks/cancel` params are identical to `tasks/get` (just `taskId`).
|
||||
CancelTaskParams = GetTaskParams
|
||||
|
||||
|
||||
class UpdateTaskParams(RequestParams):
|
||||
"""Params for `tasks/update`: task id plus the caller's input responses."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
task_id: str = Field(alias="taskId")
|
||||
input_responses: dict[str, Any] = Field(alias="inputResponses")
|
||||
|
||||
|
||||
class GetTaskRequest(BaseModel):
|
||||
"""`tasks/get` request envelope (used by tests and clients)."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
method: Literal["tasks/get"] = "tasks/get"
|
||||
params: GetTaskParams
|
||||
|
||||
|
||||
class UpdateTaskRequest(BaseModel):
|
||||
"""`tasks/update` request envelope."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
method: Literal["tasks/update"] = "tasks/update"
|
||||
params: UpdateTaskParams
|
||||
|
||||
|
||||
class CancelTaskRequest(BaseModel):
|
||||
"""`tasks/cancel` request envelope."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
method: Literal["tasks/cancel"] = "tasks/cancel"
|
||||
params: GetTaskParams
|
||||
|
||||
|
||||
def missing_capability_error_data() -> dict[str, Any]:
|
||||
"""Build the `data.requiredCapabilities` payload for a -32003 error.
|
||||
|
||||
A `required`-mode tool called without the client opting the tasks extension
|
||||
in for the request returns this so the client learns which capability to
|
||||
declare.
|
||||
"""
|
||||
from fastmcp.utilities.tasks import TASKS_EXTENSION_ID
|
||||
|
||||
return {"requiredCapabilities": {"extensions": {TASKS_EXTENSION_ID: {}}}}
|
||||
1
fastmcp_tasks/fastmcp_tasks/py.typed
Normal file
1
fastmcp_tasks/fastmcp_tasks/py.typed
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
165
fastmcp_tasks/fastmcp_tasks/settings.py
Normal file
165
fastmcp_tasks/fastmcp_tasks/settings.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"""Docket worker settings for FastMCP background tasks.
|
||||
|
||||
Moved out of ``fastmcp.settings`` during the SEP-1686 -> SEP-2663 migration.
|
||||
The ``FASTMCP_DOCKET_*`` environment prefix is unchanged so existing
|
||||
deployments keep working. ``TasksExtension`` reads this configuration (its
|
||||
constructor overrides the env defaults).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Load the same dotenv source as core FastMCP settings, so a deployment that
|
||||
# puts FASTMCP_DOCKET_* in `.env` (or a FASTMCP_ENV_FILE) configures the backend
|
||||
# rather than silently falling back to memory://.
|
||||
_ENV_FILE = os.getenv("FASTMCP_ENV_FILE", ".env")
|
||||
|
||||
|
||||
class DocketSettings(BaseSettings):
|
||||
"""Docket worker configuration."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="FASTMCP_DOCKET_",
|
||||
env_file=_ENV_FILE,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
name: Annotated[
|
||||
str,
|
||||
Field(
|
||||
description=inspect.cleandoc(
|
||||
"""
|
||||
Name for the Docket queue. All servers/workers sharing the same name
|
||||
and backend URL will share a task queue.
|
||||
"""
|
||||
),
|
||||
),
|
||||
] = "fastmcp"
|
||||
|
||||
url: Annotated[
|
||||
str,
|
||||
Field(
|
||||
description=inspect.cleandoc(
|
||||
"""
|
||||
URL for the Docket backend. Supports:
|
||||
- memory:// - In-memory backend (single process only)
|
||||
- redis://host:port/db - Redis/Valkey backend (distributed, multi-process)
|
||||
|
||||
Example: redis://localhost:6379/0
|
||||
|
||||
Default is memory:// for single-process scenarios. Use Redis or Valkey
|
||||
when coordinating tasks across multiple processes (e.g., additional
|
||||
workers via the fastmcp tasks CLI).
|
||||
"""
|
||||
),
|
||||
),
|
||||
] = "memory://"
|
||||
|
||||
worker_name: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
description=inspect.cleandoc(
|
||||
"""
|
||||
Name for the Docket worker. If None, Docket will auto-generate
|
||||
a unique worker name.
|
||||
"""
|
||||
),
|
||||
),
|
||||
] = None
|
||||
|
||||
concurrency: Annotated[
|
||||
int,
|
||||
Field(
|
||||
description=inspect.cleandoc(
|
||||
"""
|
||||
Maximum number of tasks the worker can process concurrently.
|
||||
"""
|
||||
),
|
||||
),
|
||||
] = 10
|
||||
|
||||
redelivery_timeout: Annotated[
|
||||
timedelta,
|
||||
Field(
|
||||
description=inspect.cleandoc(
|
||||
"""
|
||||
Task redelivery timeout. If a worker doesn't complete
|
||||
a task within this time, the task will be redelivered to another
|
||||
worker.
|
||||
"""
|
||||
),
|
||||
),
|
||||
] = timedelta(seconds=300)
|
||||
|
||||
reconnection_delay: Annotated[
|
||||
timedelta,
|
||||
Field(
|
||||
description=inspect.cleandoc(
|
||||
"""
|
||||
Delay between reconnection attempts when the worker
|
||||
loses connection to the Docket backend.
|
||||
"""
|
||||
),
|
||||
),
|
||||
] = timedelta(seconds=5)
|
||||
|
||||
minimum_check_interval: Annotated[
|
||||
timedelta,
|
||||
Field(
|
||||
description=inspect.cleandoc(
|
||||
"""
|
||||
How frequently the worker polls for new tasks. Lower
|
||||
values reduce latency for task pickup at the cost of
|
||||
more CPU usage. The default of 50ms is a good balance;
|
||||
increase for high-volume production deployments where
|
||||
tasks are long-running.
|
||||
"""
|
||||
),
|
||||
),
|
||||
] = timedelta(milliseconds=50)
|
||||
|
||||
|
||||
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_",
|
||||
env_file=_ENV_FILE,
|
||||
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()
|
||||
111
fastmcp_tasks/fastmcp_tasks/wire_production.py
Normal file
111
fastmcp_tasks/fastmcp_tasks/wire_production.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"""Server-side production of the tasks extension's claimed `tools/call` result.
|
||||
|
||||
The MCP SDK ships the *consumption* half of SEP-2133 claimed results — a client
|
||||
`ResultClaim` resolves an extension's result shape on a core method — but not
|
||||
the *production* half: nothing lets a server emit one. On the modern protocol
|
||||
the runner revalidates every `tools/call` result against
|
||||
`SERVER_RESULTS[("tools/call", "2026-07-28")]`, which admits only
|
||||
`CallToolResult | InputRequiredResult`. A returned `CreateTaskResult` is coerced
|
||||
through those `extra="ignore"` models and stripped to nothing — the `taskId`
|
||||
never reaches the client, so the tasks extension cannot create a task over the
|
||||
wire even though its `tasks/*` methods (being custom methods) serialize freely.
|
||||
|
||||
This module supplies the missing production half. It wraps
|
||||
`mcp_types.methods.serialize_server_result` — which the runner looks up on the
|
||||
module at call time — so that a modern `tools/call` result tagged
|
||||
`resultType: "task"` is validated against `CreateTaskResult` and dumped as-is,
|
||||
routed by the discriminator rather than the ambiguous result union (an untagged
|
||||
task dict would otherwise be swallowed by the all-optional `InputRequiredResult`
|
||||
arm). Every other result delegates to the original serializer unchanged.
|
||||
|
||||
The wrap is process-global but inert for anything that is not a tasks server: a
|
||||
server that never emits `resultType: "task"` never takes the task branch. It is
|
||||
installed and reference-counted by `TasksExtension.lifespan()` so it is present
|
||||
exactly while at least one tasks extension is running, and removed after the
|
||||
last one stops. It is gated to modern protocol versions because claimed result
|
||||
shapes exist only there.
|
||||
|
||||
Removal trigger: when the SDK grows a first-class server-side claim-production
|
||||
API (mirroring the client `ResultClaim`), this wrap is deleted and
|
||||
`TasksExtension` declares its produced claim through that API instead. See the
|
||||
upstream report in the migration notes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import mcp_types.methods as _methods
|
||||
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
|
||||
|
||||
_TASK_RESULT_TYPE = "task"
|
||||
_TASK_AUGMENTED_METHOD = "tools/call"
|
||||
|
||||
# Sentinel distinguishing "caller passed no surface" (the runner's path, which we
|
||||
# may divert) from an explicit surface another caller supplied (never diverted).
|
||||
_STOCK: Any = object()
|
||||
|
||||
# The original module function, captured once. `None` until the first install.
|
||||
_original_serialize: Any = None
|
||||
_active_holds: int = 0
|
||||
|
||||
|
||||
def _serialize_with_task_production(
|
||||
method: str,
|
||||
version: str,
|
||||
data: Mapping[str, Any],
|
||||
*,
|
||||
surface: Any = _STOCK,
|
||||
) -> dict[str, Any]:
|
||||
"""Serialize a server result, letting a tagged task result through.
|
||||
|
||||
A modern `tools/call` result carrying `resultType: "task"` is returned as
|
||||
the producer already dumped it, rather than being validated against — and
|
||||
stripped by — the stock `CallToolResult | InputRequiredResult` surface. This
|
||||
is the same bypass the runner already applies to custom-method results
|
||||
(which skip surface validation entirely); the producer built this dict from
|
||||
a validated `CreateTaskResult`, so its shape is already correct. Every other
|
||||
result — and any call that supplies an explicit `surface` — delegates to the
|
||||
SDK's original serializer unchanged.
|
||||
"""
|
||||
if (
|
||||
surface is _STOCK
|
||||
and method == _TASK_AUGMENTED_METHOD
|
||||
and version in MODERN_PROTOCOL_VERSIONS
|
||||
and isinstance(data, Mapping)
|
||||
and data.get("resultType") == _TASK_RESULT_TYPE
|
||||
):
|
||||
return dict(data)
|
||||
if surface is _STOCK:
|
||||
return _original_serialize(method, version, data)
|
||||
return _original_serialize(method, version, data, surface=surface)
|
||||
|
||||
|
||||
def install() -> None:
|
||||
"""Install the task claim-production wrap (reference-counted, idempotent).
|
||||
|
||||
Safe to call from every `TasksExtension.lifespan()`: the first call captures
|
||||
and replaces the SDK serializer, later calls only bump the reference count.
|
||||
"""
|
||||
global _original_serialize, _active_holds
|
||||
_active_holds += 1
|
||||
if _original_serialize is not None:
|
||||
return
|
||||
_original_serialize = _methods.serialize_server_result
|
||||
# Runtime attribute swap: the wrapper is call-compatible (it forwards
|
||||
# `surface` when supplied and only diverts the runner's no-surface task
|
||||
# path), but ty cannot verify a monkeypatch's signature match.
|
||||
_methods.serialize_server_result = _serialize_with_task_production # ty: ignore[invalid-assignment]
|
||||
|
||||
|
||||
def uninstall() -> None:
|
||||
"""Release one hold; restore the SDK serializer when the last one exits."""
|
||||
global _original_serialize, _active_holds
|
||||
_active_holds -= 1
|
||||
if _active_holds > 0:
|
||||
return
|
||||
_active_holds = 0
|
||||
if _original_serialize is not None:
|
||||
_methods.serialize_server_result = _original_serialize
|
||||
_original_serialize = None
|
||||
|
|
@ -1,14 +1,21 @@
|
|||
"""FastMCP tasks CLI for Docket task management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from typing import Annotated
|
||||
from typing import TYPE_CHECKING, Annotated
|
||||
|
||||
import cyclopts
|
||||
from rich.console import Console
|
||||
|
||||
from fastmcp.utilities.cli import load_and_merge_config
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.tasks import TASKS_EXTENSION_ID
|
||||
from fastmcp_tasks.settings import DocketSettings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.server.server import FastMCP
|
||||
|
||||
logger = get_logger("cli.tasks")
|
||||
console = Console()
|
||||
|
|
@ -19,7 +26,32 @@ tasks_app = cyclopts.App(
|
|||
)
|
||||
|
||||
|
||||
def check_distributed_backend() -> None:
|
||||
def resolve_docket_settings(server: FastMCP) -> DocketSettings:
|
||||
"""The effective Docket settings for `server`'s registered tasks extension.
|
||||
|
||||
Reads the *registered* `TasksExtension`'s resolved settings, not the
|
||||
env-only module-level default: a server that configures
|
||||
`TasksExtension(url="redis://...")` in code has settings the environment
|
||||
alone cannot see, and checking those defaults instead would report the
|
||||
wrong backend (see #4603 review — the CLI checked before the server, and
|
||||
therefore the extension, was even loaded).
|
||||
"""
|
||||
extension = server._extensions.get(TASKS_EXTENSION_ID)
|
||||
if extension is None:
|
||||
console.print(
|
||||
f"[bold red]✗ No tasks extension registered[/bold red]\n\n"
|
||||
f"[cyan]{server.name}[/cyan] has no `TasksExtension` registered "
|
||||
"(`mcp.add_extension(TasksExtension())`), so there is nothing for "
|
||||
"this worker to serve."
|
||||
)
|
||||
sys.exit(1)
|
||||
from fastmcp_tasks.extension import TasksExtension
|
||||
|
||||
assert isinstance(extension, TasksExtension)
|
||||
return extension.docket_settings
|
||||
|
||||
|
||||
def check_distributed_backend(settings: DocketSettings) -> None:
|
||||
"""Check if Docket is configured with a distributed backend.
|
||||
|
||||
The CLI worker runs as a separate process, so it needs Redis/Valkey
|
||||
|
|
@ -28,12 +60,8 @@ def check_distributed_backend() -> None:
|
|||
Raises:
|
||||
SystemExit: If using memory:// URL
|
||||
"""
|
||||
import fastmcp
|
||||
|
||||
docket_url = fastmcp.settings.docket.url
|
||||
|
||||
# Check for memory:// URL and provide helpful error
|
||||
if docket_url.startswith("memory://"):
|
||||
if settings.url.startswith("memory://"):
|
||||
console.print(
|
||||
"[bold red]✗ In-memory backend not supported by CLI[/bold red]\n\n"
|
||||
"Your Docket configuration uses an in-memory backend (memory://) which\n"
|
||||
|
|
@ -76,10 +104,6 @@ def worker(
|
|||
fastmcp tasks worker server.py
|
||||
fastmcp tasks worker examples/tasks/server.py
|
||||
"""
|
||||
import fastmcp
|
||||
|
||||
check_distributed_backend()
|
||||
|
||||
# Load server to get task functions
|
||||
try:
|
||||
config, _resolved_spec = load_and_merge_config(server_spec)
|
||||
|
|
@ -89,15 +113,21 @@ def worker(
|
|||
# Load the server
|
||||
server = asyncio.run(config.source.load_server())
|
||||
|
||||
# Validate against the server's actual registered extension, not an
|
||||
# env-only guess — a constructor-configured Redis URL isn't visible
|
||||
# until the server (and its extension) has loaded.
|
||||
settings = resolve_docket_settings(server)
|
||||
check_distributed_backend(settings)
|
||||
|
||||
async def run_worker():
|
||||
"""Enter server lifespan and camp forever."""
|
||||
async with server._lifespan_manager():
|
||||
console.print(
|
||||
f"[bold green]✓[/bold green] Starting worker for [cyan]{server.name}[/cyan]"
|
||||
)
|
||||
console.print(f" Docket: {fastmcp.settings.docket.name}")
|
||||
console.print(f" Backend: {fastmcp.settings.docket.url}")
|
||||
console.print(f" Concurrency: {fastmcp.settings.docket.concurrency}")
|
||||
console.print(f" Docket: {settings.name}")
|
||||
console.print(f" Backend: {settings.url}")
|
||||
console.print(f" Concurrency: {settings.concurrency}")
|
||||
|
||||
# Server's lifespan has started its worker - just camp here forever
|
||||
while True:
|
||||
|
|
@ -108,3 +138,9 @@ def worker(
|
|||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Worker stopped[/yellow]")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Enables `python -m fastmcp_tasks.worker_cli worker <server>` for running an
|
||||
# out-of-process worker now that core dropped the `fastmcp tasks` subcommand.
|
||||
tasks_app()
|
||||
57
fastmcp_tasks/pyproject.toml
Normal file
57
fastmcp_tasks/pyproject.toml
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
[project]
|
||||
name = "fastmcp-tasks"
|
||||
dynamic = ["version", "dependencies"]
|
||||
description = "Background task execution for FastMCP servers via the io.modelcontextprotocol/tasks extension (SEP-2663)."
|
||||
authors = [{ name = "Jeremiah Lowin" }]
|
||||
|
||||
requires-python = ">=3.10"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
|
||||
keywords = [
|
||||
"mcp",
|
||||
"fastmcp tasks",
|
||||
"background tasks",
|
||||
"model context protocol",
|
||||
"fastmcp",
|
||||
]
|
||||
classifiers = [
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://gofastmcp.com"
|
||||
Repository = "https://github.com/PrefectHQ/fastmcp"
|
||||
Documentation = "https://gofastmcp.com"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling", "uv-dynamic-versioning>=0.7.0"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.version]
|
||||
source = "uv-dynamic-versioning"
|
||||
|
||||
[tool.hatch.metadata]
|
||||
allow-direct-references = true
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["fastmcp_tasks"]
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
vcs = "git"
|
||||
style = "pep440"
|
||||
bump = true
|
||||
fallback-version = "0.0.0"
|
||||
|
||||
[tool.hatch.metadata.hooks.uv-dynamic-versioning]
|
||||
dependencies = [
|
||||
"fastmcp-slim[server]=={{ version }}",
|
||||
"pydocket>=0.20.0",
|
||||
]
|
||||
|
|
@ -58,7 +58,7 @@ azure = ["fastmcp-slim[azure]=={{ version }}"]
|
|||
code-mode = ["fastmcp-slim[code-mode]=={{ version }}"]
|
||||
gemini = ["fastmcp-slim[gemini]=={{ version }}"]
|
||||
openai = ["fastmcp-slim[openai]=={{ version }}"]
|
||||
tasks = ["fastmcp-slim[tasks]=={{ version }}"]
|
||||
tasks = ["fastmcp-tasks=={{ version }}"]
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
vcs = "git"
|
||||
|
|
@ -67,7 +67,7 @@ bump = true
|
|||
fallback-version = "0.0.0"
|
||||
|
||||
[tool.uv.workspace]
|
||||
members = ["fastmcp_slim", "fastmcp_remote"]
|
||||
members = ["fastmcp_slim", "fastmcp_remote", "fastmcp_tasks"]
|
||||
|
||||
[tool.uv]
|
||||
default-groups = ["dev"]
|
||||
|
|
@ -108,6 +108,7 @@ dev = [
|
|||
fastmcp = { workspace = true }
|
||||
fastmcp-slim = { workspace = true }
|
||||
fastmcp-remote = { workspace = true }
|
||||
fastmcp-tasks = { workspace = true }
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
|
|
@ -129,7 +130,7 @@ markers = [
|
|||
"subprocess_heavy: marks tests that spawn a fresh Python interpreter which imports FastMCP. Each one costs a full interpreter's memory and startup, so they run serially alongside client_process tests rather than competing with parallel xdist workers.",
|
||||
"conformance: marks MCP conformance tests (require Node.js/npx)",
|
||||
]
|
||||
pythonpath = ["fastmcp_slim", "fastmcp_remote"]
|
||||
pythonpath = ["fastmcp_slim", "fastmcp_remote", "fastmcp_tasks"]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py", "*_test.py"]
|
||||
python_classes = ["Test*"]
|
||||
|
|
@ -137,7 +138,7 @@ python_functions = ["test_*"]
|
|||
addopts = ["--inline-snapshot=disable"]
|
||||
|
||||
[tool.ty.src]
|
||||
include = ["fastmcp_slim", "fastmcp_remote", "tests", "examples"]
|
||||
include = ["fastmcp_slim", "fastmcp_remote", "fastmcp_tasks", "tests", "examples"]
|
||||
exclude = [
|
||||
"**/node_modules",
|
||||
"**/__pycache__",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,33 @@
|
|||
"""Tests for the fastmcp tasks CLI."""
|
||||
|
||||
import pytest
|
||||
from fastmcp_tasks.settings import DocketSettings
|
||||
from fastmcp_tasks.worker_cli import (
|
||||
check_distributed_backend,
|
||||
resolve_docket_settings,
|
||||
tasks_app,
|
||||
)
|
||||
|
||||
from fastmcp.cli.tasks import check_distributed_backend, tasks_app
|
||||
from fastmcp.utilities.tests import temporary_settings
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp_tasks import TasksExtension
|
||||
|
||||
|
||||
class TestResolveDocketSettings:
|
||||
"""`resolve_docket_settings` reads the server's *registered* extension."""
|
||||
|
||||
def test_reads_the_registered_extensions_settings(self):
|
||||
"""The constructor-configured URL is visible without any env var."""
|
||||
mcp = FastMCP("t")
|
||||
mcp.add_extension(TasksExtension(url="redis://example:6379/0"))
|
||||
settings = resolve_docket_settings(mcp)
|
||||
assert settings.url == "redis://example:6379/0"
|
||||
|
||||
def test_exits_when_no_tasks_extension_registered(self):
|
||||
"""A server with no TasksExtension has nothing for the CLI to serve."""
|
||||
mcp = FastMCP("t")
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
resolve_docket_settings(mcp)
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
|
||||
class TestCheckDistributedBackend:
|
||||
|
|
@ -11,17 +35,17 @@ class TestCheckDistributedBackend:
|
|||
|
||||
def test_succeeds_with_redis_url(self):
|
||||
"""Test that it succeeds with Redis URL."""
|
||||
with temporary_settings(docket__url="redis://localhost:6379/0"):
|
||||
check_distributed_backend()
|
||||
settings = DocketSettings(url="redis://localhost:6379/0")
|
||||
check_distributed_backend(settings)
|
||||
|
||||
def test_exits_with_helpful_error_for_memory_url(self):
|
||||
"""Test that it exits with helpful error for memory:// URLs."""
|
||||
with temporary_settings(docket__url="memory://test-123"):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
check_distributed_backend()
|
||||
settings = DocketSettings(url="memory://test-123")
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
check_distributed_backend(settings)
|
||||
|
||||
assert isinstance(exc_info.value, SystemExit)
|
||||
assert exc_info.value.code == 1
|
||||
assert isinstance(exc_info.value, SystemExit)
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
|
||||
class TestWorkerCommand:
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ from pydantic import AnyUrl
|
|||
|
||||
import fastmcp
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.client.tasks import TaskNotificationHandler
|
||||
from fastmcp.client.transports import (
|
||||
ClientTransport,
|
||||
FastMCPTransport,
|
||||
|
|
@ -886,32 +885,19 @@ async def test_client_list_dict_return_type():
|
|||
assert result.data == [{"city": "NYC", "temp": 72}, {"city": "LA", "temp": 85}]
|
||||
|
||||
|
||||
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[invalid-assignment]
|
||||
client._submitted_task_ids.add("task-1")
|
||||
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 == {}
|
||||
assert clone._submitted_task_ids == set()
|
||||
assert clone._task_registry is not client._task_registry
|
||||
assert clone._submitted_task_ids is not client._submitted_task_ids
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -42,14 +42,11 @@ class TestCacheConstruction:
|
|||
def test_cache_none_is_disabled_by_default(self):
|
||||
"""Caching is opt-in: the default `cache=None` builds no cache, so a legacy
|
||||
connection is byte-identical to pre-v4 behavior (no handler wrapping)."""
|
||||
from fastmcp.client.tasks import TaskNotificationHandler
|
||||
|
||||
client = Client(FastMCP("x"))
|
||||
assert client._response_cache is None
|
||||
# The message handler is the bare default, not a cache-evicting wrapper.
|
||||
assert isinstance(
|
||||
client._session_kwargs["message_handler"], TaskNotificationHandler
|
||||
)
|
||||
# No cache means no cache-evicting wrapper: the message handler is the
|
||||
# bare default (None), not a wrapper.
|
||||
assert client._session_kwargs.get("message_handler") is None
|
||||
|
||||
def test_cache_true_builds_default(self):
|
||||
client = Client(FastMCP("x"), cache=True)
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
"""Configuration for client task tests."""
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
"""
|
||||
Tests for client-side prompt task methods.
|
||||
|
||||
Tests the client's get_prompt_as_task method.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.client.tasks import PromptTask
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def prompt_server():
|
||||
"""Create a test server with background-enabled prompts."""
|
||||
mcp = FastMCP("prompt-client-test")
|
||||
|
||||
@mcp.prompt(task=True)
|
||||
async def analysis_prompt(topic: str, style: str = "formal") -> str:
|
||||
"""Generate an analysis prompt."""
|
||||
return f"Analyze {topic} in a {style} style"
|
||||
|
||||
@mcp.prompt(task=True)
|
||||
async def creative_prompt(theme: str) -> str:
|
||||
"""Generate a creative writing prompt."""
|
||||
return f"Write a story about {theme}"
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
async def test_get_prompt_as_task_returns_prompt_task(prompt_server):
|
||||
"""get_prompt with task=True returns a PromptTask object."""
|
||||
async with Client(prompt_server, mode="legacy") as client:
|
||||
task = await client.get_prompt("analysis_prompt", {"topic": "AI"}, task=True)
|
||||
|
||||
assert isinstance(task, PromptTask)
|
||||
assert isinstance(task.task_id, str)
|
||||
|
||||
|
||||
async def test_prompt_task_server_generated_id(prompt_server):
|
||||
"""get_prompt with task=True gets server-generated task ID."""
|
||||
async with Client(prompt_server, mode="legacy") as client:
|
||||
task = await client.get_prompt(
|
||||
"creative_prompt",
|
||||
{"theme": "future"},
|
||||
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
|
||||
|
||||
|
||||
@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_prompt_task_result_returns_get_prompt_result(prompt_server):
|
||||
"""PromptTask.result() returns GetPromptResult."""
|
||||
async with Client(prompt_server, mode="legacy") as client:
|
||||
task = await client.get_prompt(
|
||||
"analysis_prompt", {"topic": "Robotics", "style": "casual"}, task=True
|
||||
)
|
||||
|
||||
# Verify background execution
|
||||
assert not task.returned_immediately
|
||||
|
||||
# Get result
|
||||
result = await task.result()
|
||||
|
||||
# Result should be GetPromptResult
|
||||
assert hasattr(result, "description")
|
||||
assert hasattr(result, "messages")
|
||||
# Check the rendered message content, not the description
|
||||
assert len(result.messages) > 0
|
||||
assert "Analyze Robotics" in result.messages[0].content.text
|
||||
|
||||
|
||||
async def test_prompt_task_await_syntax(prompt_server):
|
||||
"""PromptTask can be awaited directly."""
|
||||
async with Client(prompt_server, mode="legacy") as client:
|
||||
task = await client.get_prompt("creative_prompt", {"theme": "ocean"}, task=True)
|
||||
|
||||
# Can await task directly
|
||||
result = await task
|
||||
assert "Write a story about ocean" in result.messages[0].content.text
|
||||
|
||||
|
||||
async def test_prompt_task_status_and_wait(prompt_server):
|
||||
"""PromptTask supports status() and wait() methods."""
|
||||
async with Client(prompt_server, mode="legacy") as client:
|
||||
task = await client.get_prompt("analysis_prompt", {"topic": "Space"}, task=True)
|
||||
|
||||
# Check status
|
||||
status = await task.status()
|
||||
assert status.status in ["working", "completed"]
|
||||
|
||||
# Wait for completion
|
||||
await task.wait(timeout=2.0)
|
||||
|
||||
# Get result
|
||||
result = await task.result()
|
||||
assert "Analyze Space" in result.messages[0].content.text
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
"""
|
||||
Tests for client-side resource task methods.
|
||||
|
||||
Tests the client's read_resource_as_task method.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.client.tasks import ResourceTask
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def resource_server():
|
||||
"""Create a test server with background-enabled resources."""
|
||||
mcp = FastMCP("resource-client-test")
|
||||
|
||||
@mcp.resource("file://document.txt", task=True)
|
||||
async def document() -> str:
|
||||
"""A document resource."""
|
||||
return "Document content here"
|
||||
|
||||
@mcp.resource("file://data/{id}.json", task=True)
|
||||
async def data_file(id: str) -> str:
|
||||
"""A parameterized data resource."""
|
||||
return f'{{"id": "{id}", "value": 42}}'
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
async def test_read_resource_as_task_returns_resource_task(resource_server):
|
||||
"""read_resource with task=True returns a ResourceTask object."""
|
||||
async with Client(resource_server, mode="legacy") as client:
|
||||
task = await client.read_resource("file://document.txt", task=True)
|
||||
|
||||
assert isinstance(task, ResourceTask)
|
||||
assert isinstance(task.task_id, str)
|
||||
|
||||
|
||||
async def test_resource_task_server_generated_id(resource_server):
|
||||
"""read_resource with task=True gets server-generated task ID."""
|
||||
async with Client(resource_server, mode="legacy") as client:
|
||||
task = await client.read_resource("file://document.txt", 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
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
reason="SDK v2 has no `task` field on ReadResourceRequestParams, so "
|
||||
"resource reads cannot be submitted as background tasks over the wire and "
|
||||
"always graceful-degrade to immediate execution (sdk-feedback #3).",
|
||||
strict=True,
|
||||
)
|
||||
async def test_resource_task_result_returns_read_resource_result(resource_server):
|
||||
"""ResourceTask.result() returns list of ReadResourceContents."""
|
||||
async with Client(resource_server, mode="legacy") as client:
|
||||
task = await client.read_resource("file://document.txt", task=True)
|
||||
|
||||
# Verify background execution
|
||||
assert not task.returned_immediately
|
||||
|
||||
# Get result
|
||||
result = await task.result()
|
||||
|
||||
# Result should be list of ReadResourceContents
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
assert result[0].text == "Document content here"
|
||||
|
||||
|
||||
async def test_resource_task_await_syntax(resource_server):
|
||||
"""ResourceTask can be awaited directly."""
|
||||
async with Client(resource_server, mode="legacy") as client:
|
||||
task = await client.read_resource("file://document.txt", task=True)
|
||||
|
||||
# Can await task directly
|
||||
result = await task
|
||||
assert result[0].text == "Document content here"
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
reason="SDK v2 has no `task` field on ReadResourceRequestParams, so "
|
||||
"resource reads cannot be submitted as background tasks over the wire and "
|
||||
"always graceful-degrade to immediate execution (sdk-feedback #3).",
|
||||
strict=True,
|
||||
)
|
||||
async def test_resource_template_task(resource_server):
|
||||
"""Resource templates work with task support."""
|
||||
async with Client(resource_server, mode="legacy") as client:
|
||||
task = await client.read_resource("file://data/999.json", task=True)
|
||||
|
||||
# Verify background execution
|
||||
assert not task.returned_immediately
|
||||
|
||||
# Get result
|
||||
result = await task.result()
|
||||
assert '"id": "999"' in result[0].text
|
||||
|
||||
|
||||
async def test_resource_task_status_and_wait(resource_server):
|
||||
"""ResourceTask supports status() and wait() methods."""
|
||||
async with Client(resource_server, mode="legacy") as client:
|
||||
task = await client.read_resource("file://document.txt", task=True)
|
||||
|
||||
# Check status
|
||||
status = await task.status()
|
||||
assert status.status in ["working", "completed"]
|
||||
|
||||
# Wait for completion
|
||||
await task.wait(timeout=2.0)
|
||||
|
||||
# Get result
|
||||
result = await task.result()
|
||||
assert "Document content" in result[0].text
|
||||
|
|
@ -1,281 +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
|
||||
|
||||
|
||||
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"
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
"""
|
||||
Tests for client-side task protocol.
|
||||
|
||||
Generic protocol tests that use tools as test fixtures.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
|
||||
|
||||
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
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
"""
|
||||
Tests for client-side tool task methods.
|
||||
|
||||
Tests the client's tool-specific task functionality, parallel to
|
||||
test_client_prompt_tasks.py and test_client_resource_tasks.py.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.client.tasks import ToolTask
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def tool_task_server():
|
||||
"""Create a test server with task-enabled tools."""
|
||||
mcp = FastMCP("tool-task-test")
|
||||
|
||||
@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
|
||||
|
||||
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)
|
||||
|
||||
assert isinstance(task, ToolTask)
|
||||
assert isinstance(task.task_id, str)
|
||||
assert len(task.task_id) > 0
|
||||
|
||||
|
||||
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
|
||||
|
||||
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())
|
||||
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)
|
||||
|
||||
status = await task.status()
|
||||
assert status.task_id == task.task_id
|
||||
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"
|
||||
|
||||
|
||||
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")
|
||||
|
||||
@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()
|
||||
|
||||
|
||||
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
|
||||
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")
|
||||
|
||||
@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=False)
|
||||
|
||||
assert not task.returned_immediately
|
||||
result = await task.result()
|
||||
assert result.is_error is True
|
||||
assert "background task failure" in str(result)
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
"""Fallback poll cadence for client-side task waiting.
|
||||
|
||||
Two modes: a server-advertised pollInterval is honored exactly, while an
|
||||
unadvertised one falls back to an exponential ramp up to the client setting.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from mcp_types import GetTaskResult
|
||||
from pydantic import ValidationError
|
||||
|
||||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.client.tasks import MIN_POLL_INTERVAL, ToolTask
|
||||
from fastmcp.settings import Settings
|
||||
from fastmcp.utilities.tests import temporary_settings
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
def test_positive_poll_interval_setting_is_accepted():
|
||||
settings = Settings(client_task_poll_interval=0.25)
|
||||
assert settings.client_task_poll_interval == 0.25
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def task() -> ToolTask:
|
||||
client = Client(FastMCP())
|
||||
return ToolTask(client=client, task_id="t1", tool_name="echo")
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
assert delay == MIN_POLL_INTERVAL
|
||||
assert backoff == MIN_POLL_INTERVAL * 2
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue