Skip to content

feat(context): Add background task support for Context (SEP-1686)#2905

Merged
jlowin merged 4 commits intoPrefectHQ:mainfrom
gfortaine:feature/input-required-task-status
Feb 3, 2026
Merged

feat(context): Add background task support for Context (SEP-1686)#2905
jlowin merged 4 commits intoPrefectHQ:mainfrom
gfortaine:feature/input-required-task-status

Conversation

@gfortaine
Copy link
Copy Markdown
Contributor

@gfortaine gfortaine commented Jan 18, 2026

Summary

This PR implements background task support for the Context class following the unified interface pattern suggested by @chrisguidry in code review.

Key Changes:

  • Added task_id parameter to Context.__init__()
  • Added is_background_task and task_id properties
  • Enhanced elicit() to work transparently in background task mode
  • Added tasks/elicitation.py module for Redis-based coordination

How It Works

@mcp.tool(task=True)
async def interactive_task(ctx: Context) -> str:
    # This works transparently in both request and background task modes
    result = await ctx.elicit("Please provide additional input", str)
    
    if isinstance(result, AcceptedElicitation):
        return f"You provided: {result.data}"
    else:
        return "Elicitation was declined or cancelled"

When running as a background task:

  1. Task status is set to input_required in Redis
  2. notifications/tasks/updated is sent with elicitation metadata
  3. Task waits for client to send input via tasks/sendInput
  4. Input is stored in Redis and task resumes

Architecture

┌─────────────────────────────────────────────────────┐
│                    User Code                        │
│     await ctx.elicit("Need input", str)            │
└─────────────────────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────┐
│              Context.elicit()                       │
│   if is_background_task:                           │
│       return _elicit_for_task(...)                 │
│   else:                                            │
│       return session.elicit(...)                   │
└─────────────────────────────────────────────────────┘

Approach Change

Originally proposed TaskContext as a separate dependency class. Based on review feedback, refactored to enhance the existing Context class instead, following the v3.0 principle of unified interfaces.

Tests

  • 10 new tests covering background task support
  • All 1857 server tests passing

Related

Breaking Changes

None. The new task_id parameter is optional and defaults to None.

@marvin-context-protocol marvin-context-protocol Bot added enhancement Improvement to existing functionality. For issues and smaller PR improvements. server Related to FastMCP server implementation or server-side functionality. labels Jan 18, 2026
@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: The static analysis workflow failed because the code doesn't pass Ruff linting and formatting checks.

Root Cause: The newly added code in src/fastmcp/server/dependencies.py and tests/server/tasks/test_task_input_required.py has minor formatting and style issues that violate the project's Ruff configuration. Specifically:

  1. Import ordering: Blank lines between import groups need adjustment (lines 1024-1027, 1152-1155 in dependencies.py)
  2. Slot ordering: __slots__ attributes should be alphabetically sorted (line 927 in dependencies.py)
  3. Code formatting: List comprehensions and function signatures need reformatting per Ruff's style rules (test file)

Suggested Solution: Run uv run prek run --all-files locally, which will automatically fix all these issues. Then commit the changes. The specific changes needed are:

  • src/fastmcp/server/dependencies.py:927: Change __slots__ = ("_task_id", "_session_id") to __slots__ = ("_session_id", "_task_id")
  • src/fastmcp/server/dependencies.py:1024-1027: Move blank line after mcp imports, before fastmcp imports
  • src/fastmcp/server/dependencies.py:1152-1155: Same import ordering fix
  • tests/server/tasks/test_task_input_required.py: Various formatting fixes for comprehensions and function signatures
Detailed Analysis

Prek Output

ruff check...............................................................Failed
- hook id: ruff-check
- exit code: 1
- files were modified by this hook

  Found 4 errors (4 fixed, 0 remaining).
  
ruff format..............................................................Failed
- hook id: ruff-format
- files were modified by this hook

  1 file reformatted, 491 files left unchanged

Specific Changes

# src/fastmcp/server/dependencies.py
-    __slots__ = ("_task_id", "_session_id")
+    __slots__ = ("_session_id", "_task_id")

# Import ordering fixes (two locations):
-        import anyio
-
-        import mcp.shared.exceptions
+        import anyio
+        import mcp.shared.exceptions
         import mcp.shared.message
         import mcp.types
+
Related Files
  • src/fastmcp/server/dependencies.py: Main file with TaskContext implementation - has import ordering and slots ordering issues
  • tests/server/tasks/test_task_input_required.py: Test file with formatting issues in list comprehensions and function signatures
  • .github/workflows/run-static.yml: Defines the static analysis workflow that runs prek
  • pyproject.toml: Contains Ruff configuration that defines the linting/formatting rules

This analysis was automatically generated by the FastMCP test failure triage bot

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 18, 2026

Walkthrough

Adds a TaskContext subsystem for background tasks: introduces TaskContextInfo dataclass, TaskContext class with elicit() and sample() methods, a CurrentTaskContext dependency, get_task_context/get_task_session/register_task_session APIs, and an in-process weakref session registry. Registers task sessions during background task submission and adds send_input_required_notification to emit SEP-1686 input-required notifications. Documentation files updated with TaskContext usage guidance for background tasks. No existing public signatures were removed.

Possibly related PRs

  • #2378: Adds TaskContext, task-session registration, and input-required notification logic that overlap the same task-related modules and flows.
  • #2835: Modifies fastmcp.server.dependencies and the public dependency API, touching the same dependency-resolution and context surfaces.
  • #2843: Changes ContextVar/worker propagation used by task-context/session registration and notification flows relied upon by TaskContext.
🚥 Pre-merge checks | ✅ 3 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Title check ⚠️ Warning The title references 'feat(context): Add background task support for Context' but the actual changeset introduces TaskContext and CurrentTaskContext as separate classes, not enhanced Context properties. Update the title to accurately reflect the implementation: 'feat: Add TaskContext for background task elicitation and sampling (SEP-1686)' to match the actual changes.
Description check ⚠️ Warning The PR description describes adding task_id parameter to Context.init() and enhancing Context.elicit(), but the actual implementation adds a separate TaskContext class with elicit/sample methods and a CurrentTaskContext() dependency. Update the description to accurately reflect the implemented solution: TaskContext as a separate dependency class with CurrentTaskContext factory, not as enhancements to the existing Context class.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR fully addresses issue #2877 by providing TaskContext dependency for background tasks to resolve context-like dependencies and perform elicitation/sampling, with proper status handling per SEP-1686.
Out of Scope Changes check ✅ Passed All changes are scope-appropriate: TaskContext implementation and exports, documentation updates, session registration in task handlers, and notification support directly enable the linked issue objectives.
Docstring Coverage ✅ Passed Docstring coverage is 82.35% which is sufficient. The required threshold is 80.00%.

✏️ 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 (6)
src/fastmcp/server/dependencies.py (2)

124-157: Session registry lacks cleanup mechanism for stale entries.

The _task_sessions dictionary stores weak references to sessions, but entries are only cleaned up when get_task_session is called for that specific session_id. Sessions that are never retrieved again will leave stale None-returning weakrefs in the dict indefinitely.

Consider periodic cleanup or using WeakValueDictionary (though that requires the session to be the value, not a weakref wrapper).

♻️ Suggested improvement
+def _cleanup_stale_sessions() -> None:
+    """Remove entries for garbage-collected sessions."""
+    stale_keys = [k for k, ref in _task_sessions.items() if ref() is None]
+    for k in stale_keys:
+        _task_sessions.pop(k, None)
+
+
 def register_task_session(session_id: str, session: ServerSession) -> None:
     """Register a session for TaskContext access in background tasks.
     ...
     """
+    # Opportunistic cleanup of stale entries
+    _cleanup_stale_sessions()
     _task_sessions[session_id] = weakref.ref(session)

927-927: Minor: __slots__ could be sorted alphabetically.

Static analysis suggests sorting slots for consistency.

♻️ Proposed fix
-    __slots__ = ("_task_id", "_session_id")
+    __slots__ = ("_session_id", "_task_id")
src/fastmcp/server/tasks/subscriptions.py (1)

215-263: Consider extracting shared notification-building logic.

The new send_input_required_notification function duplicates Redis lookup and notification building logic from _send_status_notification and _send_progress_notification. While this is acceptable for now, consider extracting the common pattern into a helper if more notification types are added.

The implementation is otherwise correct and follows the established patterns.

docs/servers/tasks.mdx (3)

306-312: Use second person + active voice for the 4-step sequence (and consider Steps).
Line 306 reads passive; consider “When you call elicit() or sample(), TaskContext automatically…” and render the four steps with the Steps component for sequential guidance.

As per coding guidelines, include second person and active voice for instructions and use Steps for sequential instructions.


277-383: Add error handling and expected outputs to the TaskContext examples.
The examples are clear but don’t show error/edge handling (e.g., declined input, missing session, timeout) or expected output for verification. Consider adding a small try/except or explicit failure branch plus a short “Expected result” block below each example so users can confirm behavior.

As per coding guidelines, MDX examples should be runnable, show error handling, and include expected outputs.


389-396: Add a short troubleshooting/next-steps block for distributed-worker failures.
This section explains the limitation but doesn’t tell users what error they’ll see or what to do next. Consider a brief “Troubleshooting” note (expected error message + fix) and a “Next steps” link to distributed-worker guidance.

As per coding guidelines, include troubleshooting and end sections with next steps/related information.

Comment thread src/fastmcp/server/tasks/handlers.py Outdated
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: 6e4fae4fb7

ℹ️ 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 thread src/fastmcp/server/tasks/handlers.py Outdated
Comment on lines +104 to +108
# Register session for TaskContext access in background workers (SEP-1686)
# This enables elicitation/sampling from background tasks via weakref
from fastmcp.server.dependencies import register_task_session

register_task_session(session_id, ctx.session)
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 Guard TaskContext session registration for non-session calls

When a task is created outside an MCP request (the code already falls back to session_id = "internal" for this case), ctx.session raises because no request context exists. The new unconditional call to register_task_session(session_id, ctx.session) therefore raises and prevents programmatic/background task submission that previously worked. This only happens for internal/programmatic calls (no session), but those now hard-fail before the task is queued; consider skipping registration or handling the missing session in that case.

Useful? React with 👍 / 👎.

@gfortaine gfortaine force-pushed the feature/input-required-task-status branch from 070f226 to 6e4fae4 Compare January 18, 2026 12:46
@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: Static analysis (prek) failed because the code wasn't formatted according to the project's linting rules.

Root Cause: The PR code has minor formatting violations that ruff automatically fixed:

  • Import statement ordering (blank lines between import groups)
  • tuple ordering (alphabetical)
  • Line length/wrapping for function signatures and list comprehensions

Suggested Solution: Apply the auto-formatting changes and commit them.

Run locally:

uv run prek run --all-files
git add -u
git commit -m "Apply ruff formatting"

Or simply commit the changes that the CI already made (the diff is shown in the workflow logs).

Detailed Analysis

The prek pre-commit hooks found and auto-fixed 4 style violations:

  1. src/fastmcp/server/dependencies.py:

    • __slots__ tuple reordered alphabetically: ("_session_id", "_task_id")
    • Import grouping: blank line moved to separate stdlib imports from local imports (lines 1024, 1152)
  2. tests/server/tasks/test_task_input_required.py:

    • Removed extra blank line after imports (line 34)
    • Reformatted list comprehension to single line (line 127)
    • Reformatted function signature to single line (line 316)

These are purely cosmetic changes enforced by ruff's formatting rules.

Workflow Log Excerpt:

ruff check...............................................................Failed
- hook id: ruff-check
- exit code: 1
- files were modified by this hook

  Found 4 errors (4 fixed, 0 remaining).

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

  1 file reformatted, 491 files left unchanged
Related Files
  • src/fastmcp/server/dependencies.py:927 - __slots__ ordering
  • src/fastmcp/server/dependencies.py:1024,1152 - Import grouping
  • tests/server/tasks/test_task_input_required.py:34,127,316 - Formatting

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 (3)
src/fastmcp/server/dependencies.py (3)

124-157: Minor: Stale weakref entries accumulate until accessed.

The _task_sessions dict cleans up dead weakrefs only when get_task_session() is called for that specific session. If sessions disconnect without their tasks completing (e.g., client crashes), the stale entries remain indefinitely. This is acceptable for typical usage patterns since background tasks will access their sessions, but consider adding periodic cleanup for long-running servers with high session churn.


1104-1112: Return type annotation is overly broad.

The sample() method returns Any, but per the docstring it returns CreateMessageResult. Similarly, elicit() returns a union of elicitation result types. Consider using more specific return type annotations for better IDE support and type safety.

♻️ Suggested type annotations
+from typing import Union
+# In TYPE_CHECKING block:
+from fastmcp.server.elicitation import (
+    AcceptedElicitation,
+    CancelledElicitation,
+    DeclinedElicitation,
+)

 async def elicit(
     self,
     message: str,
     response_type: type | None = None,
-) -> Any:
+) -> AcceptedElicitation[Any] | DeclinedElicitation | CancelledElicitation:

For sample(), consider returning mcp.types.CreateMessageResult explicitly.


1161-1173: Message conversion handles basic cases; consider validating role.

The dict-to-SamplingMessage conversion accepts any role value without validation. Per MCP spec, role should typically be "user" or "assistant". Invalid roles may cause downstream errors. Consider validating or documenting accepted values.

Comment thread src/fastmcp/server/dependencies.py Outdated
Comment on lines +973 to +1102
async def elicit(
self,
message: str,
response_type: type | None = None,
) -> Any:
"""Request user input, updating task status to input_required.

This method implements the SEP-1686 ``input_required`` flow:

1. Updates task status to ``input_required``
2. Sends elicitation request with ``related-task`` metadata
3. Waits for user response
4. Updates task status back to ``working``
5. Returns the parsed result

Args:
message: The message to display to the user
response_type: The expected response type. Can be:

- A dataclass or Pydantic model for structured input
- A primitive type (str, int, bool, float) for simple input
- None for freeform text input

Returns:
- ``AcceptedElicitation[T]`` if user accepted (data contains response)
- ``DeclinedElicitation`` if user declined
- ``CancelledElicitation`` if user cancelled

Raises:
RuntimeError: If session is no longer available
McpError: If client doesn't support elicitation

Example:
.. code-block:: python

@dataclass
class UserInfo:
name: str
age: int

@mcp.tool(task=True)
async def get_info(task_ctx: TaskContext = CurrentTaskContext()):
result = await task_ctx.elicit(
message="Please provide your information",
response_type=UserInfo,
)
if result.action == "accept":
return f"{result.data.name} is {result.data.age} years old"
return "No info provided"
"""
import anyio
import mcp.shared.exceptions
import mcp.shared.message
import mcp.types

from fastmcp.server.elicitation import (
CancelledElicitation,
DeclinedElicitation,
handle_elicit_accept,
parse_elicit_response_type,
)
from fastmcp.server.tasks.subscriptions import send_input_required_notification

session = self._get_session()
docket = self._get_docket()
config = parse_elicit_response_type(response_type)

try:
# Update status to input_required
await send_input_required_notification(
session=session,
task_id=self._task_id,
session_id=self._session_id,
docket=docket,
status="input_required",
)

# Build the elicitation request with related-task metadata
request = session._build_elicit_form_request( # pyright: ignore[reportPrivateUsage]
message=message,
requestedSchema=config.schema,
related_task_id=self._task_id,
)

# Send the request and wait for response
response_stream, response_stream_reader = anyio.create_memory_object_stream[
mcp.types.JSONRPCResponse | mcp.types.JSONRPCError
](1)
request_id = request.id
session._response_streams[request_id] = response_stream # pyright: ignore[reportPrivateUsage]

try:
await session._write_stream.send( # pyright: ignore[reportPrivateUsage]
mcp.shared.message.SessionMessage(
message=mcp.types.JSONRPCMessage(request)
)
)

response_or_error = await response_stream_reader.receive()

if isinstance(response_or_error, mcp.types.JSONRPCError):
raise mcp.shared.exceptions.McpError(response_or_error.error)
else:
result = mcp.types.ElicitResult.model_validate(
response_or_error.result
)
finally:
session._response_streams.pop(request_id, None) # pyright: ignore[reportPrivateUsage]
await response_stream.aclose()
await response_stream_reader.aclose()

# Parse and return result
if result.action == "accept":
return handle_elicit_accept(config, result.content)
elif result.action == "decline":
return DeclinedElicitation()
elif result.action == "cancel":
return CancelledElicitation()
else:
raise ValueError(f"Unexpected elicitation action: {result.action}")

finally:
# Always restore status to working
await send_input_required_notification(
session=session,
task_id=self._task_id,
session_id=self._session_id,
docket=docket,
status="working",
)
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

🌐 Web query:

mcp python sdk ServerSession public API elicitation

💡 Result:

Relevant public API for ServerSession elicitation (MCP Python SDK):

  • ServerSession.elicit(...) — send a generic elicitation request to the client. [1]
  • ServerSession.elicit_form(...) — send a form-mode elicitation (structured schema). [1]
  • ServerSession.elicit_url(...) — send a URL-mode elicitation (redirects user to external flow). [1]
  • ServerSession.send_elicit_complete(...) — notify the client that an elicitation has completed. [1]

Related server-side helpers / errors:

  • UrlElicitationRequiredError — raise this from a tool to signal a URL elicitation is required (SDK converts this into the appropriate MCP error/elicitation flow). [1][2]
  • elicitations module / ElicitRequestURLParams and ElicitationResult types — types used for building elicitation requests and interpreting results. [1]

Examples:

  • Elicitation examples (form and URL modes) and a pattern showing raising UrlElicitationRequiredError are in the SDK examples/documentation. [2]

Sources: API reference and SDK examples/documentation. [1][2]


Use the public ServerSession.elicit_form() API instead of private implementation details.

The MCP SDK provides a public ServerSession.elicit_form() API designed for exactly this purpose. The current implementation bypasses these public APIs in favor of private _build_elicit_form_request, _response_streams, and _write_stream methods, creating unnecessary fragility. Refactor to use the public API, which handles request/response patterns and cleanup internally.

🧰 Tools
🪛 Ruff (0.14.13)

1092-1092: Avoid specifying long messages outside the exception class

(TRY003)

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

marvin-context-protocol Bot commented Jan 18, 2026

Test Failure Analysis

Summary: Static analysis failed due to 8 unused type: ignore comments in the background task elicitation tests.

Root Cause: The test file tests/server/tasks/test_context_background_task.py contains # type: ignore[arg-type] comments that are no longer needed. The type checker now properly recognizes that the mock objects (mock_session and mock_fastmcp) match the expected parameter types for elicit_for_task() and handle_task_input() functions.

Suggested Solution: Remove all 8 unused # type: ignore[arg-type] comments from the test file.

File to modify: tests/server/tasks/test_context_background_task.py

Remove # type: ignore[arg-type] from these lines:

  • Line 168: session=mock_session parameter
  • Line 174: fastmcp=mock_fastmcp parameter
  • Line 212: fastmcp=mock_fastmcp parameter
  • Line 246: fastmcp=mock_fastmcp parameter
  • Line 283: session=mock_session parameter
  • Line 286: fastmcp=mock_fastmcp parameter
  • Line 328: session=mock_session parameter
  • Line 331: fastmcp=mock_fastmcp parameter
Detailed Analysis

The ty check hook in prek detected these unused type suppression comments:

warning[unused-ignore-comment]: Unused blanket `type: ignore` directive
   --> tests/server/tasks/test_context_background_task.py:168:44
    |
166 |                 result = await elicit_for_task(
167 |                     task_id="test-task-123",
168 |                     session=mock_session,  # type: ignore[arg-type]
    |                                            ^^^^^^^^^^^^^^^^^^^^^^^^

These comments were likely added when the mocks were first created, but the type checker now properly infers that AsyncMock and MagicMock objects satisfy the ServerSession and FastMCP type requirements for these function parameters.

The fix is straightforward: simply remove the # type: ignore[arg-type] comment from each of the 8 lines listed above. The code will continue to work exactly the same, but will pass static analysis.

You can apply the fix automatically by running:

uv run prek run --all-files

This will fix the issues and any other formatting problems automatically.

Related Files
  • tests/server/tasks/test_context_background_task.py - Contains the 8 unused type ignore comments that need to be removed
  • src/fastmcp/server/tasks/elicitation.py - Defines elicit_for_task() and handle_task_input() functions that are being tested

Updated: 2026-02-01 18:15 UTC - Refreshed analysis for latest workflow run

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.

@gfortaine Thank you for this! I like your approach and I think I can see a path to adding the distributed version later. I love how you worked with the dependency system and you covered giving good errors for the distributed case.

I do have some changes to request, though:

  • Could we remove the v3-notes/distributed-task-context-design.md from this PR? I think there's a simpler possible implementation for the distributed case that we could discuss in a Github issue instead. I think we'd rather not have a file with a speculative design in the repo

  • What do you think about making Context itself task-aware rather than introducing a separate TaskContext? The idea would be that users always declare ctx: Context = CurrentContext() as their dependency, and it just works regardless of whether the tool is running in foreground or as a background task.

    • For foreground calls, it behaves as it does today. For background tasks, elicit() and sample() would route through the session registry (embedded) or eventually via Redis (distributed). Properties that don't make sense in a task context (like request_id) could raise RuntimeError with a clear message if accessed.
    • This would be simpler for users - one context type, one dependency - and aligns well with SEP-1686 where the same tool may be called either way. Thoughts?

@gfortaine gfortaine force-pushed the feature/input-required-task-status branch from c6dc7e0 to d81ba79 Compare January 31, 2026 00:35
@gfortaine gfortaine changed the title Add TaskContext for background task elicitation/sampling (SEP-1686) feat(context): Add background task support for Context (SEP-1686) Jan 31, 2026
@gfortaine
Copy link
Copy Markdown
Contributor Author

Thanks for the detailed feedback @chrisguidry. Implemented the unified Context approach you suggested - refactored to enhance the existing Context class rather than introducing a separate TaskContext.

Key changes:

  • Added is_background_task and task_id properties to Context
  • session property now works in both request and background task modes
  • elicit() transparently handles both modes

This follows the v3.0 pattern where a single interface adapts to its execution context (similar to how Progress works). All 1857 tests passing.

Ready for another review when you have a moment.

@gfortaine gfortaine force-pushed the feature/input-required-task-status branch from d81ba79 to a62ecec Compare January 31, 2026 01:14
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.

This is looking really good! Could we get at least one test showing the end-to-end of it working, with a background task that's eliciting input? This will help with what the client-side sees when this happens.

@gfortaine
Copy link
Copy Markdown
Contributor Author

@chrisguidry sounds like you wanted clarity on the client-server dance during elicitation. the new test in TestBackgroundTaskContextWiring breaks it down step by step:

client receives:

  • notifications/tasks/updated with status="input_required"
  • full metadata: taskId, statusMessage, elicitation.requestedSchema

client responds:

  • handle_task_input(task_id, session_id, action="accept", content={...})

tool gets back:

  • AcceptedElicitation(action="accept", data="Alice")

the test explicitly asserts on the notification structure so you can see exactly what the client sees:

meta = notification._meta
related_task = meta["modelcontextprotocol.io/related-task"]

assert related_task["taskId"] == "test-task-456"
assert related_task["status"] == "input_required"
assert related_task["statusMessage"] == "What is your name?"
assert "requestedSchema" in related_task["elicitation"]

if you want me to add assertions on the redis key structure or anything else lmk.

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: The static analysis job () failed due to formatting issues and unused comments in the test file.

Root Cause: The type checker and formatter detected several fixable issues in :

  1. 8 unused type: ignore[arg-type] comments that are no longer needed (lines 246, 283, 286, 328, 331)
  2. Missing newline at end of file (line 657)
  3. Code formatting issues: Long lines that should be broken up and inline ternary expressions that should be simplified

Suggested Solution: Run uv run prek run --all-files locally to auto-fix all formatting issues. The prek tool will automatically:

  • Remove the unused type: ignore comments
  • Add the missing newline at EOF
  • Reformat the long assertion and ternary expression
  • Format the missing blank line after line 337

Then commit the changes:

uv run prek run --all-files
git add tests/server/tasks/test_context_background_task.py
git commit -m "Fix formatting and remove unused type ignores"
Detailed Analysis

The prek pre-commit hook ran and detected issues but the changes weren't committed, causing CI to fail. Here are the specific issues:

Unused type ignores (8 instances):

# Lines 246, 283, 286, 328, 331
fastmcp=mock_fastmcp,  # type: ignore[arg-type]  # ← Remove this comment
session=mock_session,  # type: ignore[arg-type]  # ← Remove this comment

Missing newline at EOF:

# Line 657 (last line)
assert result.data == "Alice"  # The value from content["value"]
\ No newline at end of file  # ← Need to add newline

Long assertion that needs reformatting (line 522):

# Before:
assert captured_is_background is True, "Context.is_background_task should be True"

# After (prettier format):
assert captured_is_background is True, (
    "Context.is_background_task should be True"
)

Ternary expression simplification (lines 559-561):

# Before:
redis_storage[key] = (
    value.encode() if isinstance(value, str) else value
)

# After:
redis_storage[key] = value.encode() if isinstance(value, str) else value

Missing blank line (after line 337):
Needs a blank line between the test method end and the class definition.

Related Files
  • tests/server/tasks/test_context_background_task.py - The test file with formatting issues. All issues are auto-fixable by running prek.

@gfortaine gfortaine force-pushed the feature/input-required-task-status branch 5 times, most recently from cffffc9 to 32928b9 Compare February 2, 2026 01:15
@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: A single test () in is timing out on Windows Python 3.10, but passing on all other platforms (Ubuntu Python 3.10, 3.13, Windows lowest deps, integration tests).

Root Cause: This is not related to the Context changes in this PR. The failing test doesn't use Context at all - it simply instantiates objects with different values and verifies endpoint configuration. The timeout appears to be a Windows-specific performance issue or transient CI slowness, not a code defect.

Evidence:

  • Test times out at line 108:
  • Same test passes on Ubuntu 3.10 and Ubuntu 3.13
  • The test hasn't been modified in this PR
  • The initialization doesn't interact with at all

Suggested Solution: Re-run the failed Windows job. This looks like a transient timeout issue rather than a code problem. If it continues to fail:

  1. Short-term: Mark the test with @pytest.mark.slow or increase timeout for this specific test
  2. Long-term: Investigate if Windows initialization of AzureProvider is hitting disk I/O bottlenecks (it creates JWT verifiers which may fetch JWKS data)

The PR changes are safe - they only modify Context.__init__() to add an optional task_id parameter (backward compatible), and update elicit() behavior. These changes don't affect OAuth provider initialization at all.

Detailed Analysis

Test Log Excerpt

2026-02-02T01:17:44.6887505Z tests\server\auth\providers\test_azure.py [32m.[0m[32m.[0m[32m.[0m[32m.[0m[32m.[0m+++++++++++++++++++++++++++++++++++ Timeout +++++++++++++++++++++++++++++++++++
2026-02-02T01:17:44.6934059Z   File "D:\a\fastmcp\fastmcp\tests\server\auth\providers\test_azure.py", line 108, in test_special_tenant_values

test_special_tenant_values (lines 105-129)

def test_special_tenant_values(self):
    """Test that special tenant values are accepted."""
    # Test with "organizations"
    provider1 = AzureProvider(  # ← Times out here on Windows
        client_id="test_client",
        client_secret="test_secret",
        tenant_id="organizations",
        base_url="https://myserver.com",
        required_scopes=["read"],
        jwt_signing_key="test-secret",
    )
    parsed = urlparse(provider1._upstream_authorization_endpoint)
    assert "/organizations/" in parsed.path
    # ... rest of test

Why This Isn't Related to the PR

The PR changes signature:

# Before
def __init__(self, fastmcp: FastMCP, session: ServerSession | None = None):

# After  
def __init__(self, fastmcp: FastMCP, session: ServerSession | None = None, *, task_id: str | None = None):

But (line 107 in azure.py) calls which goes to , not . There's no connection between OAuth providers and the Context class.

CI Job Results

Job Result
Tests: Python 3.10 on ubuntu-latest ✅ Passed
Tests: Python 3.13 on ubuntu-latest ✅ Passed
Tests: Python 3.10 on windows-latest ❌ Failed (timeout)
Tests with lowest-direct dependencies ✅ Passed
Integration tests ✅ Passed
Related Files
  • tests/server/auth/providers/test_azure.py:108 - Test that's timing out (no changes in PR)
  • src/fastmcp/server/auth/providers/azure.py:100-229 - AzureProvider initialization (no changes in PR)
  • src/fastmcp/server/context.py:182-229 - Context changes (not used by Azure tests)

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: A single test (test_special_tenant_values) in tests/server/auth/providers/test_azure.py is timing out on Windows Python 3.10, but passing on all other platforms.

Root Cause: This is NOT related to the Context changes in this PR. The failing test does not use Context at all - it simply instantiates AzureProvider objects with different tenant_id values and verifies endpoint configuration. The timeout appears to be a Windows-specific performance issue or transient CI slowness, not a code defect.

Evidence:

  • Test times out at line 108 during AzureProvider initialization
  • Same test passes on Ubuntu 3.10 and Ubuntu 3.13
  • The test was not modified in this PR
  • The AzureProvider initialization does not interact with Context at all

Suggested Solution: Re-run the failed Windows job. This looks like a transient timeout issue rather than a code problem. If it continues to fail:

  1. Short-term: Mark the test with @pytest.mark.slow or increase timeout for this specific test
  2. Long-term: Investigate if Windows initialization of AzureProvider is hitting disk I/O bottlenecks (it creates JWT verifiers which may fetch JWKS data)

The PR changes are safe - they only modify Context.init() to add an optional task_id parameter (backward compatible), and update elicit() behavior. These changes do not affect OAuth provider initialization at all.

Details: The test times out when creating an AzureProvider instance, which goes through OAuthProxy.init(), not Context.init(). There is no code path connecting OAuth providers to the Context class. All other CI jobs passed, including Ubuntu 3.10, Ubuntu 3.13, lowest dependencies, and integration tests.

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.

Thanks @gfortaine, looking good! I think I'd have gone a little less mock-y with these because we do have the memory:// redis broker available during tests. Glad to see the extra end-to-end tests here, thank you!

@gfortaine
Copy link
Copy Markdown
Contributor Author

🙏 thanks chris! good point on the memory:// broker – added a test using the real docket backend. ready for merge whenever you are.

Implements unified Context approach for background tasks running in Docket workers.
Based on code review feedback from @chrisguidry to use existing Context class
rather than introducing a separate TaskContext.

Key changes:
- Add task_id parameter to Context.__init__()
- Add is_background_task and task_id properties
- Wire Context creation in _CurrentContext for Docket workers
- Add session registry for background task access
- Add elicitation module for Redis-based coordination
- Remove outdated warning about Context unavailability in workers

The elicitation flow:
1. Tool calls ctx.elicit() in background task
2. Request stored in Redis, sends input_required notification
3. Client responds via handle_task_input()
4. Tool receives response and resumes

Addresses PrefectHQ#2568, fixes PrefectHQ#2877
@gfortaine gfortaine force-pushed the feature/input-required-task-status branch from 5c0a52a to 5d7ea90 Compare February 2, 2026 22:30
- 21 tests covering Context.is_background_task, session property,
  elicit() in background task mode, and documentation
- E2E tests for elicit_for_task() and handle_task_input() with mocked Redis
- Full flow tests: task blocks on elicit(), client sends input, task resumes
@gfortaine gfortaine force-pushed the feature/input-required-task-status branch from 5d7ea90 to 16656ee Compare February 2, 2026 22:47
@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: The static analysis job failed due to type checking warnings in the new background task test file. The type checker found 11 warnings: 8 unused comments and 3 warnings.

Root Cause:

  1. Unused type ignore comments (8 occurrences): The test file tests/server/tasks/test_context_background_task.py contains # type: ignore[arg-type] comments on lines 168, 174, 212, 246, 283, 286, 328, and 331 that are no longer needed. This suggests that the underlying type issues have been fixed, but the suppression comments were not removed.

  2. Missing attribute checks (3 occurrences): Lines 432-434 access .data attribute on a result that could be AcceptedElicitation[UserInfo] | DeclinedElicitation | CancelledElicitation. The code doesn't check which variant it is before accessing .data, which only exists on AcceptedElicitation.

Suggested Solution:

  1. Remove the 8 unused # type: ignore[arg-type] comments from:

    • Line 168: session=mock_session
    • Line 174: fastmcp=mock_fastmcp
    • Line 212: fastmcp=mock_fastmcp
    • Line 246: fastmcp=mock_fastmcp
    • Line 283: session=mock_session
    • Line 286: fastmcp=mock_fastmcp
    • Line 328: session=mock_session
    • Line 331: fastmcp=mock_fastmcp
  2. Fix the attribute access on lines 432-434 by adding a type guard or assertion after the check on line 431:

    # Line 431
    assert result.action == "accept"
    # Add this check
    assert isinstance(result, AcceptedElicitation)
    # Then these are safe
    assert isinstance(result.data, UserInfo)
    assert result.data.name == "Alice"
    assert result.data.age == 30
Detailed Analysis

Type Checker Output

The ty check pre-commit hook failed with exit code 1. All other checks (prettier, ruff check, ruff format) passed.

Unused Type Ignore Comments

The type checker reports that 8 # type: ignore[arg-type] comments are unused, meaning the mock objects now type-check correctly without suppression. This is actually a good sign - it means the types are better aligned than when the comments were added.

Possibly Missing Attribute

The more critical issue is the possibly-missing-attribute warning. The code structure is:

assert result.action == "accept"
assert isinstance(result.data, UserInfo)  # Warning: .data may not exist
assert result.data.name == "Alice"        # Warning: .data may not exist
assert result.data.age == 30              # Warning: .data may not exist

The type checker correctly identifies that checking result.action == "accept" doesn't narrow the type to AcceptedElicitation (which is the only variant with a .data attribute). You need to use isinstance() to narrow the union type.

Related Files
  • tests/server/tasks/test_context_background_task.py: The test file with type checking issues (lines 168, 174, 212, 246, 283, 286, 328, 331, 432-434)
  • src/fastmcp/server/tasks/elicitation.py: Likely defines AcceptedElicitation, DeclinedElicitation, and CancelledElicitation types

@gfortaine gfortaine force-pushed the feature/input-required-task-status branch from 16656ee to 521a9de Compare February 2, 2026 23:03
Copy link
Copy Markdown
Member

@jlowin jlowin left a comment

Choose a reason for hiding this comment

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

@gfortaine this looks awesome! At one point @chrisguidry and I thought this wasn't possible, thanks for proving us wrong :)

I ran through one of my FastMCP reviewbots and have a few relatively small comments for you. Please feel free to consider, some (like this first one) we could defer or @chrisguidry may take up as some future work.

On elicitation.py — the polling loop (~line 574)

The polling approach works for now, but it's worth noting this will make up to 7,200 Redis round-trips over the 1-hour TTL (every 0.5s), each going through async with docket.redis(). For a future iteration, Redis BLPOP or pub/sub would be a much better fit here — it's the exact use case they're designed for. Not necessarily a blocker but something to keep in mind for production workloads.


On elicitation.pycontextlib.suppress(Exception) (~line 566)

This notification is the only way the client learns the task needs input. If it fails silently, the task will poll Redis indefinitely waiting for a response that never comes. Could we at least log the exception here? Something like logger.warning("Failed to send input_required notification for task %s: %s", task_id, e) would make debugging much easier.


On elicitation.pysession._fastmcp_state_prefix access (~line 511)

Context.session_id already handles this extraction (with the same fallback logic). Since _elicit_for_task is called from Context.elicit(), could we just pass self.session_id through as a parameter instead of re-deriving it from the session's private attribute here? Or load from the (Python contextvar) context instead?


On elicitation.pyimport contextlib inside function body (~line 564)

Nit: this import should be at module level per project conventions.


On context.pyis_background_task docstring example (~line 32)

The example shows an if/else where both branches do the same thing (ctx.elicit("Need input", str)) — which actually makes the point that it's transparent, but reads oddly as example code. Maybe just show the simple case without the branch? The transparency is the whole point.


On test_context_background_task.py — general

The mock-heavy tests verify the mechanics, but the test_context_elicit_with_real_docket_memory_backend test at the end is doing the most work in terms of actual confidence. If there's appetite, leaning more on the real memory:// backend (as Chris mentioned) would strengthen the suite — mocks can pass even when the real integration has issues.


On context.pysession property fallback chain (~line 74)

The new fallback to self._session (line 83) is more permissive than before — previously this only returned from request_context.session. Just want to make sure the "fallback to stored session" path is intentional and won't mask cases where the session wasn't properly set up through the normal path.

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: Static analysis (prek/ruff) failed due to 4 unused # type: ignore[arg-type] comments in the new test file test_context_background_task.py.

Root Cause: The test file contains # type: ignore[arg-type] suppressions on lines 249, 283, 286, and 328 that are no longer needed. The type checker is now able to validate these types correctly, making the suppressions obsolete. This triggers ruff's unused-ignore-comment warning, which fails the CI build.

Suggested Solution: Remove the four unused type ignore comments:

  1. Line 249: Remove # type: ignore[arg-type] from fastmcp=mock_fastmcp,
  2. Line 283: Remove # type: ignore[arg-type] from session=mock_session,
  3. Line 286: Remove # type: ignore[arg-type] from fastmcp=mock_fastmcp,
  4. Line 328: Remove # type: ignore[arg-type] from session=mock_session,
Detailed Analysis

The prek static analysis tool (combining Ruff + Prettier + ty) reports:

warning[unused-ignore-comment]: Unused blanket `type: ignore` directive
   --> tests/server/tasks/test_context_background_task.py:249:40
    |
247 |         success = await send_task_input(
248 |             task_id="test-task-id",
249 |             content={"value": 42},
    |                                        ^^^^^^^^^^^^^^^^^^^^^^^^
250 |             fastmcp=mock_fastmcp,  # type: ignore[arg-type]

And three similar warnings at lines 283, 286, and 328. Each suggests removing the suppression comment and shows the exact diff needed.

The type checker no longer reports type errors for these mock objects being passed to the functions, so the suppressions are unnecessary and should be removed.

Related Files
  • tests/server/tasks/test_context_background_task.py - New test file with unused type suppressions (lines 249, 283, 286, 328)
  • .pre-commit-config.yaml - Configures prek hooks that enforce this check

Per reviewer feedback (Chris Guidry), adds test demonstrating elicitation
flow with Docket's real memory:// backend instead of mocking Redis.
This complements the existing mock-based tests.
@gfortaine gfortaine force-pushed the feature/input-required-task-status branch from 521a9de to 0847145 Compare February 2, 2026 23:15
@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: Static type checking () is failing with 12 type warnings in the new test file.

Root Cause: Two categories of type issues in tests/server/tasks/test_context_background_task.py:

  1. 8 unused type: ignore comments that are no longer needed
  2. 4 unsafe attribute accesses on union types without proper narrowing

Suggested Solution:

Fix 1: Remove Unused Type Ignores

Remove the # type: ignore[arg-type] comments from these lines:

  • Line 168: session=mock_session
  • Line 174: fastmcp=mock_fastmcp
  • Line 212: fastmcp=mock_fastmcp
  • Line 246: fastmcp=mock_fastmcp
  • Line 283: session=mock_session
  • Line 286: fastmcp=mock_fastmcp
  • Line 328: session=mock_session
  • Line 331: fastmcp=mock_fastmcp

Fix 2: Add Type Narrowing for Attribute Access

For lines 432-434 and 816, add type narrowing before accessing .data:

# Before (line 432-434)
assert result.action == "accept"
assert isinstance(result.data, UserInfo)
assert result.data.name == "Alice"

# After
assert result.action == "accept"
assert isinstance(result, AcceptedElicitation)  # Type narrowing
assert isinstance(result.data, UserInfo)
assert result.data.name == "Alice"
# Before (line 816)
if result.action == "accept":
    return f"Hello, {result.data}!"

# After
if isinstance(result, AcceptedElicitation):  # Type narrowing
    return f"Hello, {result.data}!"
Detailed Analysis

The type checker correctly identifies that the union type AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation doesn't guarantee .data exists. While checking result.action == "accept" logically narrows the type, ty doesn't recognize this pattern. Using isinstance(result, AcceptedElicitation) provides explicit type narrowing that the type checker understands.

Relevant log excerpts:

warning[unused-ignore-comment]: Unused blanket `type: ignore` directive
warning[possibly-missing-attribute]: Attribute `data` may be missing on object of type `AcceptedElicitation[UserInfo] | DeclinedElicitation | CancelledElicitation`
Related Files
  • tests/server/tasks/test_context_background_task.py - New test file with type issues
  • src/fastmcp/server/elicitation.py:105 - Definition of AcceptedElicitation with generic .data attribute
  • src/fastmcp/server/context.py:922-998 - Return type overloads for ctx.elicit() showing union types

- Log notification failures instead of silently suppressing (elicitation.py)
- Simplify is_background_task docstring example (context.py)
- Remove unused type: ignore comments (test file)
- Add proper type narrowing with isinstance(AcceptedElicitation) (test file)
@gfortaine
Copy link
Copy Markdown
Contributor Author

🙏 @jlowin thanks for the detailed pass—the logging point was spot on. all addressed in fb9e222. looking forward to tackling the blpop optimization in a follow-up if there's appetite.

@jlowin
Copy link
Copy Markdown
Member

jlowin commented Feb 3, 2026

Thanks @gfortaine!

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

Labels

enhancement Improvement to existing functionality. For issues and smaller PR improvements. server Related to FastMCP server implementation or server-side functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Passing context to Background Task

3 participants