Unify background task context forwarding, fix concurrent dependency bugs (#3710)

* Unify background task context forwarding and fix concurrent dependency bugs

We've been getting a steady trickle of edge-case reports around background tasks
and contextual dependencies over the last few months (#3654, #3656, #3569). Each
one pointed at a different symptom, but they all traced back to the same area:
the way context is negotiated between the "frontend" server and Docket workers
was grown piecemeal, with each new piece of context (access tokens, HTTP headers,
origin request IDs) getting its own Redis key, its own restore function, and its
own ContextVar. This made it hard to reason about what state was available where,
and the shared-instance Dependency pattern made concurrent tasks stomp on each
other's cleanup state.

This takes a step back and reworks the whole thing as a single unified system:

- Dependency subclasses (_CurrentContext, Progress, _CurrentAccessToken, etc.)
  are now stateless factories — __aenter__ returns a fresh per-invocation
  object, so concurrent tasks never share mutable state. Fixes #3654, #3656.

- The three individual context-snapshot Redis keys (access_token, http_headers,
  origin_request_id) are collapsed into a single TaskContextSnapshot stored as
  one JSON key per task. The three _restore_task_* functions and two ContextVars
  they populated are gone.

- Sync functions like get_http_request() and get_access_token() now find the
  snapshot transparently in background tasks via a 3-tier sync fallback:
  ContextVar (set by _CurrentContext for functions with deps) → in-memory dict
  (same-process workers) → sync Redis GET (out-of-process workers). No function
  wrapping needed.

- The _wrap_for_task_http_headers hack is deleted. FunctionTool registers its
  raw function with Docket so Docket sees and resolves ALL dependencies,
  including Docket-native ones like Retry and Timeout.

- ProxyTool.from_mcp_tool() now propagates execution.taskSupport metadata from
  remote tools. Fixes #3569.

- Removed redundant _current_docket/_current_worker ContextVar management from
  Context.__aenter__/__aexit__ (they're only set in the lifespan now).

Closes #3654
Closes #3656
Closes #3569

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address code review feedback

- _OptionalCurrentContext: guard __aexit__ against cleaning up contexts it
  didn't create (check is_background_task before delegating)
- Narrow except clauses in snapshot loading (OSError, JSONDecodeError, etc.
  instead of bare Exception)
- Fix docstrings on register_with_docket for resources/prompts/templates
- Simplify Progress: read ExecutionProgress directly from current_execution
  instead of creating and manually entering a DocketProgress wrapper

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Use pop-on-access transfer buffer instead of bounded LRU cache for snapshots

The in-memory snapshot dict is a transfer mechanism, not a cache. Entries go
in at submission and come out at the worker's first access. Using pop instead
of get means the dict only holds entries during the brief submission-to-execution
window, bounded by task concurrency (~10) rather than a 10,000-entry LRU limit.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Drop in-memory transfer buffer, use sync Redis for all backends

Instead of maintaining an in-memory dict to bridge the async/sync gap, use
a sync Redis client directly. For memory:// backends (fakeredis), shares the
same FakeServer instance via docket._redis.get_memory_server() so data written
by the async Docket client is visible to sync reads. For real Redis, creates a
standard sync connection. No in-process state to manage at all.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Move snapshot operations to TaskContextSnapshot methods

capture(), from_json(), to_json(), save() are now classmethod/instance methods
on the dataclass instead of free functions. Deduplicates JSON parsing that was
copy-pasted between the async and sync load paths.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Trim implementation details from register_with_docket docstrings

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Clarify docket lookup comment in submit_to_docket

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Restore docket/worker ContextVar bridge in Context.__aenter__

Servers that own the Docket (the parent) re-set _current_docket/_current_worker
from their instance attributes when entering a Context. Mounted children skip
this (their _docket is None), so they inherit the parent's value. This is needed
for ASGI deployments where ContextVars set during the lifespan don't propagate
to request handlers.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Key snapshot cache by task_id to prevent cross-task context leakage

Docket workers may reuse the same asyncio context for sequential tasks.
The ContextVar cache now stores (task_id, snapshot) tuples so stale entries
from previous tasks are automatically ignored.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Guidry 2026-04-03 10:48:27 -04:00 committed by GitHub
commit d41bcb2c9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 586 additions and 349 deletions

View file

@ -333,11 +333,7 @@ class FunctionPrompt(Prompt):
raise PromptError(f"Error rendering prompt {self.name}.") from e
def register_with_docket(self, docket: Docket) -> None:
"""Register this prompt with docket for background execution.
FunctionPrompt registers the underlying function, which has the user's
Depends parameters for docket to resolve.
"""
"""Register this prompt with docket for background execution."""
if not self.task_config.supports_tasks():
return
docket.register(self.fn, names=[self.key])

View file

@ -228,11 +228,7 @@ class FunctionResource(Resource):
return result
def register_with_docket(self, docket: Docket) -> None:
"""Register this resource with docket for background execution.
FunctionResource registers the underlying function, which has the user's
Depends parameters for docket to resolve.
"""
"""Register this resource with docket for background execution."""
if not self.task_config.supports_tasks():
return
docket.register(self.fn, names=[self.key])

View file

@ -439,11 +439,7 @@ class FunctionResourceTemplate(ResourceTemplate):
return result
def register_with_docket(self, docket: Docket) -> None:
"""Register this template with docket for background execution.
FunctionResourceTemplate registers the underlying function, which has the
user's Depends parameters for docket to resolve.
"""
"""Register this template with docket for background execution."""
if not self.task_config.supports_tasks():
return
docket.register(self.fn, names=[self.key])

View file

@ -272,16 +272,18 @@ class Context:
self._server_token = _current_server.set(weakref.ref(self.fastmcp))
# Set docket/worker from server instance for this request's context.
# This ensures ContextVars work even in ASGI environments (Lambda, FastAPI mount)
# where lifespan ContextVars don't propagate to request handlers.
server = 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)
else:
if not is_docket_available():
# Without docket, the lifespan won't provide a SharedContext,
# so create one scoped to this Context for Shared() dependencies.
self._shared_context = SharedContext()
@ -297,7 +299,6 @@ class Context:
_current_worker,
)
# Mirror __aenter__: clean up docket/worker tokens or SharedContext
if hasattr(self, "_worker_token"):
_current_worker.reset(self._worker_token)
del self._worker_token

View file

@ -15,7 +15,7 @@ import weakref
from collections import OrderedDict
from collections.abc import AsyncGenerator, Callable
from contextlib import AsyncExitStack, asynccontextmanager
from contextvars import ContextVar, Token
from contextvars import ContextVar
from dataclasses import dataclass
from datetime import datetime, timezone
from functools import lru_cache
@ -65,6 +65,7 @@ __all__ = [
"CurrentWorker",
"Progress",
"TaskContextInfo",
"TaskContextSnapshot",
"TokenClaim",
"get_access_token",
"get_context",
@ -205,14 +206,215 @@ def register_task_server(task_id: str, server: FastMCP) -> None:
_current_docket: ContextVar[Docket | None] = ContextVar("docket", default=None)
_current_worker: ContextVar[Worker | None] = ContextVar("worker", default=None)
_task_access_token: ContextVar[AccessToken | None] = ContextVar(
"task_access_token", default=None
)
_task_http_headers: ContextVar[dict[str, str] | None] = ContextVar(
"task_http_headers", default=None
# --- Unified task context snapshot ---
@dataclass(frozen=True, slots=True)
class TaskContextSnapshot:
"""All context data snapshotted at task-submission time.
Stored as a single Redis key per task, restored once in the worker.
"""
access_token_json: str | None = None
http_headers: dict[str, str] | None = None
origin_request_id: str | None = None
@classmethod
def capture(cls) -> TaskContextSnapshot:
"""Capture current context for background task execution."""
access_token = get_access_token()
ctx = get_context()
request_context = ctx.request_context
return cls(
access_token_json=(
access_token.model_dump_json() if access_token else None
),
http_headers=get_http_headers(include_all=True) or None,
origin_request_id=(
str(request_context.request_id) if request_context is not None else None
),
)
@classmethod
def from_json(cls, raw: str | bytes) -> TaskContextSnapshot:
"""Deserialize from JSON stored in Redis."""
if isinstance(raw, bytes):
raw = raw.decode()
parsed = json.loads(raw)
headers = parsed.get("http_headers")
if isinstance(headers, dict):
headers = {str(k).lower(): str(v) for k, v in headers.items()}
return cls(
access_token_json=parsed.get("access_token_json"),
http_headers=headers,
origin_request_id=parsed.get("origin_request_id"),
)
def to_json(self) -> str:
"""Serialize to JSON for Redis storage."""
return json.dumps(
{
"access_token_json": self.access_token_json,
"http_headers": self.http_headers,
"origin_request_id": self.origin_request_id,
}
)
async def save(
self,
docket: Docket,
session_id: str,
task_id: str,
ttl_seconds: int,
) -> None:
"""Store this snapshot as a single Redis key."""
key = docket.key(f"fastmcp:task:{session_id}:{task_id}:snapshot")
async with docket.redis() as redis:
await redis.set(key, self.to_json(), ex=ttl_seconds)
# Cache keyed by task_id so stale entries from previous tasks in the same
# asyncio context are automatically ignored (Docket workers may reuse contexts).
_task_snapshot: ContextVar[tuple[str, TaskContextSnapshot] | None] = ContextVar(
"task_snapshot", default=None
)
def _set_cached_snapshot(task_id: str, snapshot: TaskContextSnapshot) -> None:
"""Cache a snapshot keyed by task_id."""
_task_snapshot.set((task_id, snapshot))
def _get_cached_snapshot(task_id: str) -> TaskContextSnapshot | None:
"""Get cached snapshot if it belongs to this task."""
cached = _task_snapshot.get()
if cached is not None:
cached_task_id, snapshot = cached
if cached_task_id == task_id:
return snapshot
return None
def _redis_key(session_id: str, task_id: str) -> str:
"""Build the Redis key suffix for a task snapshot."""
return f"fastmcp:task:{session_id}:{task_id}:snapshot"
async def _load_task_snapshot_async(
session_id: str, task_id: str
) -> TaskContextSnapshot | None:
"""Load task context snapshot from Redis (async) and cache it.
Idempotent returns the cached value if already loaded for this task.
"""
cached = _get_cached_snapshot(task_id)
if cached is not None:
return cached
try:
docket = get_server()._docket
except RuntimeError:
docket = None
if docket is None:
docket = _current_docket.get()
if docket is None:
return None
try:
async with docket.redis() as redis:
raw = await redis.get(docket.key(_redis_key(session_id, task_id)))
if raw is None:
return None
snapshot = TaskContextSnapshot.from_json(raw)
_set_cached_snapshot(task_id, snapshot)
return snapshot
except (OSError, json.JSONDecodeError, KeyError, ValueError):
_logger.warning(
"Failed to load task snapshot for %s:%s",
session_id,
task_id,
exc_info=True,
)
return None
def _get_task_snapshot_sync() -> TaskContextSnapshot | None:
"""Get the task snapshot using only sync operations.
Fallback chain:
1. ContextVar cache (keyed by task_id, set by async or sync loaders)
2. Sync Redis GET (works for both memory:// and real Redis)
"""
task_info = get_task_context()
if task_info is None:
return None
cached = _get_cached_snapshot(task_info.task_id)
if cached is not None:
return cached
return _load_task_snapshot_sync(task_info.session_id, task_info.task_id)
def _load_task_snapshot_sync(
session_id: str, task_id: str
) -> TaskContextSnapshot | None:
"""Load snapshot via sync Redis.
For memory:// backends (fakeredis), shares the same FakeServer instance
that Docket uses so data is accessible. For real Redis, creates a standard
sync connection.
"""
try:
from docket.dependencies import current_docket as _docket_cv
docket = _docket_cv.get()
except (LookupError, ImportError):
return None
if docket is None:
return None
try:
sync_redis = _get_sync_redis(docket.url)
raw = sync_redis.get(docket.key(_redis_key(session_id, task_id)))
if raw is None:
return None
snapshot = TaskContextSnapshot.from_json(raw)
_set_cached_snapshot(task_id, snapshot)
return snapshot
except (OSError, json.JSONDecodeError, KeyError, ValueError, ImportError):
_logger.warning(
"Failed to load task snapshot via sync Redis for %s:%s",
session_id,
task_id,
exc_info=True,
)
return None
def _get_sync_redis(url: str) -> Any:
"""Get a sync Redis client that shares the same backend as Docket.
For memory:// URLs, connects to the same fakeredis FakeServer instance
so data written by the async Docket client is visible. For real Redis
URLs, creates a standard sync connection.
"""
from docket._redis import get_memory_server
server = get_memory_server(url)
if server is not None:
from fakeredis import FakeRedis
return FakeRedis(server=server)
from redis import Redis
return Redis.from_url(url)
# --- Docket availability check ---
_DOCKET_AVAILABLE: bool | None = None
@ -459,8 +661,10 @@ def get_http_request() -> Request:
request = _current_http_request.get()
# In Docket workers, restore a minimal request from the snapshotted headers.
# Uses sync fallback chain: ContextVar → in-memory dict → sync Redis.
if request is None:
task_headers = _task_http_headers.get()
snapshot = _get_task_snapshot_sync()
task_headers = snapshot.http_headers if snapshot else None
if task_headers:
request = Request(
{
@ -575,12 +779,11 @@ def get_access_token() -> AccessToken | None:
# Fall back to background task snapshot (#3095)
# In Docket workers, neither HTTP request nor SDK context var are available.
# The token was snapshotted in Redis at submit_to_docket() time and restored
# into this ContextVar by _CurrentContext.__aenter__().
# Uses sync fallback chain: ContextVar → in-memory dict → sync Redis.
if access_token is None:
task_token = _task_access_token.get()
if task_token is not None:
# Check expiration: if expires_at is set and past, treat as expired
snapshot = _get_task_snapshot_sync()
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
@ -811,112 +1014,17 @@ async def resolve_dependencies(
# so that get_dependency_parameters can detect them.
async def _restore_task_access_token(
session_id: str, task_id: str
) -> Token[AccessToken | None] | None:
"""Restore the access token snapshot from Redis into a ContextVar.
Called when setting up context in a Docket worker. The token was stored at
submit_to_docket() time. The token is restored regardless of expiration;
get_access_token() checks expiry when reading from the ContextVar.
Returns:
The ContextVar token for resetting, or None if nothing was restored.
"""
docket = _current_docket.get()
if docket is None:
return None
token_key = docket.key(f"fastmcp:task:{session_id}:{task_id}:access_token")
try:
async with docket.redis() as redis:
token_data = await redis.get(token_key)
if token_data is not None:
restored = AccessToken.model_validate_json(token_data)
return _task_access_token.set(restored)
except Exception:
_logger.warning(
"Failed to restore access token for task %s:%s",
session_id,
task_id,
exc_info=True,
)
return None
async def _restore_task_http_headers(
session_id: str, task_id: str
) -> Token[dict[str, str] | None] | None:
"""Restore the HTTP header snapshot from Redis into a ContextVar."""
docket = _current_docket.get()
if docket is None:
return None
headers_key = docket.key(f"fastmcp:task:{session_id}:{task_id}:http_headers")
try:
async with docket.redis() as redis:
headers_data = await redis.get(headers_key)
if headers_data is None:
return None
if isinstance(headers_data, bytes):
headers_data = headers_data.decode()
restored = json.loads(str(headers_data))
if not isinstance(restored, dict):
return None
return _task_http_headers.set(
{str(name).lower(): str(value) for name, value in restored.items()}
)
except Exception:
_logger.warning(
"Failed to restore HTTP headers for task %s:%s",
session_id,
task_id,
exc_info=True,
)
return None
async def _restore_task_origin_request_id(session_id: str, task_id: str) -> str | None:
"""Restore the origin request ID snapshot for a background task.
Returns None if no request ID was captured at submission time.
"""
docket = _current_docket.get()
if docket is None:
return None
request_id_key = docket.key(
f"fastmcp:task:{session_id}:{task_id}:origin_request_id"
)
try:
async with docket.redis() as redis:
request_id_data = await redis.get(request_id_key)
if request_id_data is None:
return None
if isinstance(request_id_data, bytes):
return request_id_data.decode()
return str(request_id_data)
except Exception:
_logger.warning(
"Failed to restore origin request ID for task %s:%s",
session_id,
task_id,
exc_info=True,
)
return None
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 restores the access token snapshot from Redis.
"""
and loads the unified task snapshot from Redis.
_context: Context | None = None
_access_token_cv_token: Token[AccessToken | None] | None = None
_http_headers_cv_token: Token[dict[str, str] | None] | None = None
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.
"""
async def __aenter__(self) -> Context:
from fastmcp.server.context import Context, _current_context
@ -929,36 +1037,24 @@ class _CurrentContext(Dependency["Context"]):
# Check if we're in a Docket worker context
task_info = get_task_context()
if task_info is not None:
# Get session from registry (registered when task was submitted)
session = get_task_session(task_info.session_id)
# Get server from ContextVar
server = get_server()
origin_request_id = await _restore_task_origin_request_id(
# Load unified snapshot (sets _task_snapshot ContextVar)
snapshot = await _load_task_snapshot_async(
task_info.session_id, task_info.task_id
)
# Create task-aware Context
self._context = Context(
origin_request_id = snapshot.origin_request_id if snapshot else None
ctx = Context(
fastmcp=server,
session=session,
task_id=task_info.task_id,
origin_request_id=origin_request_id,
)
# Enter the context to set up ContextVars
await self._context.__aenter__()
await ctx.__aenter__()
return ctx
# Restore access token snapshot from Redis (#3095)
self._access_token_cv_token = await _restore_task_access_token(
task_info.session_id, task_info.task_id
)
# Restore HTTP headers snapshot from Redis (#3631)
self._http_headers_cv_token = await _restore_task_http_headers(
task_info.session_id, task_info.task_id
)
return self._context
# Neither foreground nor background context available
raise RuntimeError(
"No active context found. This can happen if:\n"
" - Called outside an MCP request handler\n"
@ -972,40 +1068,29 @@ class _CurrentContext(Dependency["Context"]):
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
# Clean up access token ContextVar
if self._access_token_cv_token is not None:
_task_access_token.reset(self._access_token_cv_token)
self._access_token_cv_token = None
# Clean up HTTP headers ContextVar
if self._http_headers_cv_token is not None:
_task_http_headers.reset(self._http_headers_cv_token)
self._http_headers_cv_token = None
# Clean up if we created a context for background task
if self._context is not None:
await self._context.__aexit__(exc_type, exc_value, traceback)
self._context = None
from fastmcp.server.context import _current_context
ctx = _current_context.get()
if ctx is not None and ctx.is_background_task:
await ctx.__aexit__(exc_type, exc_value, traceback)
class _OptionalCurrentContext(Dependency["Context | None"]):
"""Context dependency that degrades to None when no context is active.
"""Context dependency that returns None instead of raising when no context
is active. Used for ``ctx: Context = None`` parameter patterns.
This is implemented as a wrapper (composition), not a subclass of
`_CurrentContext`, to avoid overriding `__aenter__` with an incompatible
return type.
Delegates entirely to ``_CurrentContext`` just catches the RuntimeError.
Cleanup is handled by ``_CurrentContext.__aexit__`` reading from the
task-local ContextVar.
"""
_inner: _CurrentContext | None = None
async def __aenter__(self) -> Context | None:
inner = _CurrentContext()
try:
context = await inner.__aenter__()
return await _CurrentContext().__aenter__()
except RuntimeError as exc:
if "No active context found" in str(exc):
return None
raise
self._inner = inner
return context
async def __aexit__(
self,
@ -1013,10 +1098,11 @@ class _OptionalCurrentContext(Dependency["Context | None"]):
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
if self._inner is None:
return
await self._inner.__aexit__(exc_type, exc_value, traceback)
self._inner = None
from fastmcp.server.context import _current_context
ctx = _current_context.get()
if ctx is not None and ctx.is_background_task:
await _CurrentContext().__aexit__(exc_type, exc_value, traceback)
def CurrentContext() -> Context:
@ -1054,7 +1140,14 @@ class _CurrentDocket(Dependency["Docket"]):
async def __aenter__(self) -> Docket:
require_docket("CurrentDocket()")
docket = _current_docket.get()
# 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 "
@ -1104,7 +1197,13 @@ class _CurrentWorker(Dependency["Worker"]):
async def __aenter__(self) -> Worker:
require_docket("CurrentWorker()")
worker = _current_worker.get()
# 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 "
@ -1191,20 +1290,8 @@ def CurrentFastMCP() -> FastMCP:
class _CurrentRequest(Dependency[Request]):
"""Async context manager for HTTP Request dependency."""
_task_http_headers_cv_token: Token[dict[str, str] | None] | None = None
async def __aenter__(self) -> Request:
try:
return get_http_request()
except RuntimeError:
task_info = get_task_context()
if task_info is None:
raise
if _task_http_headers.get() is None:
self._task_http_headers_cv_token = await _restore_task_http_headers(
task_info.session_id, task_info.task_id
)
return get_http_request()
return get_http_request()
async def __aexit__(
self,
@ -1212,9 +1299,7 @@ class _CurrentRequest(Dependency[Request]):
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
if self._task_http_headers_cv_token is not None:
_task_http_headers.reset(self._task_http_headers_cv_token)
self._task_http_headers_cv_token = None
pass
def CurrentRequest() -> Request:
@ -1246,15 +1331,7 @@ def CurrentRequest() -> Request:
class _CurrentHeaders(Dependency[dict[str, str]]):
"""Async context manager for HTTP Headers dependency."""
_task_http_headers_cv_token: Token[dict[str, str] | None] | None = None
async def __aenter__(self) -> dict[str, str]:
if _task_http_headers.get() is None:
task_info = get_task_context()
if task_info is not None:
self._task_http_headers_cv_token = await _restore_task_http_headers(
task_info.session_id, task_info.task_id
)
return get_http_headers(include={"authorization"})
async def __aexit__(
@ -1263,9 +1340,7 @@ class _CurrentHeaders(Dependency[dict[str, str]]):
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
if self._task_http_headers_cv_token is not None:
_task_http_headers.reset(self._task_http_headers_cv_token)
self._task_http_headers_cv_token = None
pass
def CurrentHeaders() -> dict[str, str]:
@ -1388,16 +1463,14 @@ class InMemoryProgress:
class Progress(Dependency["Progress"]):
"""FastMCP Progress dependency that works in both server and worker contexts.
"""Progress dependency that works in both server and worker contexts.
Handles three execution modes:
- In Docket worker: Uses the execution's progress (observable via Redis)
- In FastMCP server with Docket: Falls back to in-memory progress
- In FastMCP server without Docket: Uses in-memory progress
In a Docket worker, delegates to the execution's Redis-backed progress
(observable across processes). Otherwise, uses in-memory tracking.
This allows tools to use Progress() regardless of whether they're called
immediately or as background tasks, and regardless of whether pydocket
is installed.
The shared default instance acts as a stateless factory ``__aenter__``
creates a fresh ``Progress`` per invocation so concurrent tasks never
share mutable state.
"""
_impl: ProgressLike | None = None
@ -1407,18 +1480,19 @@ class Progress(Dependency["Progress"]):
if server_ref is None or server_ref() is None:
raise RuntimeError("Progress dependency requires a FastMCP server context.")
if is_docket_available():
from docket.dependencies import Progress as DocketProgress
instance = Progress()
if is_docket_available():
try:
docket_progress = DocketProgress()
self._impl = await docket_progress.__aenter__()
return self
from docket.dependencies import current_execution
instance._impl = current_execution.get().progress
return instance
except LookupError:
pass
self._impl = InMemoryProgress()
return self
instance._impl = InMemoryProgress()
return instance
async def __aexit__(
self,
@ -1426,7 +1500,7 @@ class Progress(Dependency["Progress"]):
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
self._impl = None
pass
@property
def current(self) -> int | None:
@ -1468,22 +1542,9 @@ class Progress(Dependency["Progress"]):
class _CurrentAccessToken(Dependency[AccessToken]):
"""Async context manager for AccessToken dependency."""
_access_token_cv_token: Token[AccessToken | None] | None = None
async def __aenter__(self) -> AccessToken:
token = get_access_token()
# If no token found and we're in a Docket worker, try restoring from
# Redis. This handles the case where ctx: Context is not in the
# function signature, so _CurrentContext never ran the restoration.
if token is None:
task_info = get_task_context()
if task_info is not None:
self._access_token_cv_token = await _restore_task_access_token(
task_info.session_id, task_info.task_id
)
token = get_access_token()
if token is None:
raise RuntimeError(
"No access token found. Ensure authentication is configured "
@ -1497,9 +1558,7 @@ class _CurrentAccessToken(Dependency[AccessToken]):
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
if self._access_token_cv_token is not None:
_task_access_token.reset(self._access_token_cv_token)
self._access_token_cv_token = None
pass
def CurrentAccessToken() -> AccessToken:

View file

@ -90,14 +90,12 @@ class LifespanMixin:
name=settings.docket.name,
url=settings.docket.url,
) as docket:
# Store on server instance for cross-task access (FastMCPTransport)
self._docket = docket
# Register task-enabled components with Docket
for component in task_components:
component.register_with_docket(docket)
# Set Docket in ContextVar so CurrentDocket can access it
docket_token = _current_docket.set(docket)
try:
# Build worker kwargs from settings
@ -112,9 +110,7 @@ class LifespanMixin:
# Create and start Worker
async with Worker(docket, **worker_kwargs) as worker:
# Store on server instance for cross-context access
self._worker = worker
# Set Worker in ContextVar so CurrentWorker can access it
worker_token = _current_worker.set(worker)
try:
worker_task = asyncio.create_task(worker.run_forever())
@ -128,9 +124,7 @@ class LifespanMixin:
_current_worker.reset(worker_token)
self._worker = None
finally:
# Reset ContextVar
_current_docket.reset(docket_token)
# Clear instance attribute
self._docket = None
finally:
# Reset server ContextVar

View file

@ -108,6 +108,7 @@ class ProxyTool(Tool):
icons=mcp_tool.icons,
meta=mcp_tool.meta,
tags=get_fastmcp_metadata(mcp_tool.meta).get("tags", []),
execution=mcp_tool.execution,
)
async def run(

View file

@ -5,7 +5,6 @@ Handles queuing tool/prompt/resource executions to Docket as background tasks.
from __future__ import annotations
import json
import uuid
from contextlib import suppress
from datetime import datetime, timezone
@ -16,10 +15,9 @@ from mcp.shared.exceptions import McpError
from mcp.types import INTERNAL_ERROR, ErrorData
from fastmcp.server.dependencies import (
TaskContextSnapshot,
_current_docket,
get_access_token,
get_context,
get_http_headers,
register_task_server,
)
from fastmcp.server.tasks.config import TaskMeta
@ -78,7 +76,9 @@ async def submit_to_docket(
except RuntimeError:
session_id = "internal"
docket = _current_docket.get()
# 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(
ErrorData(
@ -111,36 +111,17 @@ async def submit_to_docket(
poll_interval_key = docket.key(
f"fastmcp:task:{session_id}:{server_task_id}:poll_interval"
)
origin_request_id_key = docket.key(
f"fastmcp:task:{session_id}:{server_task_id}:origin_request_id"
)
poll_interval_ms = int(component.task_config.poll_interval.total_seconds() * 1000)
origin_request_id = (
str(ctx.request_context.request_id) if ctx.request_context is not None else None
)
# Snapshot the current access token (if any) for background task access (#3095)
access_token = get_access_token()
access_token_key = docket.key(
f"fastmcp:task:{session_id}:{server_task_id}:access_token"
)
http_headers = get_http_headers(include_all=True)
http_headers_key = docket.key(
f"fastmcp:task:{session_id}:{server_task_id}:http_headers"
)
# Snapshot all context (access token, headers, origin request ID) as a single key
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)
if origin_request_id is not None:
await redis.set(origin_request_id_key, origin_request_id, ex=ttl_seconds)
if access_token is not None:
await redis.set(
access_token_key, access_token.model_dump_json(), ex=ttl_seconds
)
if http_headers:
await redis.set(http_headers_key, json.dumps(http_headers), ex=ttl_seconds)
await snapshot.save(docket, session_id, server_task_id, ttl_seconds)
# Register session for Context access in background workers (SEP-1686)
# This enables elicitation/sampling from background tasks via weakref

View file

@ -2,7 +2,6 @@
from __future__ import annotations
import functools
import inspect
import warnings
from collections.abc import Callable
@ -28,12 +27,7 @@ import fastmcp
from fastmcp.decorators import resolve_task_config
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.dependencies import (
_restore_task_http_headers,
_task_http_headers,
get_task_context,
without_injected_parameters,
)
from fastmcp.server.dependencies import without_injected_parameters
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.tools.base import (
Tool,
@ -289,14 +283,13 @@ class FunctionTool(Tool):
def register_with_docket(self, docket: Docket) -> None:
"""Register this tool with docket for background execution.
FunctionTool registers the underlying function, which has the user's
Depends parameters for docket to resolve. The function is wrapped to
eagerly restore HTTP headers from Redis so that get_http_request()
works even without explicit dependency injection.
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(_wrap_for_task_http_headers(self.fn), names=[self.key])
docket.register(self.fn, names=[self.key])
async def add_to_docket(
self,
@ -324,34 +317,6 @@ class FunctionTool(Tool):
return await docket.add(lookup_key, **kwargs)(**arguments)
def _wrap_for_task_http_headers(fn: Callable[..., Any]) -> Callable[..., Any]:
"""Wrap a function to restore HTTP headers in background task workers.
Uses functools.wraps so docket sees the original signature for dependency
resolution while the wrapper eagerly populates _task_http_headers before
the user's function runs.
"""
@functools.wraps(fn)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
task_info = get_task_context()
token = None
if task_info is not None and _task_http_headers.get() is None:
token = await _restore_task_http_headers(
task_info.session_id, task_info.task_id
)
try:
result = fn(*args, **kwargs)
if inspect.isawaitable(result):
result = await result
return result
finally:
if token is not None:
_task_http_headers.reset(token)
return wrapper
@overload
def tool(fn: F) -> F: ...
@overload

View file

@ -0,0 +1,213 @@
"""Tests for concurrent dependency resolution in foreground and background tasks.
Regression tests for:
- #3654: ValueError when concurrent Docket tasks share a Dependency instance
that stores a ContextVar token on `self`
- #3656: Progress raises AssertionError when concurrent tasks share `_impl`
"""
import asyncio
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.dependencies import Progress
from fastmcp.server.context import Context
from fastmcp.server.dependencies import (
get_access_token,
get_http_headers,
)
async def test_concurrent_foreground_tools_with_context():
"""Multiple concurrent tool calls sharing the same CurrentContext() default
should not raise ValueError from ContextVar token resets (#3654)."""
mcp = FastMCP("test")
results: list[str] = []
@mcp.tool()
async def slow_tool(name: str, ctx: Context) -> str:
await asyncio.sleep(0.05)
results.append(name)
return f"done:{name}"
async with Client(mcp) as client:
tasks = [client.call_tool("slow_tool", {"name": f"task-{i}"}) for i in range(4)]
outcomes = await asyncio.gather(*tasks)
assert len(outcomes) == 4
for outcome in outcomes:
assert outcome.content[0].text.startswith("done:")
async def test_concurrent_foreground_tools_with_progress():
"""Multiple concurrent tool calls sharing the same Progress() default
should not raise AssertionError from _impl being None (#3656)."""
mcp = FastMCP("test")
@mcp.tool()
async def variable_tool(
name: str, delay: float, progress: Progress = Progress()
) -> str:
await progress.set_total(3)
await progress.increment()
await asyncio.sleep(delay)
await progress.increment()
await progress.set_message(f"finishing {name}")
await progress.increment()
return f"done:{name}"
async with Client(mcp) as client:
tasks = [
client.call_tool(
"variable_tool", {"name": f"t-{i}", "delay": 0.01 * (i + 1)}
)
for i in range(4)
]
outcomes = await asyncio.gather(*tasks)
assert len(outcomes) == 4
for outcome in outcomes:
assert outcome.content[0].text.startswith("done:")
async def test_concurrent_background_tasks_with_context():
"""Multiple concurrent background tasks sharing _CurrentContext() should
not raise ValueError from ContextVar token resets (#3654)."""
mcp = FastMCP("test")
@mcp.tool(task=True)
async def bg_tool(name: str, ctx: Context) -> str:
await asyncio.sleep(0.05)
return f"bg:{name}"
async with Client(mcp) as client:
task_handles = [
await client.call_tool("bg_tool", {"name": f"bg-{i}"}, task=True)
for i in range(4)
]
results = await asyncio.gather(*[t.result() for t in task_handles])
assert len(results) == 4
for result in results:
assert result.content[0].text.startswith("bg:")
async def test_concurrent_background_tasks_with_progress():
"""Multiple concurrent background tasks sharing Progress() should
not raise AssertionError from _impl being None (#3656)."""
mcp = FastMCP("test")
@mcp.tool(task=True)
async def bg_progress_tool(
name: str, delay: float, progress: Progress = Progress()
) -> str:
await progress.set_total(3)
await progress.increment()
await asyncio.sleep(delay)
await progress.increment()
await progress.set_message(f"bg finishing {name}")
await progress.increment()
return f"bg:{name}"
async with Client(mcp) as client:
task_handles = [
await client.call_tool(
"bg_progress_tool",
{"name": f"bg-{i}", "delay": 0.01 * (i + 1)},
task=True,
)
for i in range(4)
]
results = await asyncio.gather(*[t.result() for t in task_handles])
assert len(results) == 4
for result in results:
assert result.content[0].text.startswith("bg:")
async def test_dependency_aenter_returns_fresh_instances():
"""Verify that Dependency.__aenter__ returns independent per-invocation
objects, not the shared default."""
mcp = FastMCP("test")
instances: list[Context] = []
@mcp.tool()
async def capture_context(ctx: Context) -> str:
instances.append(ctx)
return "ok"
async with Client(mcp) as client:
await asyncio.gather(
client.call_tool("capture_context", {}),
client.call_tool("capture_context", {}),
)
assert len(instances) == 2
assert instances[0] is not instances[1]
async def test_progress_aenter_returns_fresh_instances():
"""Verify that Progress.__aenter__ returns independent per-invocation
objects, not the shared default."""
progress_instances: list[Progress] = []
mcp = FastMCP("test")
@mcp.tool()
async def capture_progress(progress: Progress = Progress()) -> str:
progress_instances.append(progress)
await progress.set_total(1)
await progress.increment()
return "ok"
async with Client(mcp) as client:
await asyncio.gather(
client.call_tool("capture_progress", {}),
client.call_tool("capture_progress", {}),
)
assert len(progress_instances) == 2
assert progress_instances[0] is not progress_instances[1]
assert progress_instances[0]._impl is not progress_instances[1]._impl
async def test_sync_context_functions_work_in_background_without_deps():
"""Sync functions like get_http_request() should work in background tasks
even when the tool declares no Context or CurrentRequest dependency.
This exercises the sync Redis fallback path (_get_task_snapshot_sync
_load_snapshot_sync_redis) which must work with both memory:// (fakeredis)
and real Redis backends.
"""
mcp = FastMCP("test")
@mcp.tool(task=True)
async def bare_sync_access() -> dict[str, str]:
headers = get_http_headers()
return {"has_headers": str(bool(headers))}
async with Client(mcp) as client:
task = await client.call_tool("bare_sync_access", {}, task=True)
result = await task.result()
assert result.data == {"has_headers": "False"}
async def test_sync_context_functions_work_in_background_with_context():
"""Sync functions work via ContextVar when _CurrentContext loads the snapshot."""
mcp = FastMCP("test")
@mcp.tool(task=True)
async def context_sync_access(ctx: Context) -> dict[str, str]:
headers = get_http_headers()
token = get_access_token()
return {
"has_headers": str(bool(headers)),
"has_token": str(token is not None),
"is_background": str(ctx.is_background_task),
}
async with Client(mcp) as client:
task = await client.call_tool("context_sync_access", {}, task=True)
result = await task.result()
assert result.data["is_background"] == "True"

View file

@ -6,10 +6,16 @@ no mocking of Redis, Docket, or session internals.
"""
import asyncio
import json
from datetime import datetime, timezone
from typing import cast
from unittest.mock import patch
import pytest
from mcp import ServerSession
from mcp.server.auth.middleware.auth_context import auth_context_var
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
from pydantic import BaseModel
from fastmcp import FastMCP
from fastmcp.client import Client
@ -17,8 +23,17 @@ from fastmcp.client.elicitation import ElicitResult
from fastmcp.dependencies import CurrentDocket
from fastmcp.server.auth import AccessToken
from fastmcp.server.context import Context
from fastmcp.server.dependencies import get_access_token
from fastmcp.server.elicitation import AcceptedElicitation, DeclinedElicitation
from fastmcp.server.dependencies import (
TaskContextInfo,
TaskContextSnapshot,
_set_cached_snapshot,
get_access_token,
)
from fastmcp.server.elicitation import (
AcceptedElicitation,
CancelledElicitation,
DeclinedElicitation,
)
from fastmcp.server.tasks.elicitation import handle_task_input
# =============================================================================
@ -119,10 +134,6 @@ class TestElicitFailFast:
This test patches ONLY push_notification all other components
(Docket, Redis, session) are real via the memory:// backend.
"""
from unittest.mock import patch
from fastmcp.server.elicitation import CancelledElicitation
mcp = FastMCP("failfast-test")
elicit_started = asyncio.Event()
captured: dict[str, object] = {}
@ -250,16 +261,16 @@ class TestBackgroundTaskIntegration:
assert isinstance(origin, str)
assert origin != ""
key = docket.key(
f"fastmcp:task:{ctx.session_id}:{ctx.task_id}:origin_request_id"
)
# Verify the snapshot in Redis contains the same value
key = docket.key(f"fastmcp:task:{ctx.session_id}:{ctx.task_id}:snapshot")
async with docket.redis() as redis:
raw = await redis.get(key)
assert raw is not None
if isinstance(raw, bytes):
raw = raw.decode()
assert str(raw) == origin
snapshot = json.loads(raw)
assert snapshot["origin_request_id"] == origin
return "ok"
async with Client(mcp) as client:
@ -311,7 +322,6 @@ class TestBackgroundTaskIntegration:
async def test_elicit_with_pydantic_model(self):
"""E2E: tool elicits structured Pydantic input via elicitation_handler."""
from pydantic import BaseModel
class UserInfo(BaseModel):
name: str
@ -371,9 +381,6 @@ class TestAccessTokenInBackgroundTasks:
async def test_token_round_trips_through_background_task(self):
"""E2E: token set at submit time is available inside the worker."""
from mcp.server.auth.middleware.auth_context import auth_context_var
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
mcp = FastMCP("token-roundtrip")
@mcp.tool(task=True)
@ -412,49 +419,60 @@ class TestAccessTokenInBackgroundTasks:
async def test_expired_token_returns_none(self):
"""get_access_token() returns None when task token has expired."""
from datetime import datetime, timezone
from fastmcp.server.dependencies import _task_access_token
expired = AccessToken(
token="expired-jwt",
client_id="test-client",
scopes=["read"],
expires_at=int(datetime.now(timezone.utc).timestamp()) - 3600,
)
_task_access_token.set(expired)
assert get_access_token() is None
_set_cached_snapshot(
"test-task",
TaskContextSnapshot(access_token_json=expired.model_dump_json()),
)
fake_ctx = TaskContextInfo(task_id="test-task", session_id="s")
with patch(
"fastmcp.server.dependencies.get_task_context", return_value=fake_ctx
):
assert get_access_token() is None
async def test_valid_token_with_future_expiry(self):
"""get_access_token() returns token when expiry is in the future."""
from datetime import datetime, timezone
from fastmcp.server.dependencies import _task_access_token
valid = AccessToken(
token="valid-jwt",
client_id="test-client",
scopes=["read"],
expires_at=int(datetime.now(timezone.utc).timestamp()) + 3600,
)
_task_access_token.set(valid)
result = get_access_token()
assert result is not None
assert result.token == "valid-jwt"
_set_cached_snapshot(
"test-task",
TaskContextSnapshot(access_token_json=valid.model_dump_json()),
)
fake_ctx = TaskContextInfo(task_id="test-task", session_id="s")
with patch(
"fastmcp.server.dependencies.get_task_context", return_value=fake_ctx
):
result = get_access_token()
assert result is not None
assert result.token == "valid-jwt"
async def test_token_without_expiry_always_valid(self):
"""get_access_token() returns token when no expires_at is set."""
from fastmcp.server.dependencies import _task_access_token
no_expiry = AccessToken(
token="eternal-jwt",
client_id="test-client",
scopes=["read"],
)
_task_access_token.set(no_expiry)
result = get_access_token()
assert result is not None
assert result.token == "eternal-jwt"
_set_cached_snapshot(
"test-task",
TaskContextSnapshot(access_token_json=no_expiry.model_dump_json()),
)
fake_ctx = TaskContextInfo(task_id="test-task", session_id="s")
with patch(
"fastmcp.server.dependencies.get_task_context", return_value=fake_ctx
):
result = get_access_token()
assert result is not None
assert result.token == "eternal-jwt"
class TestLifespanContextInBackgroundTasks:

View file

@ -13,6 +13,7 @@ import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.dependencies import CurrentDocket, CurrentFastMCP, Depends
from fastmcp.exceptions import ToolError
@pytest.fixture
@ -257,8 +258,6 @@ async def test_dependency_errors_propagate_to_task_failure():
) -> str:
return f"Got: {dep}"
from fastmcp.exceptions import ToolError
async with Client(mcp) as client:
task = await client.call_tool(
"tool_with_failing_dep", {"value": "test"}, task=True

View file

@ -10,6 +10,8 @@ import asyncio
import mcp.types as mt
import pytest
from docket import Docket
from mcp.types import Tool as MCPTool
from mcp.types import ToolExecution
from fastmcp import FastMCP
from fastmcp.client import Client
@ -17,6 +19,7 @@ from fastmcp.prompts.base import PromptResult
from fastmcp.resources.base import ResourceResult
from fastmcp.server.dependencies import CurrentDocket, CurrentFastMCP
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
from fastmcp.server.providers.proxy import ProxyTool
from fastmcp.server.tasks import TaskConfig
from fastmcp.tools.base import ToolResult
@ -571,6 +574,21 @@ class TestMountedTaskMetadata:
assert child_mcp_tool.execution.taskSupport == "optional"
assert parent_mcp_tool.execution.taskSupport == "optional"
async def test_proxy_tool_preserves_execution_metadata(self):
"""ProxyTool.from_mcp_tool should propagate execution.taskSupport (#3569)."""
mcp_tool = MCPTool(
name="remote_task_tool",
description="A remote tool that supports tasks",
inputSchema={"type": "object", "properties": {}},
execution=ToolExecution(taskSupport="optional"),
)
proxy = ProxyTool.from_mcp_tool(lambda: None, mcp_tool)
result = proxy.to_mcp_tool(name=proxy.name)
assert result.execution is not None
assert result.execution.taskSupport == "optional"
class TestMountedTaskConfigModes:
"""Test TaskConfig mode enforcement for mounted tools."""