Skip to content

fix get_access_token and lifespan_context returning None in background tasks - issue (#3095)#3121

Closed
cristiangreco94 wants to merge 1 commit intoPrefectHQ:mainfrom
cristiangreco94:issue-3095-fix-depends-background-tasks
Closed

fix get_access_token and lifespan_context returning None in background tasks - issue (#3095)#3121
cristiangreco94 wants to merge 1 commit intoPrefectHQ:mainfrom
cristiangreco94:issue-3095-fix-depends-background-tasks

Conversation

@cristiangreco94
Copy link
Copy Markdown

@cristiangreco94 cristiangreco94 commented Feb 9, 2026

Description

When a tool is marked with task=True, get_access_token() always returns None and lifespan_context is always empty — even when authentication is properly configured and a lifespan is set. This happens because neither the HTTP request scope nor the SDK's auth context var survive the Docket worker boundary, and request_ctx isn't set in workers.

The fix snapshots the AccessToken into Redis at submit_to_docket() time (alongside existing task metadata), restores it into a ContextVar when the background task Context is created in the Docket worker, and adds it as a fallback in get_access_token(). Expired tokens (past expires_at) return None rather than raising. For lifespan_context, the fix falls back to reading from the FastMCP server instance directly when request_context is unavailable — no Redis needed.

@mcp.tool(name="test_background", task=True)
async def test_background(
    access_token: AccessToken | None = Depends(get_access_token),
) -> str:
    # access_token is now properly populated in background tasks
    if access_token:
        return f"Token: {access_token.token[:20]}..."
    return "No token!"

Contributors Checklist

Review Checklist

  • I have self-reviewed my changes
  • My Pull Request is ready for review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 9, 2026

Walkthrough

Adds background-task access token propagation and a lifespan-context fallback for non-request tasks. On task submission, if an AccessToken exists it is serialized to Redis under a task-specific key with TTL. Background-task startup restores that token into a private ContextVar (with expiry validation) so get_access_token() can return the restored token in task contexts. lifespan_context now returns a stored lifespan result when no request context exists. ContextVar restoration handles are tracked and reset on context exit.

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the primary fix: making get_access_token and lifespan_context work in background tasks, with direct reference to the related issue #3095.
Description check ✅ Passed The description comprehensively covers the problem, solution approach, implementation details, example usage, and all checklist items are addressed with proper status markings.
Linked Issues check ✅ Passed All coding requirements from issue #3095 are met: AccessToken is restored in background tasks via ContextVar fallback [#3095], lifespan_context is made available via fallback to FastMCP instance [#3095], and authentication context is preserved across Docket worker boundary [#3095].
Out of Scope Changes check ✅ Passed All code changes directly address the linked issue: token snapshotting in handlers.py, token restoration in dependencies.py, and lifespan fallback in context.py are all within scope of fixing background task authentication and lifespan context.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/fastmcp/server/dependencies.py (1)

508-521: Expiration check looks correct but import could be hoisted.

The from datetime import datetime, timezone import inside the function body on line 517 is executed on every call to get_access_token() that reaches this branch. While Python caches module imports, moving it to the top-level would be cleaner and marginally faster for hot paths.

Also, verify that expires_at is always an int (epoch seconds). If it could be a float from the JWT, the int() cast on datetime.now(...) is fine, but the < comparison between int and int is safe only if both sides agree on the unit.

src/fastmcp/server/tasks/handlers.py (1)

106-113: Token write failure will abort task submission — consider whether this is intentional.

The access_token Redis write (lines 110-113) is inside the same async with docket.redis() block as the mandatory metadata writes. If model_dump_json() raises (unlikely but possible with custom serializers), the entire submit_to_docket call fails. Since the access token is supplementary (tasks can run without it), you may want to guard this write separately so that serialization issues don't prevent task submission.

♻️ Optional: isolate token write from mandatory metadata
     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 access_token is not None:
-            await redis.set(
-                access_token_key, access_token.model_dump_json(), ex=ttl_seconds
-            )
+        if access_token is not None:
+            try:
+                await redis.set(
+                    access_token_key, access_token.model_dump_json(), ex=ttl_seconds
+                )
+            except Exception:
+                import logging
+                logging.getLogger(__name__).warning(
+                    "Failed to snapshot access token for task %s", server_task_id,
+                    exc_info=True,
+                )

Comment thread src/fastmcp/server/dependencies.py Outdated
Comment on lines +790 to +810
async def _restore_task_access_token(self, session_id: str, task_id: str) -> None:
"""Restore the access token snapshot from Redis into a ContextVar.

Called when creating a task-aware Context in a Docket worker. The token
was stored at submit_to_docket() time. If the token has expired, it is
not restored (get_access_token() will return None).
"""
docket = _current_docket.get()
if docket is None:
return

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)
self._access_token_cv_token = _task_access_token.set(restored)
except Exception:
# Don't let token restoration failures break task execution
pass
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Silent except Exception: pass swallows all errors — log a warning at minimum.

Static analysis flags this (S110, BLE001), and the coding guidelines state: "Never use bare except - be specific with exception types." While except Exception isn't technically bare, it catches far too broadly (e.g., AccessToken.model_validate_json raising ValidationError, Redis connectivity issues, key encoding problems). Silently discarding these makes debugging token-restoration failures very difficult.

At minimum, log a warning. Ideally, narrow the exception types.

As per coding guidelines: "Never use bare except - be specific with exception types."

🛡️ Proposed fix
+        import logging
+
+        _logger = logging.getLogger(__name__)
+
         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)
                 self._access_token_cv_token = _task_access_token.set(restored)
-        except Exception:
-            # Don't let token restoration failures break task execution
-            pass
+        except Exception:
+            # Don't let token restoration failures break task execution
+            _logger.warning(
+                "Failed to restore access token for task %s:%s",
+                session_id,
+                task_id,
+                exc_info=True,
+            )
🧰 Tools
🪛 Ruff (0.14.14)

[error] 808-810: try-except-pass detected, consider logging the exception

(S110)


[warning] 808-808: Do not catch blind exception: Exception

(BLE001)

@marvin-context-protocol marvin-context-protocol Bot added bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. server Related to FastMCP server implementation or server-side functionality. auth Related to authentication (Bearer, JWT, OAuth, WorkOS) for client or server. tests labels Feb 9, 2026
@cristiangreco94 cristiangreco94 force-pushed the issue-3095-fix-depends-background-tasks branch from c4855fb to ae40d0f Compare February 9, 2026 15:03
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/fastmcp/server/dependencies.py (2)

508-521: Expiration check and fallback look correct, but consider hoisting the import.

The fallback logic is sound: check _task_access_token ContextVar, validate expiry, and return. The < comparison correctly treats a token whose expires_at equals now as still valid.

However, from datetime import datetime, timezone is a deferred import inside what can be a frequently called function. Since this module already imports from contextvars, contextlib, etc. at the top level, consider adding datetime to the top-level imports instead.

♻️ Hoist import to module level
 from collections.abc import AsyncGenerator, Callable
 from contextlib import AsyncExitStack, asynccontextmanager
 from contextvars import ContextVar, Token
 from dataclasses import dataclass
+from datetime import datetime, timezone
 from functools import lru_cache

Then in get_access_token():

             if task_token.expires_at is not None:
-                from datetime import datetime, timezone
-
                 if task_token.expires_at < int(datetime.now(timezone.utc).timestamp()):
                     return None

787-787: Consider using Token[AccessToken | None] | None instead of Any.

Both _CurrentContext._access_token_cv_token and _CurrentAccessToken._access_token_cv_token are typed as Any. Since you already import Token from contextvars (line 15), you could use the precise type for better static analysis support.

♻️ Suggested type annotation
 class _CurrentContext(Dependency):  # type: ignore[misc]
     _context: Context | None = None
-    _access_token_cv_token: Any = None
+    _access_token_cv_token: Token[AccessToken | None] | None = None
 class _CurrentAccessToken(Dependency):  # type: ignore[misc]
-    _access_token_cv_token: Any = None
+    _access_token_cv_token: Token[AccessToken | None] | None = None

Also applies to: 1066-1067

Comment on lines +752 to +763
docket = _current_docket.get()
# Fall back to docket's own worker ContextVar, which is set by the worker
# even when _CurrentContext hasn't run (no ctx: Context in signature)
if docket is None:
try:
from docket.dependencies import Dependency as DocketDependency

docket = DocketDependency.docket.get()
except (ImportError, LookupError):
pass
if docket is None:
return None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

rg -n "DocketDependency\.docket" --type=py -C2

Repository: jlowin/fastmcp

Length of output: 439


🏁 Script executed:

# Check if there are other imports or uses of docket library
rg -n "from docket" --type=py -C1

Repository: jlowin/fastmcp

Length of output: 6373


🏁 Script executed:

# Look at the full context of the file to understand the pattern
head -n 20 src/fastmcp/server/dependencies.py

Repository: jlowin/fastmcp

Length of output: 768


🌐 Web query:

docket library Python Dependency ContextVar docket attribute documentation

💡 Result:

In Docket (PyPI package pydocket), “dependency injection” is done by declaring default parameter values like CurrentDocket(), CurrentWorker(), CurrentExecution(), TaskKey(), TaskArgument(), or Depends(fn) in your task signature; Docket resolves those at runtime when executing the task. (chrisguidry.github.io)

Those “current-*” dependencies are contextual (they’re meant to give you “the current docket/worker/execution for this running task” without threading it through every call). In Python, the standard way libraries implement safe per-request/per-task ambient context across async boundaries is contextvars.ContextVar. ContextVar objects have a read-only .name attribute (so if a library creates something like ContextVar("docket"), its “docket attribute”/name is just "docket"). (docs.python.org)

If you’re coming from FastMCP: FastMCP re-exports these DI concepts (and notes its DI is “powered by Docket”; task-related dependencies like CurrentDocket/CurrentWorker require fastmcp[tasks]). (gofastmcp.com)

Citations:


🌐 Web query:

pydocket Dependency.docket ContextVar stable API

💡 Result:

Dependency.docket (a ContextVar hanging off the internal Dependency base class) should be treated as internal / not a stable public API. Docket is still marked “Beta” on PyPI, so internals are especially likely to change. [1]

If you need “current docket/worker/execution” context in a supported way, use the documented built-in dependencies instead:

  • CurrentDocket() for the current Docket [2]
  • CurrentWorker() / CurrentExecution() for worker/execution context [2]
  • TaskKey() / TaskArgument() for task identity/arguments (often used inside your own dependency functions) [2]

And for custom DI, use the public helpers Depends(...) (per-task) and Shared(...) (worker-scoped), rather than reaching into Dependency internals. [3]

Bottom line: don’t rely on Dependency.docket / ContextVar attributes directly; prefer CurrentDocket() (and friends) or Depends(...). [2][3]

References:
[1] https://pypi.org/project/pydocket/
[2] https://chrisguidry.github.io/docket/dependencies/
[3] https://chrisguidry.github.io/docket/api-reference/


Replace DocketDependency.docket with the documented public API.

The fallback to DocketDependency.docket.get() relies on an internal, unstable API that is not part of Docket's public contract. According to Docket's documentation, Dependency.docket is an internal implementation detail and should not be used directly. Additionally, Docket is currently in Beta status on PyPI, making internal APIs especially prone to breaking changes.

Use the documented public dependency CurrentDocket() instead, which is the official way to access the current docket context in Docket-powered applications. If the fallback is needed to handle cases where _CurrentContext hasn't propagated, consider whether CurrentDocket() would better address the underlying issue, or add a comment explaining why this fallback is necessary.

assert result.data == "Hello, Bob!"


class TestAccessTokenInBackgroundTasks:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cristiangreco94 I've got similar feedback about this test suite as I did with @gfortaine's last PR. He did a really phenomenal job of de-mocking it, can you give this test suite a pass too?

The general idea here is that we have a fully functional in-memory Docket and redis (via Docket's memory broker implementation using fakeredis) so you shouldn't really need to mock anything here. Just write the test using the FastMCP client and example MCP servers and tools/prompts/resources to demonstrate the end-to-end flow. You can absolutely use mocks when you want to emulate failure conditions, etc, but these tests as they stand right now create such a closed mock world that they could easily drift from reality without failing.

Comment on lines +1045 to +1046
try:
task_info = TaskContextInfo(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having a try in a unit test is kind of a code smell for me. If we need to ensure some clean up across tests, let's have a pytest fixture that handles that instead so that the test can just be "flat" and more obvious.

gfortaine added a commit to gfortaine/fastmcp that referenced this pull request Feb 9, 2026
…fectHQ#3121)

Merges two orthogonal PRs into a single coherent changeset:

From PrefectHQ#2906 - Distributed notification queue & BLPOP elicitation:
- Add notifications.py: LPUSH/BRPOP notification queue for background tasks
- Replace polling-based elicitation with single BLPOP call
- Fail-fast on notification push failure (return cancel immediately)
- Add Docket ExecutionProgress API for report_progress() in background tasks
- Wire notification subscriber lifecycle on session exit stack
- Use typed TaskStatusNotification.model_validate() in handlers

From PrefectHQ#3121 - Access token snapshot & lifespan_context fallback:
- Snapshot AccessToken to Redis alongside task metadata
- Add _task_access_token ContextVar restored on Docket worker entry
- Extend get_access_token() with expiry-aware fallback chain
- Add lifespan_context property fallback to server._lifespan_result

Reviewer fixes applied to PrefectHQ#3121:
- Replace silent except with logger.warning()
- Use _current_docket before DocketDependency.docket.get() fallback
- Hoist datetime import to module level
- Type _access_token_cv_token as Token[...] | None instead of Any

Tests: 27 new/consolidated tests (de-mocked), 264 total tasks/ tests pass.
gfortaine added a commit to gfortaine/fastmcp that referenced this pull request Feb 10, 2026
…fectHQ#3121)

Merges two orthogonal PRs into a single coherent changeset:

From PrefectHQ#2906 - Distributed notification queue & BLPOP elicitation:
- Add notifications.py: LPUSH/BRPOP notification queue for background tasks
- Replace polling-based elicitation with single BLPOP call
- Fail-fast on notification push failure (return cancel immediately)
- Add Docket ExecutionProgress API for report_progress() in background tasks
- Wire notification subscriber lifecycle on session exit stack
- Use typed TaskStatusNotification.model_validate() in handlers

From PrefectHQ#3121 - Access token snapshot & lifespan_context fallback:
- Snapshot AccessToken to Redis alongside task metadata
- Add _task_access_token ContextVar restored on Docket worker entry
- Extend get_access_token() with expiry-aware fallback chain
- Add lifespan_context property fallback to server._lifespan_result

Reviewer fixes applied to PrefectHQ#3121:
- Replace silent except with logger.warning()
- Use _current_docket before DocketDependency.docket.get() fallback
- Hoist datetime import to module level
- Type _access_token_cv_token as Token[...] | None instead of Any

Tests: 27 new/consolidated tests (de-mocked), 264 total tasks/ tests pass.
@gfortaine
Copy link
Copy Markdown
Contributor

gfortaine commented Feb 10, 2026

@cristiangreco94 @chrisguidry — i have a consolidated branch (feat/distributed-task-enhancements) that integrates the access token snapshot + lifespan fallback from this PR with the notification queue and BLPOP elicitation work from #2906.

the test suite is fully end-to-end: in-memory docket + fakeredis, no mock world, no try blocks in tests — addresses the review feedback here. also picked up a non-monotonic progress delta bug along the way.

4 commits, rebased on upstream/main. PR is up at #2906. happy to adjust if @chrisguidry or @jlowin prefer a different structure.

@chrisguidry
Copy link
Copy Markdown
Collaborator

chrisguidry commented Feb 10, 2026

@gfortaine Thanks but I actually just merged #2906 :D

@cristiangreco94 if you can rebase this one on main, I can review it soon after

@cristiangreco94
Copy link
Copy Markdown
Author

I think this PR from @gfortaine supersedes mine, so i will proceed and close it. if not, let me know and I will reopen it and work on it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth Related to authentication (Bearer, JWT, OAuth, WorkOS) for client or server. bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. server Related to FastMCP server implementation or server-side functionality. tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

get_access_token returns None in background tasks (task=True)

3 participants