Skip to content

Commit 659ec38

Browse files
authored
Merge pull request #2575 from jlowin/multi-mount-docket-isolation
Prefix Docket function names to avoid collisions in multi-mount setups
2 parents 11ae638 + 4a6f873 commit 659ec38

File tree

3 files changed

+138
-19
lines changed

3 files changed

+138
-19
lines changed

src/fastmcp/server/server.py

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,24 @@
102102

103103
logger = get_logger(__name__)
104104

105+
106+
def _create_named_fn_wrapper(fn: Callable[..., Any], name: str) -> Callable[..., Any]:
107+
"""Create a wrapper function with a custom __name__ for Docket registration.
108+
109+
Docket uses fn.__name__ as the key for function registration and lookup.
110+
When mounting servers, we need unique names to avoid collisions between
111+
mounted servers that have identically-named functions.
112+
"""
113+
import functools
114+
115+
@functools.wraps(fn)
116+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
117+
return await fn(*args, **kwargs)
118+
119+
wrapper.__name__ = name
120+
return wrapper
121+
122+
105123
DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
106124
Transport = Literal["stdio", "http", "sse", "streamable-http"]
107125

@@ -449,7 +467,7 @@ async def _docket_lifespan(self) -> AsyncIterator[None]:
449467
# execute in the parent's Docket context
450468
for mounted in self._mounted_servers:
451469
await self._register_mounted_server_functions(
452-
mounted.server, docket
470+
mounted.server, docket, mounted.prefix
453471
)
454472

455473
# Set Docket in ContextVar so CurrentDocket can access it
@@ -491,45 +509,69 @@ async def _docket_lifespan(self) -> AsyncIterator[None]:
491509
_current_server.reset(server_token)
492510

493511
async def _register_mounted_server_functions(
494-
self, server: FastMCP, docket: Docket
512+
self, server: FastMCP, docket: Docket, prefix: str | None
495513
) -> None:
496514
"""Register task-enabled functions from a mounted server with Docket.
497515
498516
This enables background task execution for mounted server components
499517
through the parent server's Docket context.
518+
519+
Args:
520+
server: The mounted server whose functions to register
521+
docket: The Docket instance to register with
522+
prefix: The mount prefix to prepend to function names (matches
523+
client-facing tool/prompt names)
500524
"""
501-
# Register tools
525+
# Register tools with prefixed names to avoid collisions
502526
for tool in server._tool_manager._tools.values():
503527
if isinstance(tool, FunctionTool) and tool.task_config.mode != "forbidden":
504-
docket.register(tool.fn)
528+
# Use same naming as client-facing tool keys
529+
fn_name = f"{prefix}_{tool.key}" if prefix else tool.key
530+
named_fn = _create_named_fn_wrapper(tool.fn, fn_name)
531+
docket.register(named_fn)
505532

506-
# Register prompts
533+
# Register prompts with prefixed names
507534
for prompt in server._prompt_manager._prompts.values():
508535
if (
509536
isinstance(prompt, FunctionPrompt)
510537
and prompt.task_config.mode != "forbidden"
511538
):
512-
docket.register(cast(Callable[..., Awaitable[Any]], prompt.fn))
539+
fn_name = f"{prefix}_{prompt.key}" if prefix else prompt.key
540+
named_fn = _create_named_fn_wrapper(
541+
cast(Callable[..., Awaitable[Any]], prompt.fn), fn_name
542+
)
543+
docket.register(named_fn)
513544

514-
# Register resources
545+
# Register resources with prefixed names (use name, not key/URI)
515546
for resource in server._resource_manager._resources.values():
516547
if (
517548
isinstance(resource, FunctionResource)
518549
and resource.task_config.mode != "forbidden"
519550
):
520-
docket.register(resource.fn)
551+
fn_name = f"{prefix}_{resource.name}" if prefix else resource.name
552+
named_fn = _create_named_fn_wrapper(resource.fn, fn_name)
553+
docket.register(named_fn)
521554

522-
# Register resource templates
555+
# Register resource templates with prefixed names (use name, not key/URI)
523556
for template in server._resource_manager._templates.values():
524557
if (
525558
isinstance(template, FunctionResourceTemplate)
526559
and template.task_config.mode != "forbidden"
527560
):
528-
docket.register(template.fn)
561+
fn_name = f"{prefix}_{template.name}" if prefix else template.name
562+
named_fn = _create_named_fn_wrapper(template.fn, fn_name)
563+
docket.register(named_fn)
529564

530-
# Recursively register from nested mounted servers
565+
# Recursively register from nested mounted servers with accumulated prefix
531566
for nested in server._mounted_servers:
532-
await self._register_mounted_server_functions(nested.server, docket)
567+
nested_prefix = (
568+
f"{prefix}_{nested.prefix}"
569+
if prefix and nested.prefix
570+
else (prefix or nested.prefix)
571+
)
572+
await self._register_mounted_server_functions(
573+
nested.server, docket, nested_prefix
574+
)
533575

534576
@asynccontextmanager
535577
async def _lifespan_manager(self) -> AsyncIterator[None]:

src/fastmcp/server/tasks/handlers.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,10 @@ async def handle_tool_as_task(
9999
# Don't let notification failures break task creation
100100
await ctx.session.send_notification(notification) # type: ignore[arg-type]
101101

102-
# Queue function to Docket (result storage via execution_ttl)
102+
# Queue function to Docket by name (result storage via execution_ttl)
103+
# Use tool.key which matches what was registered - prefixed for mounted tools
103104
await docket.add(
104-
tool.fn, # type: ignore[attr-defined]
105+
tool.key,
105106
key=task_key,
106107
)(**arguments)
107108

@@ -204,9 +205,10 @@ async def handle_prompt_as_task(
204205
with suppress(Exception):
205206
await ctx.session.send_notification(notification) # type: ignore[arg-type]
206207

207-
# Queue function to Docket (result storage via execution_ttl)
208+
# Queue function to Docket by name (result storage via execution_ttl)
209+
# Use prompt.key which matches what was registered - prefixed for mounted prompts
208210
await docket.add(
209-
prompt.fn, # type: ignore[attr-defined]
211+
prompt.key,
210212
key=task_key,
211213
)(**(arguments or {}))
212214

@@ -307,19 +309,20 @@ async def handle_resource_as_task(
307309
with suppress(Exception):
308310
await ctx.session.send_notification(notification) # type: ignore[arg-type]
309311

310-
# Queue function to Docket (result storage via execution_ttl)
312+
# Queue function to Docket by name (result storage via execution_ttl)
313+
# Use resource.name which matches what was registered - prefixed for mounted resources
311314
# For templates, extract URI params and pass them to the function
312315
from fastmcp.resources.template import FunctionResourceTemplate, match_uri_template
313316

314317
if isinstance(resource, FunctionResourceTemplate):
315318
params = match_uri_template(uri, resource.uri_template) or {}
316319
await docket.add(
317-
resource.fn, # type: ignore[attr-defined]
320+
resource.name,
318321
key=task_key,
319322
)(**params)
320323
else:
321324
await docket.add(
322-
resource.fn, # type: ignore[attr-defined]
325+
resource.name,
323326
key=task_key,
324327
)()
325328

tests/server/tasks/test_task_mount.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,80 @@ async def subtract(a: int, b: int) -> int:
368368
assert result2.data == 5
369369

370370

371+
class TestMountedFunctionNameCollisions:
372+
"""Test task execution when mounted servers have identically-named functions."""
373+
374+
async def test_multiple_mounts_with_same_function_names(self):
375+
"""Two mounted servers with identically-named functions don't collide."""
376+
child1 = FastMCP("child1")
377+
child2 = FastMCP("child2")
378+
379+
@child1.tool(task=True)
380+
async def process(value: int) -> int:
381+
return value * 2 # Double
382+
383+
@child2.tool(task=True)
384+
async def process(value: int) -> int: # noqa: F811
385+
return value * 3 # Triple
386+
387+
parent = FastMCP("parent")
388+
parent.mount(child1, prefix="c1")
389+
parent.mount(child2, prefix="c2")
390+
391+
async with Client(parent) as client:
392+
# Both should execute their own implementation
393+
task1 = await client.call_tool("c1_process", {"value": 10}, task=True)
394+
task2 = await client.call_tool("c2_process", {"value": 10}, task=True)
395+
396+
result1 = await task1.result()
397+
result2 = await task2.result()
398+
399+
assert result1.data == 20 # child1's process (doubles)
400+
assert result2.data == 30 # child2's process (triples)
401+
402+
async def test_no_prefix_mount_collision(self):
403+
"""No-prefix mounts with same tool name - last mount wins."""
404+
child1 = FastMCP("child1")
405+
child2 = FastMCP("child2")
406+
407+
@child1.tool(task=True)
408+
async def process(value: int) -> int:
409+
return value * 2
410+
411+
@child2.tool(task=True)
412+
async def process(value: int) -> int: # noqa: F811
413+
return value * 3
414+
415+
parent = FastMCP("parent")
416+
parent.mount(child1) # No prefix
417+
parent.mount(child2) # No prefix - overwrites child1's "process"
418+
419+
async with Client(parent) as client:
420+
# Last mount wins - child2's process should execute
421+
task = await client.call_tool("process", {"value": 10}, task=True)
422+
result = await task.result()
423+
assert result.data == 30 # child2's process (triples)
424+
425+
async def test_nested_mount_prefix_accumulation(self):
426+
"""Nested mounts accumulate prefixes correctly for tasks."""
427+
grandchild = FastMCP("gc")
428+
child = FastMCP("child")
429+
parent = FastMCP("parent")
430+
431+
@grandchild.tool(task=True)
432+
async def deep_tool() -> str:
433+
return "deep"
434+
435+
child.mount(grandchild, prefix="gc")
436+
parent.mount(child, prefix="child")
437+
438+
async with Client(parent) as client:
439+
# Tool should be accessible and execute correctly
440+
task = await client.call_tool("child_gc_deep_tool", {}, task=True)
441+
result = await task.result()
442+
assert result.data == "deep"
443+
444+
371445
class TestMountedTaskList:
372446
"""Test task listing with mounted servers."""
373447

0 commit comments

Comments
 (0)