fix: resolve CurrentFastMCP/ctx.fastmcp to child server in mounted background tasks

Closes #3571
This commit is contained in:
Jeremiah Lowin 2026-03-27 10:10:48 -04:00
commit ecee38d3d6
No known key found for this signature in database
3 changed files with 123 additions and 11 deletions

View file

@ -72,6 +72,7 @@ __all__ = [
"get_task_context",
"get_task_session",
"is_docket_available",
"register_task_server",
"register_task_session",
"require_docket",
"resolve_dependencies",
@ -174,6 +175,26 @@ def get_task_session(session_id: str) -> ServerSession | None:
_current_server: ContextVar[weakref.ref[FastMCP] | None] = ContextVar(
"server", default=None
)
# --- Background task server map ---
# Maps task_id → server weakref so background workers can resolve the correct
# server for mounted-child tasks. Follows the same pattern as _task_sessions.
# Populated in submit_to_docket() where the child server is in context;
# consulted in get_server() when running inside a Docket worker.
_task_server_map: dict[str, weakref.ref[FastMCP]] = {}
def register_task_server(task_id: str, server: FastMCP) -> None:
"""Register the server for a background task.
Called at task-submission time (inside the child server's call_tool
context) so that background workers can resolve CurrentFastMCP() and
ctx.fastmcp to the child server for mounted tasks.
"""
_task_server_map[task_id] = weakref.ref(server)
_current_docket: ContextVar[Docket | None] = ContextVar("docket", default=None)
_current_worker: ContextVar[Worker | None] = ContextVar("worker", default=None)
_task_access_token: ContextVar[AccessToken | None] = ContextVar(
@ -378,12 +399,28 @@ def get_context() -> Context:
def get_server() -> FastMCP:
"""Get the current FastMCP server instance directly.
In a background-task worker, checks the task-server map first so that
mounted-child tasks resolve to the child server (not the parent that
started the worker).
Returns:
The active FastMCP server
Raises:
RuntimeError: If no server in context
"""
# In a task context, prefer the task-specific server mapping.
# This handles mounted-child tasks where _current_server is the parent.
task_info = get_task_context()
if task_info is not None:
ref = _task_server_map.get(task_info.task_id)
if ref is not None:
server = ref()
if server is not None:
return server
# Server was garbage collected, clean up
_task_server_map.pop(task_info.task_id, None)
server_ref = _current_server.get()
if server_ref is None:
raise RuntimeError("No FastMCP server instance in context")
@ -1037,13 +1074,7 @@ class _CurrentFastMCP(Dependency["FastMCP"]):
"""Async context manager for FastMCP server dependency."""
async def __aenter__(self) -> FastMCP:
server_ref = _current_server.get()
if server_ref is None:
raise RuntimeError("No FastMCP server instance in context")
server = server_ref()
if server is None:
raise RuntimeError("FastMCP server instance is no longer available")
return server
return get_server()
async def __aexit__(
self,

View file

@ -14,7 +14,12 @@ import mcp.types
from mcp.shared.exceptions import McpError
from mcp.types import INTERNAL_ERROR, ErrorData
from fastmcp.server.dependencies import _current_docket, get_access_token, get_context
from fastmcp.server.dependencies import (
_current_docket,
get_access_token,
get_context,
register_task_server,
)
from fastmcp.server.tasks.config import TaskMeta
from fastmcp.server.tasks.keys import build_task_key
from fastmcp.utilities.logging import get_logger
@ -80,6 +85,12 @@ async def submit_to_docket(
)
)
# Register the current server so background workers resolve
# CurrentFastMCP() / ctx.fastmcp to the correct (child) server
# for mounted tasks. At this point ctx.fastmcp is the child because
# we're inside the child's call_tool dispatch.
register_task_server(server_task_id, ctx.fastmcp)
# Build full task key with embedded metadata
task_key = build_task_key(session_id, server_task_id, task_type, key)

View file

@ -336,10 +336,80 @@ class TestMountedTaskDependencies:
task = await client.call_tool("child_tool_with_server", {}, task=True)
await task.result()
# The server should be the child server since that's where the tool is defined
assert len(received_server) == 1
# Note: It might be parent or child depending on implementation
assert received_server[0] is not None
assert received_server[0].name == "server-dep-child"
class TestMountedTaskServerContext:
"""Test that background tasks on mounted servers resolve to the child server (#3571)."""
async def test_current_fastmcp_resolves_to_child_server(self):
"""CurrentFastMCP() inside a mounted background task returns the child server."""
child = FastMCP("child")
received_server: list[FastMCP] = []
@child.tool(task=True)
async def whoami(server: CurrentFastMCP = CurrentFastMCP()) -> str: # type: ignore[invalid-type-form] # ty:ignore[invalid-type-form]
received_server.append(server)
return f"server name: {server.name}"
parent = FastMCP("parent")
parent.mount(child, namespace="child")
async with Client(parent) as client:
task = await client.call_tool("child_whoami", {}, task=True)
result = await task.result()
assert len(received_server) == 1
assert received_server[0].name == "child"
assert "server name: child" in str(result)
async def test_context_fastmcp_resolves_to_child_server(self):
"""ctx.fastmcp inside a mounted background task returns the child server."""
from fastmcp import Context
child = FastMCP("child")
received_server: list[FastMCP] = []
@child.tool(task=True)
async def whoami_ctx(ctx: Context) -> str:
received_server.append(ctx.fastmcp)
return f"context server: {ctx.fastmcp.name}"
parent = FastMCP("parent")
parent.mount(child, namespace="child")
async with Client(parent) as client:
task = await client.call_tool("child_whoami_ctx", {}, task=True)
result = await task.result()
assert len(received_server) == 1
assert received_server[0].name == "child"
assert "context server: child" in str(result)
async def test_nested_mount_resolves_to_innermost_server(self):
"""Doubly-nested mounts resolve to the innermost child server."""
grandchild = FastMCP("grandchild")
received_server: list[FastMCP] = []
@grandchild.tool(task=True)
async def deep_whoami(server: CurrentFastMCP = CurrentFastMCP()) -> str: # type: ignore[invalid-type-form] # ty:ignore[invalid-type-form]
received_server.append(server)
return f"server name: {server.name}"
child = FastMCP("child")
child.mount(grandchild, namespace="gc")
parent = FastMCP("parent")
parent.mount(child, namespace="child")
async with Client(parent) as client:
task = await client.call_tool("child_gc_deep_whoami", {}, task=True)
result = await task.result()
assert len(received_server) == 1
assert received_server[0].name == "grandchild"
assert "server name: grandchild" in str(result)
class TestMultipleMounts: