mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-13 00:59:10 +02:00
* 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>
498 lines
19 KiB
Python
498 lines
19 KiB
Python
"""Tests for Context background task support (SEP-1686).
|
|
|
|
Tests Context API surface (unit) and background task elicitation (integration).
|
|
Integration tests use Client(mcp) with the real memory:// Docket backend —
|
|
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
|
|
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 (
|
|
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
|
|
|
|
# =============================================================================
|
|
# Unit tests: Context API surface (no Redis/Docket needed)
|
|
# =============================================================================
|
|
|
|
|
|
class TestContextBackgroundTaskSupport:
|
|
"""Tests for Context.is_background_task and related functionality."""
|
|
|
|
def test_context_not_background_task_by_default(self):
|
|
"""Context should not be a background task by default."""
|
|
mcp = FastMCP("test")
|
|
ctx = Context(mcp)
|
|
assert ctx.is_background_task is False
|
|
assert ctx.task_id is None
|
|
|
|
def test_context_is_background_task_when_task_id_provided(self):
|
|
"""Context should be a background task when task_id is provided."""
|
|
mcp = FastMCP("test")
|
|
ctx = Context(mcp, task_id="test-task-123")
|
|
assert ctx.is_background_task is True
|
|
assert ctx.task_id == "test-task-123"
|
|
|
|
def test_context_task_id_is_readonly(self):
|
|
"""task_id should be a read-only property."""
|
|
mcp = FastMCP("test")
|
|
ctx = Context(mcp, task_id="test-task-123")
|
|
with pytest.raises(AttributeError):
|
|
setattr(ctx, "task_id", "new-id")
|
|
|
|
|
|
class TestContextSessionProperty:
|
|
"""Tests for Context.session property in different modes."""
|
|
|
|
def test_session_raises_when_no_session_available(self):
|
|
"""session should raise RuntimeError when no session is available."""
|
|
mcp = FastMCP("test")
|
|
ctx = Context(mcp) # No session, not a background task
|
|
|
|
with pytest.raises(RuntimeError, match="session is not available"):
|
|
_ = ctx.session
|
|
|
|
def test_session_uses_stored_session_in_background_task(self):
|
|
"""session should use _session in background task mode."""
|
|
mcp = FastMCP("test")
|
|
|
|
class MockSession:
|
|
_fastmcp_state_prefix = "test-session"
|
|
|
|
mock_session = MockSession()
|
|
ctx = Context(
|
|
mcp, session=cast(ServerSession, mock_session), task_id="test-task-123"
|
|
)
|
|
|
|
assert ctx.session is mock_session
|
|
|
|
def test_session_uses_stored_session_during_on_initialize(self):
|
|
"""session should use _session during on_initialize (no request context)."""
|
|
mcp = FastMCP("test")
|
|
|
|
class MockSession:
|
|
_fastmcp_state_prefix = "test-session"
|
|
|
|
mock_session = MockSession()
|
|
ctx = Context(mcp, session=cast(ServerSession, mock_session))
|
|
|
|
assert ctx.session is mock_session
|
|
|
|
|
|
class TestContextElicitBackgroundTask:
|
|
"""Tests for Context.elicit() in background task mode."""
|
|
|
|
async def test_elicit_raises_when_background_task_but_no_docket(self):
|
|
"""elicit() should raise when in background task mode but Docket unavailable."""
|
|
mcp = FastMCP("test")
|
|
ctx = Context(mcp, task_id="test-task-123")
|
|
|
|
class MockSession:
|
|
_fastmcp_state_prefix = "test-session"
|
|
|
|
ctx._session = cast(ServerSession, MockSession())
|
|
|
|
with pytest.raises(RuntimeError, match="Docket"):
|
|
await ctx.elicit("Need input", str)
|
|
|
|
|
|
class TestElicitFailFast:
|
|
"""Tests for elicit_for_task fail-fast on notification push failure."""
|
|
|
|
async def test_elicit_returns_cancel_when_notification_push_fails(self):
|
|
"""elicit_for_task should return cancel immediately when push_notification fails.
|
|
|
|
If the client can't receive the input_required notification, waiting
|
|
for a response that will never come would block for up to 1 hour.
|
|
Instead, we return cancel immediately (fail-fast).
|
|
|
|
This test patches ONLY push_notification — all other components
|
|
(Docket, Redis, session) are real via the memory:// backend.
|
|
"""
|
|
mcp = FastMCP("failfast-test")
|
|
elicit_started = asyncio.Event()
|
|
captured: dict[str, object] = {}
|
|
|
|
@mcp.tool(task=True)
|
|
async def failfast_tool(ctx: Context) -> str:
|
|
elicit_started.set()
|
|
result = await ctx.elicit("This notification will fail", str)
|
|
captured["result_type"] = type(result).__name__
|
|
captured["is_cancelled"] = isinstance(result, CancelledElicitation)
|
|
return "done"
|
|
|
|
# Patch push_notification BEFORE starting client so it's active
|
|
# when the tool runs in the Docket worker
|
|
with patch(
|
|
"fastmcp.server.tasks.notifications.push_notification",
|
|
side_effect=ConnectionError("Redis queue unavailable"),
|
|
):
|
|
async with Client(mcp) as client:
|
|
task = await client.call_tool("failfast_tool", {}, task=True)
|
|
await asyncio.wait_for(elicit_started.wait(), timeout=5.0)
|
|
await task.wait(timeout=10.0)
|
|
result = await task.result()
|
|
assert result.data == "done"
|
|
|
|
# The tool should have received CancelledElicitation (fail-fast)
|
|
assert captured["is_cancelled"] is True
|
|
assert captured["result_type"] == "CancelledElicitation"
|
|
|
|
|
|
class TestContextDocumentation:
|
|
"""Tests to verify Context documentation and API surface."""
|
|
|
|
def test_is_background_task_has_docstring(self):
|
|
"""is_background_task property should have documentation."""
|
|
assert Context.is_background_task.__doc__ is not None
|
|
assert "background task" in Context.is_background_task.__doc__.lower()
|
|
|
|
def test_task_id_has_docstring(self):
|
|
"""task_id property should have documentation."""
|
|
assert Context.task_id.fget.__doc__ is not None
|
|
assert "task ID" in Context.task_id.fget.__doc__
|
|
|
|
def test_session_has_docstring(self):
|
|
"""session property should document background task support."""
|
|
assert Context.session.fget.__doc__ is not None
|
|
assert "background task" in Context.session.fget.__doc__.lower()
|
|
|
|
|
|
# =============================================================================
|
|
# Integration tests: Client(mcp) + memory:// Docket backend
|
|
# =============================================================================
|
|
|
|
|
|
class TestBackgroundTaskIntegration:
|
|
"""Integration tests for background task context using real Docket memory backend.
|
|
|
|
These tests use Client(mcp) with the memory:// broker — no mocking.
|
|
The memory:// backend provides a fully functional in-memory Redis store
|
|
that Docket uses automatically when running tests.
|
|
"""
|
|
|
|
async def test_report_progress_in_background_task(self):
|
|
"""report_progress() should complete without error in a background task."""
|
|
mcp = FastMCP("progress-test")
|
|
progress_reported = asyncio.Event()
|
|
|
|
@mcp.tool(task=True)
|
|
async def progress_tool(ctx: Context) -> str:
|
|
await ctx.report_progress(0, 100, "Starting...")
|
|
await ctx.report_progress(50, 100, "Half done")
|
|
await ctx.report_progress(100, 100, "Complete")
|
|
progress_reported.set()
|
|
return "done"
|
|
|
|
async with Client(mcp) as client:
|
|
task = await client.call_tool("progress_tool", {}, task=True)
|
|
await asyncio.wait_for(progress_reported.wait(), timeout=5.0)
|
|
await task.wait(timeout=5.0)
|
|
result = await task.result()
|
|
assert result.data == "done"
|
|
|
|
async def test_context_wiring_in_background_task(self):
|
|
"""Context should be properly wired with task_id and session_id."""
|
|
mcp = FastMCP("wiring-test")
|
|
task_completed = asyncio.Event()
|
|
captured: dict[str, object] = {}
|
|
|
|
@mcp.tool(task=True)
|
|
async def verify_wiring(ctx: Context) -> str:
|
|
captured["task_id"] = ctx.task_id
|
|
captured["session_id"] = ctx.session_id
|
|
captured["is_background"] = ctx.is_background_task
|
|
task_completed.set()
|
|
return "ok"
|
|
|
|
async with Client(mcp) as client:
|
|
task = await client.call_tool("verify_wiring", {}, task=True)
|
|
await asyncio.wait_for(task_completed.wait(), timeout=5.0)
|
|
await task.wait(timeout=5.0)
|
|
result = await task.result()
|
|
assert result.data == "ok"
|
|
|
|
assert captured["task_id"] is not None
|
|
assert captured["session_id"] is not None
|
|
assert captured["is_background"] is True
|
|
|
|
async def test_origin_request_id_round_trips_through_background_task(self):
|
|
"""E2E: origin_request_id captured at submit time is restored in worker.
|
|
|
|
We validate this by comparing ctx.origin_request_id with the value
|
|
stored in Docket's Redis for this task.
|
|
"""
|
|
|
|
mcp = FastMCP("origin-request-id-roundtrip")
|
|
|
|
@mcp.tool(task=True)
|
|
async def check_origin_request_id(ctx: Context, docket=CurrentDocket()) -> str:
|
|
assert ctx.is_background_task is True
|
|
assert ctx.request_context is None
|
|
assert ctx.task_id is not None
|
|
|
|
origin = ctx.origin_request_id
|
|
assert origin is not None
|
|
assert isinstance(origin, str)
|
|
assert origin != ""
|
|
|
|
# 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()
|
|
snapshot = json.loads(raw)
|
|
assert snapshot["origin_request_id"] == origin
|
|
return "ok"
|
|
|
|
async with Client(mcp) as client:
|
|
task = await client.call_tool("check_origin_request_id", {}, task=True)
|
|
result = await task.result()
|
|
assert result.data == "ok"
|
|
|
|
async def test_elicit_accept_flow(self):
|
|
"""E2E: tool elicits input, client accepts via elicitation_handler."""
|
|
mcp = FastMCP("elicit-accept-test")
|
|
|
|
@mcp.tool(task=True)
|
|
async def ask_name(ctx: Context) -> str:
|
|
result = await ctx.elicit("What is your name?", str)
|
|
if isinstance(result, AcceptedElicitation):
|
|
return f"Hello, {result.data}!"
|
|
return "No name provided"
|
|
|
|
async def handler(message, response_type, params, ctx):
|
|
return ElicitResult(action="accept", content={"value": "Bob"})
|
|
|
|
async with Client(mcp, elicitation_handler=handler) as client:
|
|
task = await client.call_tool("ask_name", {}, task=True)
|
|
await task.wait(timeout=10.0)
|
|
result = await task.result()
|
|
assert result.data == "Hello, Bob!"
|
|
|
|
async def test_elicit_decline_flow(self):
|
|
"""E2E: tool elicits input, client declines via elicitation_handler."""
|
|
mcp = FastMCP("elicit-decline-test")
|
|
|
|
@mcp.tool(task=True)
|
|
async def optional_input(ctx: Context) -> str:
|
|
result = await ctx.elicit("Want to provide a name?", str)
|
|
if isinstance(result, DeclinedElicitation):
|
|
return "User declined"
|
|
if isinstance(result, AcceptedElicitation):
|
|
return f"Got: {result.data}"
|
|
return "Cancelled"
|
|
|
|
async def handler(message, response_type, params, ctx):
|
|
return ElicitResult(action="decline")
|
|
|
|
async with Client(mcp, elicitation_handler=handler) as client:
|
|
task = await client.call_tool("optional_input", {}, task=True)
|
|
await task.wait(timeout=10.0)
|
|
result = await task.result()
|
|
assert result.data == "User declined"
|
|
|
|
async def test_elicit_with_pydantic_model(self):
|
|
"""E2E: tool elicits structured Pydantic input via elicitation_handler."""
|
|
|
|
class UserInfo(BaseModel):
|
|
name: str
|
|
age: int
|
|
|
|
mcp = FastMCP("elicit-pydantic-test")
|
|
|
|
@mcp.tool(task=True)
|
|
async def get_user_info(ctx: Context) -> str:
|
|
result = await ctx.elicit("Provide user info", UserInfo)
|
|
if isinstance(result, AcceptedElicitation):
|
|
assert isinstance(result.data, UserInfo)
|
|
return f"{result.data.name} is {result.data.age}"
|
|
return "No info"
|
|
|
|
async def handler(message, response_type, params, ctx):
|
|
return ElicitResult(action="accept", content={"name": "Alice", "age": 30})
|
|
|
|
async with Client(mcp, elicitation_handler=handler) as client:
|
|
task = await client.call_tool("get_user_info", {}, task=True)
|
|
await task.wait(timeout=10.0)
|
|
result = await task.result()
|
|
assert result.data == "Alice is 30"
|
|
|
|
async def test_handle_task_input_rejects_when_not_waiting(self):
|
|
"""handle_task_input returns False when no task is waiting for input."""
|
|
mcp = FastMCP("reject-test")
|
|
|
|
@mcp.tool(task=True)
|
|
async def simple_tool() -> str:
|
|
return "done"
|
|
|
|
async with Client(mcp) as client:
|
|
task = await client.call_tool("simple_tool", {}, task=True)
|
|
await task.wait(timeout=5.0)
|
|
|
|
# Task already completed — no elicitation waiting
|
|
success = await handle_task_input(
|
|
task_id=task.task_id,
|
|
session_id="nonexistent-session",
|
|
action="accept",
|
|
content={"value": "too late"},
|
|
fastmcp=mcp,
|
|
)
|
|
assert success is False
|
|
|
|
|
|
class TestAccessTokenInBackgroundTasks:
|
|
"""Tests for access token availability in background tasks (#3095).
|
|
|
|
Integration tests use Client(mcp) with the real memory:// Docket backend.
|
|
The token snapshot/restore round-trip flows through actual Redis (fakeredis).
|
|
|
|
Note: async tests run in isolated asyncio tasks, so ContextVar changes
|
|
are automatically scoped — no cleanup required.
|
|
"""
|
|
|
|
async def test_token_round_trips_through_background_task(self):
|
|
"""E2E: token set at submit time is available inside the worker."""
|
|
mcp = FastMCP("token-roundtrip")
|
|
|
|
@mcp.tool(task=True)
|
|
async def check_token(ctx: Context) -> str:
|
|
token = get_access_token()
|
|
if token is None:
|
|
return "no-token"
|
|
return f"{token.token}|{token.client_id}"
|
|
|
|
test_token = AccessToken(
|
|
token="roundtrip-jwt",
|
|
client_id="test-client",
|
|
scopes=["read"],
|
|
claims={"sub": "user-1"},
|
|
)
|
|
auth_context_var.set(AuthenticatedUser(test_token))
|
|
|
|
async with Client(mcp) as client:
|
|
task = await client.call_tool("check_token", {}, task=True)
|
|
result = await task.result()
|
|
assert result.data == "roundtrip-jwt|test-client"
|
|
|
|
async def test_no_token_when_unauthenticated(self):
|
|
"""E2E: background task gets no token when nothing was set."""
|
|
mcp = FastMCP("no-auth")
|
|
|
|
@mcp.tool(task=True)
|
|
async def check_token(ctx: Context) -> str:
|
|
token = get_access_token()
|
|
return "no-token" if token is None else token.token
|
|
|
|
async with Client(mcp) as client:
|
|
task = await client.call_tool("check_token", {}, task=True)
|
|
result = await task.result()
|
|
assert result.data == "no-token"
|
|
|
|
async def test_expired_token_returns_none(self):
|
|
"""get_access_token() returns None when task token has expired."""
|
|
expired = AccessToken(
|
|
token="expired-jwt",
|
|
client_id="test-client",
|
|
scopes=["read"],
|
|
expires_at=int(datetime.now(timezone.utc).timestamp()) - 3600,
|
|
)
|
|
_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."""
|
|
valid = AccessToken(
|
|
token="valid-jwt",
|
|
client_id="test-client",
|
|
scopes=["read"],
|
|
expires_at=int(datetime.now(timezone.utc).timestamp()) + 3600,
|
|
)
|
|
_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."""
|
|
no_expiry = AccessToken(
|
|
token="eternal-jwt",
|
|
client_id="test-client",
|
|
scopes=["read"],
|
|
)
|
|
_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:
|
|
"""Tests for lifespan_context availability in background tasks (#3095)."""
|
|
|
|
def test_lifespan_context_falls_back_to_server_result(self):
|
|
"""lifespan_context reads from server when request_context is None."""
|
|
mcp = FastMCP("test")
|
|
mcp._lifespan_result = {"db": "mock-db-connection", "cache": "mock-cache"}
|
|
|
|
ctx = Context(mcp, task_id="test-task")
|
|
assert ctx.request_context is None
|
|
assert ctx.lifespan_context == {
|
|
"db": "mock-db-connection",
|
|
"cache": "mock-cache",
|
|
}
|
|
|
|
def test_lifespan_context_returns_empty_dict_when_no_lifespan(self):
|
|
"""lifespan_context returns {} when no lifespan is configured."""
|
|
mcp = FastMCP("test")
|
|
ctx = Context(mcp, task_id="test-task")
|
|
assert ctx.request_context is None
|
|
assert ctx.lifespan_context == {}
|