Skip to content

fix: snapshot access token for background tasks (#3095)#3138

Merged
jlowin merged 1 commit intoPrefectHQ:mainfrom
gfortaine:fix/access-token-background-tasks
Feb 11, 2026
Merged

fix: snapshot access token for background tasks (#3095)#3138
jlowin merged 1 commit intoPrefectHQ:mainfrom
gfortaine:fix/access-token-background-tasks

Conversation

@gfortaine
Copy link
Copy Markdown
Contributor

Summary

Makes get_access_token() return the caller's token inside Docket background tasks. The token is snapshotted to Redis at submission time and restored into a ContextVar when the worker picks up the task.

Also fixes lifespan_context returning {} in background tasks by falling back to the server's lifespan result when request_context is unavailable.

Closes #3095. Supersedes #3121.

Changes

handlers.py — snapshot token at submit time

  • Call get_access_token() in submit_to_docket() and store the result in Redis alongside other task metadata

dependencies.py — restore token in worker

  • New _task_access_token ContextVar for the restored token
  • New _restore_task_access_token() reads from Redis and sets the ContextVar
  • get_access_token() falls back to _task_access_token when HTTP request and SDK context var are both unavailable
  • _CurrentContext.__aenter__() restores the token when entering background task context
  • _CurrentAccessToken.__aenter__() restores the token as fallback when _CurrentContext hasn't run (no ctx: Context in signature)
  • Expiration check: expired tokens return None

context.py — lifespan fallback

  • lifespan_context falls back to server._lifespan_result when request_context is None

Tests — integration, zero mocks

  • test_token_round_trips_through_background_task: E2E with Client(mcp) + memory:// Docket — token injected via SDK auth context, verified inside worker
  • test_no_token_when_unauthenticated: no auth → None in worker
  • test_expired_token_returns_none, test_valid_token_with_future_expiry, test_token_without_expiry_always_valid: expiration edge cases
  • test_lifespan_context_falls_back_to_server_result, test_lifespan_context_returns_empty_dict_when_no_lifespan: lifespan fallback

Review feedback addressed

@chrisguidry's comments on #3121:

  • De-mocked the test suite — replaced MagicMock/patch tests with real Client(mcp) + fakeredis integration tests (same pattern as feat: distributed notification queue + BLPOP elicitation for background tasks #2906 and test_task_elicitation_relay.py)
  • Removed try/finally blocks in tests — ContextVar cleanup via cv_token pattern
  • Dropped the test_lifespan_context_still_uses_request_context_when_available test that relied on patch.object

CodeRabbit feedback:

  • Replaced silent except Exception: pass with logger.warning(..., exc_info=True) (fixes S110/BLE001)
  • Dropped DocketDependency.docket.get() fallback — uses only _current_docket ContextVar (no unstable internal API)
  • Hoisted from datetime import datetime, timezone to module-level imports
  • Typed _access_token_cv_token as Token[AccessToken | None] | None instead of Any

Credit

Based on the initial work by @cristiangreco94 in #3121. The core design (snapshot at submit → restore in worker → ContextVar fallback) is his — this PR rebases onto current main, resolves conflicts from #2906/#3136, rewrites tests as integration, and addresses review feedback.

@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 10, 2026
@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: The static analysis workflow failed due to code formatting issues detected by ruff format.

Root Cause: Two files have trailing blank lines that don't match the project's formatting standards:

  1. src/fastmcp/server/dependencies.py - Extra blank line after line 1060
  2. tests/server/tasks/test_context_background_task.py - Trailing blank line at end of file

Suggested Solution: Run uv run prek run --all-files locally to auto-fix the formatting issues, then commit the changes. This will:

  • Remove the extra blank line in src/fastmcp/server/dependencies.py at line 1060
  • Remove the trailing blank line in tests/server/tasks/test_context_background_task.py

Note: There are also loq file size violations reported, but these are not enforced yet ("loq violations not enforced... yet!") and won't block the PR.

Detailed Analysis

From the CI logs:

ruff format..............................................................[41mFailed[49m
- hook id: ruff-format
- files were modified by this hook

  2 files reformatted, 617 files left unchanged

The specific changes needed:

diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py
index fabed02..a1c2084 100644
--- a/src/fastmcp/server/dependencies.py
+++ b/src/fastmcp/server/dependencies.py
@@ -1058,7 +1058,6 @@ def CurrentHeaders() -> dict[str, str]:
     return cast(dict[str, str], _CurrentHeaders())
 
 
-
 # --- Progress dependency ---
 
 
diff --git a/tests/server/tasks/test_context_background_task.py b/tests/server/tasks/test_context_background_task.py
index 61473e1..400e4c3 100644
--- a/tests/server/tasks/test_context_background_task.py
+++ b/tests/server/tasks/test_context_background_task.py
@@ -452,4 +452,3 @@ class TestLifespanContextInBackgroundTasks:
         ctx = Context(mcp, task_id="test-task")
         assert ctx.request_context is None
         assert ctx.lifespan_context == {}
-
Related Files
  • src/fastmcp/server/dependencies.py:1060 - Extra blank line needs removal
  • tests/server/tasks/test_context_background_task.py:454 - Trailing blank line needs removal

Copy link
Copy Markdown
Collaborator

@chrisguidry chrisguidry left a comment

Choose a reason for hiding this comment

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

Looks great! Thank you both!

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 10, 2026

Walkthrough

Adds Redis-backed snapshot and restoration of access tokens for background Docket workers: submit_to_docket now saves the current access token (if any) to Redis alongside task metadata; get_access_token and related context managers (_CurrentContext, _CurrentAccessToken) gain logic to restore that snapshot into a ContextVar when running in background/task contexts and to clean it up on exit. Also, lifespan_context now reads fastmcp._lifespan_result when request_context is None to surface lifespan data in background tasks.

Possibly related PRs

  • jlowin/fastmcp PR 2505: Changes get_access_token token retrieval order/paths in src/fastmcp/server/dependencies.py, directly overlapping token resolution logic modified here.
  • jlowin/fastmcp PR 2811: Modifies task-related Redis key construction and usage in task handlers, which intersects with storing/restoring access-token snapshots under task keys.
  • jlowin/fastmcp PR 2905: Alters submit_to_docket and background-task handling in tasks/handlers.py, overlapping the code paths where token snapshots are created and stored.
🚥 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 is specific and directly related to the main change: snapshotting the access token to make it available in background tasks.
Description check ✅ Passed The PR description provides a clear summary, detailed breakdown of changes across three files, test coverage, and addresses review feedback with proper credits.
Linked Issues check ✅ Passed All core requirements from #3095 are met: get_access_token returns the caller's token in background tasks [handlers.py, dependencies.py], token expiration is handled [dependencies.py], lifespan_context fallback added [context.py], and integration tests verify behavior [tests].
Out of Scope Changes check ✅ Passed All changes are scoped to requirements in #3095: token snapshot/restore mechanism, lifespan fallback, and related tests. No unrelated functionality added.

✏️ 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

No actionable comments were generated in the recent review. 🎉


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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e200d89ff5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +517 to +518
if access_token is None:
task_token = _task_access_token.get()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restore snapshot for direct get_access_token task usage

get_access_token() now falls back to _task_access_token, but this ContextVar is only populated when _restore_task_access_token() runs via _CurrentContext or _CurrentAccessToken. A background task that calls get_access_token() directly and does not inject ctx: Context/CurrentAccessToken() never triggers restoration, so this branch still returns None even though submit_to_docket() persisted a token for the task.

Useful? React with 👍 / 👎.

Comment on lines +1211 to +1214
self._access_token_cv_token = await _restore_task_access_token(
task_info.session_id, task_info.task_id
)
token = get_access_token()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear saved ContextVar token when access-token enter fails

_CurrentAccessToken.__aenter__ stores self._access_token_cv_token before confirming the restored token is usable; if the restored token is expired, get_access_token() returns None and __aenter__ raises. Because failed __aenter__ calls are not paired with __aexit__, this stale token remains on the reused dependency instance and a later invocation can call _task_access_token.reset() with a token from another context, raising ValueError during cleanup.

Useful? React with 👍 / 👎.

@gfortaine gfortaine force-pushed the fix/access-token-background-tasks branch from e200d89 to 8ee0b7a Compare February 10, 2026 23:46
@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: Static analysis failed because detected a formatting issue - there's a trailing blank line at the end of that needs to be removed.

Root Cause: The file has an extra blank line after line 442 (after the last assertion in the test). Ruff's formatter automatically removed it during the CI run, causing the static_analysis job to fail because files were modified by the hook.

Suggested Solution: Remove the trailing blank line at the end of . You can fix this by:

  1. Run uv run prek run --all-files locally (this will auto-fix the formatting)
  2. Commit the changes
  3. Push to the PR

Or simply remove the blank line manually at the end of the file.

Detailed Analysis

From the workflow logs:

ruff format.............................................................Failed
- hook id: ruff-format
- files were modified by this hook

  1 file reformatted, 618 files left unchanged

The diff shows:

diff --git a/tests/server/tasks/test_context_background_task.py b/tests/server/tasks/test_context_background_task.py
index 53b94aa..c7eb9e9 100644
--- a/tests/server/tasks/test_context_background_task.py
+++ b/tests/server/tasks/test_context_background_task.py
@@ -440,4 +440,3 @@ class TestLifespanContextInBackgroundTasks:
          ctx = Context(mcp, task_id="test-task")
          assert ctx.request_context is None
          assert ctx.lifespan_context == {}
-

The - at the end indicates a blank line was removed.

Related Files
  • tests/server/tasks/test_context_background_task.py:444 - Contains the trailing blank line that needs removal

@jlowin
Copy link
Copy Markdown
Member

jlowin commented Feb 11, 2026

@gfortaine I can't push to your branch but if you run uv run prek run --all-files and commit it will resolve the static error. Otherwise we can merge tomorrow and pick up the static changes subsequently. Thanks!

Snapshot the current access token at task submission time and restore it
in Docket workers via ContextVar. This makes get_access_token() return
the caller's token inside background tasks.

Changes:
- handlers.py: store token in Redis at submit_to_docket() time
- dependencies.py: add _restore_task_access_token() to read from Redis,
  add fallback in get_access_token() for _task_access_token ContextVar,
  wire restoration into _CurrentContext and _CurrentAccessToken
- context.py: fall back to server lifespan_result when
  request_context is unavailable in background tasks
- tests: integration tests using Client(mcp) + memory:// Docket

Review feedback addressed (chrisguidry):
- De-mocked test suite: replaced MagicMock/patch tests with real
  Client(mcp) + fakeredis integration tests
- No try/finally in tests: ContextVar cleanup via cv_token pattern

CodeRabbit feedback addressed:
- Replaced silent except-pass with logger.warning(exc_info=True)
- Dropped DocketDependency.docket internal API fallback
- Hoisted datetime import to module level
- Typed _access_token_cv_token as Token[AccessToken | None] | None

Co-authored-by: cristiangreco94 <cristiangreco94@users.noreply.github.com>
@jlowin
Copy link
Copy Markdown
Member

jlowin commented Feb 11, 2026

Thank you!

@jlowin jlowin merged commit 263e0bf into PrefectHQ:main Feb 11, 2026
8 checks passed
@gfortaine gfortaine deleted the fix/access-token-background-tasks branch February 11, 2026 16:00
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