60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
712 lines
26 KiB
Python
712 lines
26 KiB
Python
"""Advanced mounting scenarios."""
|
|
|
|
import pytest
|
|
from mcp_types import TextContent
|
|
from starlette.routing import Route
|
|
|
|
from fastmcp import FastMCP
|
|
from fastmcp.client import Client
|
|
from fastmcp.server.providers import FastMCPProvider
|
|
from fastmcp.server.providers.wrapped_provider import _WrappedProvider
|
|
|
|
|
|
class TestDynamicChanges:
|
|
"""Test that changes to mounted servers are reflected dynamically."""
|
|
|
|
async def test_adding_tool_after_mounting(self):
|
|
"""Test that tools added after mounting are accessible."""
|
|
main_app = FastMCP("MainApp")
|
|
sub_app = FastMCP("SubApp")
|
|
|
|
# Mount the sub-app before adding any tools
|
|
main_app.mount(sub_app, "sub")
|
|
|
|
# Initially, there should be no tools from sub_app
|
|
tools = await main_app.list_tools()
|
|
assert not any(t.name.startswith("sub_") for t in tools)
|
|
|
|
# Add a tool to the sub-app after mounting
|
|
@sub_app.tool
|
|
def dynamic_tool() -> str:
|
|
return "Added after mounting"
|
|
|
|
# The tool should be accessible through the main app
|
|
tools = await main_app.list_tools()
|
|
assert any(t.name == "sub_dynamic_tool" for t in tools)
|
|
|
|
# Call the dynamically added tool
|
|
result = await main_app.call_tool("sub_dynamic_tool", {})
|
|
assert result.structured_content == {"result": "Added after mounting"}
|
|
|
|
async def test_removing_tool_after_mounting(self):
|
|
"""Test that tools removed from mounted servers are no longer accessible."""
|
|
main_app = FastMCP("MainApp")
|
|
sub_app = FastMCP("SubApp")
|
|
|
|
@sub_app.tool
|
|
def temp_tool() -> str:
|
|
return "Temporary tool"
|
|
|
|
# Mount the sub-app
|
|
main_app.mount(sub_app, "sub")
|
|
|
|
# Initially, the tool should be accessible
|
|
tools = await main_app.list_tools()
|
|
assert any(t.name == "sub_temp_tool" for t in tools)
|
|
|
|
# Remove the tool from sub_app
|
|
sub_app.local_provider.remove_tool("temp_tool")
|
|
|
|
# The tool should no longer be accessible
|
|
tools = await main_app.list_tools()
|
|
assert not any(t.name == "sub_temp_tool" for t in tools)
|
|
|
|
|
|
class TestCustomRouteForwarding:
|
|
"""Test that custom HTTP routes from mounted servers are forwarded."""
|
|
|
|
async def test_get_additional_http_routes_empty(self):
|
|
"""Test _get_additional_http_routes returns empty list for server with no routes."""
|
|
server = FastMCP("TestServer")
|
|
routes = server._get_additional_http_routes()
|
|
assert routes == []
|
|
|
|
async def test_get_additional_http_routes_with_custom_route(self):
|
|
"""Test _get_additional_http_routes returns server's own routes."""
|
|
server = FastMCP("TestServer")
|
|
|
|
@server.custom_route("/test", methods=["GET"])
|
|
async def test_route(request):
|
|
from starlette.responses import JSONResponse
|
|
|
|
return JSONResponse({"message": "test"})
|
|
|
|
routes = server._get_additional_http_routes()
|
|
assert len(routes) == 1
|
|
assert isinstance(routes[0], Route)
|
|
assert routes[0].path == "/test"
|
|
|
|
async def test_mounted_servers_tracking(self):
|
|
"""Test that providers list tracks mounted servers correctly."""
|
|
from fastmcp.server.providers.local_provider import LocalProvider
|
|
|
|
main_server = FastMCP("MainServer")
|
|
sub_server1 = FastMCP("SubServer1")
|
|
sub_server2 = FastMCP("SubServer2")
|
|
|
|
@sub_server1.tool
|
|
def tool1() -> str:
|
|
return "1"
|
|
|
|
@sub_server2.tool
|
|
def tool2() -> str:
|
|
return "2"
|
|
|
|
# Initially only LocalProvider
|
|
assert len(main_server.providers) == 1
|
|
assert isinstance(main_server.providers[0], LocalProvider)
|
|
|
|
# Mount first server
|
|
main_server.mount(sub_server1, "sub1")
|
|
assert len(main_server.providers) == 2
|
|
# LocalProvider is at index 0, mounted provider (wrapped) at index 1
|
|
provider1 = main_server.providers[1]
|
|
assert isinstance(provider1, _WrappedProvider)
|
|
assert isinstance(provider1._inner, FastMCPProvider)
|
|
assert provider1._inner.server == sub_server1
|
|
|
|
# Mount second server
|
|
main_server.mount(sub_server2, "sub2")
|
|
assert len(main_server.providers) == 3
|
|
provider2 = main_server.providers[2]
|
|
assert isinstance(provider2, _WrappedProvider)
|
|
assert isinstance(provider2._inner, FastMCPProvider)
|
|
assert provider2._inner.server == sub_server2
|
|
|
|
# Verify namespacing is applied by checking tool names
|
|
tools = await main_server.list_tools()
|
|
tool_names = {t.name for t in tools}
|
|
assert tool_names == {"sub1_tool1", "sub2_tool2"}
|
|
|
|
async def test_multiple_routes_same_server(self):
|
|
"""Test that multiple custom routes from same server are all included."""
|
|
server = FastMCP("TestServer")
|
|
|
|
@server.custom_route("/route1", methods=["GET"])
|
|
async def route1(request):
|
|
from starlette.responses import JSONResponse
|
|
|
|
return JSONResponse({"message": "route1"})
|
|
|
|
@server.custom_route("/route2", methods=["POST"])
|
|
async def route2(request):
|
|
from starlette.responses import JSONResponse
|
|
|
|
return JSONResponse({"message": "route2"})
|
|
|
|
routes = server._get_additional_http_routes()
|
|
assert len(routes) == 2
|
|
route_paths = [route.path for route in routes if isinstance(route, Route)]
|
|
assert "/route1" in route_paths
|
|
assert "/route2" in route_paths
|
|
|
|
async def test_mounted_server_custom_routes_forwarded(self):
|
|
"""Test that custom routes from a mounted server appear in the parent.
|
|
|
|
Regression test for https://github.com/PrefectHQ/fastmcp/issues/3457
|
|
where custom_route endpoints defined on a child server were silently
|
|
dropped when the child was mounted onto a parent, resulting in 404s.
|
|
"""
|
|
parent = FastMCP("Parent")
|
|
child = FastMCP("Child")
|
|
|
|
@child.custom_route("/readyz", methods=["GET"])
|
|
async def readiness_check(request):
|
|
from starlette.responses import JSONResponse
|
|
|
|
return JSONResponse({"status": "ok"})
|
|
|
|
parent.mount(child)
|
|
|
|
routes = parent._get_additional_http_routes()
|
|
assert len(routes) == 1
|
|
assert isinstance(routes[0], Route)
|
|
assert routes[0].path == "/readyz"
|
|
|
|
async def test_mounted_server_custom_routes_with_namespace(self):
|
|
"""Test that custom routes from a namespaced mount are forwarded."""
|
|
parent = FastMCP("Parent")
|
|
child = FastMCP("Child")
|
|
|
|
@child.custom_route("/health", methods=["GET"])
|
|
async def health(request):
|
|
from starlette.responses import JSONResponse
|
|
|
|
return JSONResponse({"status": "ok"})
|
|
|
|
parent.mount(child, namespace="child")
|
|
|
|
routes = parent._get_additional_http_routes()
|
|
assert len(routes) == 1
|
|
assert isinstance(routes[0], Route)
|
|
assert routes[0].path == "/health"
|
|
|
|
async def test_deeply_nested_custom_routes_forwarded(self):
|
|
"""Test that custom routes from deeply nested mounts are collected."""
|
|
root = FastMCP("Root")
|
|
middle = FastMCP("Middle")
|
|
leaf = FastMCP("Leaf")
|
|
|
|
@leaf.custom_route("/leaf-health", methods=["GET"])
|
|
async def leaf_health(request):
|
|
from starlette.responses import JSONResponse
|
|
|
|
return JSONResponse({"status": "ok"})
|
|
|
|
@middle.custom_route("/middle-health", methods=["GET"])
|
|
async def middle_health(request):
|
|
from starlette.responses import JSONResponse
|
|
|
|
return JSONResponse({"status": "ok"})
|
|
|
|
middle.mount(leaf)
|
|
root.mount(middle)
|
|
|
|
routes = root._get_additional_http_routes()
|
|
route_paths = [r.path for r in routes if isinstance(r, Route)]
|
|
assert "/leaf-health" in route_paths
|
|
assert "/middle-health" in route_paths
|
|
assert len(route_paths) == 2
|
|
|
|
async def test_mounted_custom_routes_http_app_integration(self):
|
|
"""End-to-end: custom routes from mounted servers are reachable via http_app.
|
|
|
|
This reproduces the exact scenario from issue #3457.
|
|
"""
|
|
from starlette.testclient import TestClient
|
|
|
|
parent = FastMCP("Parent")
|
|
child = FastMCP("Child")
|
|
|
|
@child.custom_route("/readyz", methods=["GET"])
|
|
async def readiness_check(request):
|
|
from starlette.responses import JSONResponse
|
|
|
|
return JSONResponse({"status": "ok"})
|
|
|
|
parent.mount(child)
|
|
|
|
app = parent.http_app()
|
|
client = TestClient(app)
|
|
response = client.get("/readyz")
|
|
assert response.status_code == 200
|
|
assert response.json() == {"status": "ok"}
|
|
|
|
|
|
class TestDeeplyNestedMount:
|
|
"""Test deeply nested mount scenarios (3+ levels deep).
|
|
|
|
This tests the fix for https://github.com/PrefectHQ/fastmcp/issues/2583
|
|
where tools/resources/prompts mounted more than 2 levels deep would fail
|
|
to invoke even though they were correctly listed.
|
|
"""
|
|
|
|
async def test_three_level_nested_tool_invocation(self):
|
|
"""Test invoking tools from servers mounted 3 levels deep."""
|
|
root = FastMCP("root")
|
|
middle = FastMCP("middle")
|
|
leaf = FastMCP("leaf")
|
|
|
|
@leaf.tool
|
|
def add(a: int, b: int) -> int:
|
|
return a + b
|
|
|
|
@middle.tool
|
|
def multiply(a: int, b: int) -> int:
|
|
return a * b
|
|
|
|
middle.mount(leaf, namespace="leaf")
|
|
root.mount(middle, namespace="middle")
|
|
|
|
# Tool at level 2 should work
|
|
result = await root.call_tool("middle_multiply", {"a": 3, "b": 4})
|
|
assert result.structured_content == {"result": 12}
|
|
|
|
# Tool at level 3 should also work (this was the bug)
|
|
result = await root.call_tool("middle_leaf_add", {"a": 5, "b": 7})
|
|
assert result.structured_content == {"result": 12}
|
|
|
|
async def test_three_level_nested_resource_invocation(self):
|
|
"""Test reading resources from servers mounted 3 levels deep."""
|
|
root = FastMCP("root")
|
|
middle = FastMCP("middle")
|
|
leaf = FastMCP("leaf")
|
|
|
|
@leaf.resource("leaf://data")
|
|
def leaf_data() -> str:
|
|
return "leaf data"
|
|
|
|
@middle.resource("middle://data")
|
|
def middle_data() -> str:
|
|
return "middle data"
|
|
|
|
middle.mount(leaf, namespace="leaf")
|
|
root.mount(middle, namespace="middle")
|
|
|
|
# Resource at level 2 should work
|
|
result = await root.read_resource("middle://middle/data")
|
|
assert result.contents[0].content == "middle data"
|
|
|
|
# Resource at level 3 should also work
|
|
result = await root.read_resource("leaf://middle/leaf/data")
|
|
assert result.contents[0].content == "leaf data"
|
|
|
|
async def test_three_level_nested_resource_template_invocation(self):
|
|
"""Test reading resource templates from servers mounted 3 levels deep."""
|
|
root = FastMCP("root")
|
|
middle = FastMCP("middle")
|
|
leaf = FastMCP("leaf")
|
|
|
|
@leaf.resource("leaf://item/{id}")
|
|
def leaf_item(id: str) -> str:
|
|
return f"leaf item {id}"
|
|
|
|
@middle.resource("middle://item/{id}")
|
|
def middle_item(id: str) -> str:
|
|
return f"middle item {id}"
|
|
|
|
middle.mount(leaf, namespace="leaf")
|
|
root.mount(middle, namespace="middle")
|
|
|
|
# Resource template at level 2 should work
|
|
result = await root.read_resource("middle://middle/item/42")
|
|
assert result.contents[0].content == "middle item 42"
|
|
|
|
# Resource template at level 3 should also work
|
|
result = await root.read_resource("leaf://middle/leaf/item/99")
|
|
assert result.contents[0].content == "leaf item 99"
|
|
|
|
async def test_three_level_nested_prompt_invocation(self):
|
|
"""Test getting prompts from servers mounted 3 levels deep."""
|
|
root = FastMCP("root")
|
|
middle = FastMCP("middle")
|
|
leaf = FastMCP("leaf")
|
|
|
|
@leaf.prompt
|
|
def leaf_prompt(name: str) -> str:
|
|
return f"Hello from leaf: {name}"
|
|
|
|
@middle.prompt
|
|
def middle_prompt(name: str) -> str:
|
|
return f"Hello from middle: {name}"
|
|
|
|
middle.mount(leaf, namespace="leaf")
|
|
root.mount(middle, namespace="middle")
|
|
|
|
# Prompt at level 2 should work
|
|
result = await root.render_prompt("middle_middle_prompt", {"name": "World"})
|
|
assert isinstance(result.messages[0].content, TextContent)
|
|
assert "Hello from middle: World" in result.messages[0].content.text
|
|
|
|
# Prompt at level 3 should also work
|
|
result = await root.render_prompt("middle_leaf_leaf_prompt", {"name": "Test"})
|
|
assert isinstance(result.messages[0].content, TextContent)
|
|
assert "Hello from leaf: Test" in result.messages[0].content.text
|
|
|
|
async def test_four_level_nested_tool_invocation(self):
|
|
"""Test invoking tools from servers mounted 4 levels deep."""
|
|
root = FastMCP("root")
|
|
level1 = FastMCP("level1")
|
|
level2 = FastMCP("level2")
|
|
level3 = FastMCP("level3")
|
|
|
|
@level3.tool
|
|
def deep_tool() -> str:
|
|
return "very deep"
|
|
|
|
level2.mount(level3, namespace="l3")
|
|
level1.mount(level2, namespace="l2")
|
|
root.mount(level1, namespace="l1")
|
|
|
|
# Verify tool is listed
|
|
tools = await root.list_tools()
|
|
tool_names = [t.name for t in tools]
|
|
assert "l1_l2_l3_deep_tool" in tool_names
|
|
|
|
# Tool at level 4 should work
|
|
result = await root.call_tool("l1_l2_l3_deep_tool", {})
|
|
assert result.structured_content == {"result": "very deep"}
|
|
|
|
|
|
class TestToolNameOverrides:
|
|
"""Test tool and prompt name overrides in mount() (issue #2596)."""
|
|
|
|
async def test_tool_names_override_via_transforms(self):
|
|
"""Test that tool_names renames tools via ToolTransform layer.
|
|
|
|
Tool renames are applied first, then namespace prefixing.
|
|
So original_tool → custom_name → prefix_custom_name.
|
|
"""
|
|
sub = FastMCP("Sub")
|
|
|
|
@sub.tool
|
|
def original_tool() -> str:
|
|
return "test"
|
|
|
|
main = FastMCP("Main")
|
|
# tool_names renames first, then namespace is applied
|
|
main.mount(
|
|
sub,
|
|
namespace="prefix",
|
|
tool_names={"original_tool": "custom_name"},
|
|
)
|
|
|
|
# Server introspection shows renamed + namespaced names
|
|
tools = await main.list_tools()
|
|
tool_names = [t.name for t in tools]
|
|
assert "prefix_custom_name" in tool_names
|
|
assert "original_tool" not in tool_names
|
|
assert "prefix_original_tool" not in tool_names
|
|
assert "custom_name" not in tool_names
|
|
|
|
async def test_tool_names_override_applied_in_list_tools(self):
|
|
"""Test that tool_names override is reflected in list_tools()."""
|
|
sub = FastMCP("Sub")
|
|
|
|
@sub.tool
|
|
def original_tool() -> str:
|
|
return "test"
|
|
|
|
main = FastMCP("Main")
|
|
main.mount(
|
|
sub,
|
|
namespace="prefix",
|
|
tool_names={"original_tool": "custom_name"},
|
|
)
|
|
|
|
tools = await main.list_tools()
|
|
tool_names = [t.name for t in tools]
|
|
assert "prefix_custom_name" in tool_names
|
|
assert "prefix_original_tool" not in tool_names
|
|
|
|
async def test_tool_call_with_overridden_name(self):
|
|
"""Test that overridden tool can be called by its new name."""
|
|
sub = FastMCP("Sub")
|
|
|
|
@sub.tool
|
|
def original_tool() -> str:
|
|
return "success"
|
|
|
|
main = FastMCP("Main")
|
|
main.mount(
|
|
sub,
|
|
namespace="prefix",
|
|
tool_names={"original_tool": "renamed"},
|
|
)
|
|
|
|
# Tool is renamed then namespaced: original_tool → renamed → prefix_renamed
|
|
result = await main.call_tool("prefix_renamed", {})
|
|
assert result.structured_content == {"result": "success"}
|
|
|
|
def test_duplicate_tool_rename_targets_raises_error(self):
|
|
"""Test that duplicate target names in tool_renames raises ValueError."""
|
|
sub = FastMCP("Sub")
|
|
main = FastMCP("Main")
|
|
|
|
with pytest.raises(ValueError, match="duplicate target name"):
|
|
main.mount(
|
|
sub,
|
|
tool_names={"tool_a": "same_name", "tool_b": "same_name"},
|
|
)
|
|
|
|
|
|
class TestMountedServerLifespanContext:
|
|
"""Regression tests for mounted server lifespan_context resolution.
|
|
|
|
A mounted server's tools should see *their own* lifespan context via
|
|
``ctx.lifespan_context``, not the parent's. Previously the result yielded
|
|
by a mounted server's lifespan was discarded and ``ctx.lifespan_context``
|
|
fell through to the parent's MCP-session lifespan context.
|
|
"""
|
|
|
|
async def test_mounted_child_sees_own_lifespan_context(self):
|
|
"""A tool on a mounted child reads its own lifespan result."""
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastmcp.server.context import Context
|
|
|
|
@asynccontextmanager
|
|
async def parent_lifespan(_mcp: FastMCP) -> AsyncIterator[dict]:
|
|
yield {"who": "parent", "parent_only": "P"}
|
|
|
|
@asynccontextmanager
|
|
async def child_lifespan(_mcp: FastMCP) -> AsyncIterator[dict]:
|
|
yield {"who": "child", "child_only": "C"}
|
|
|
|
parent = FastMCP("Parent", lifespan=parent_lifespan)
|
|
child = FastMCP("Child", lifespan=child_lifespan)
|
|
|
|
@child.tool
|
|
def whoami(ctx: Context) -> dict:
|
|
return ctx.lifespan_context
|
|
|
|
parent.mount(child, "child")
|
|
|
|
async with Client(parent) as client:
|
|
result = await client.call_tool("child_whoami", {})
|
|
|
|
assert result.data == {"who": "child", "child_only": "C"}
|
|
|
|
async def test_independent_sibling_servers_each_get_own_lifecycle(self):
|
|
"""Two unrelated servers entered in the same async context are not nested.
|
|
|
|
Each must run its own ``_docket_lifespan_root`` and establish its own
|
|
Docket / server-context — they are not in a parent/child relationship,
|
|
only the same async stack.
|
|
"""
|
|
from contextlib import AsyncExitStack
|
|
|
|
from fastmcp.server.dependencies import _current_server
|
|
|
|
server_a = FastMCP("A")
|
|
server_b = FastMCP("B")
|
|
|
|
async with AsyncExitStack() as stack:
|
|
await stack.enter_async_context(server_a._lifespan_manager())
|
|
# While only A is active, _current_server resolves to A.
|
|
ref = _current_server.get()
|
|
assert ref is not None and ref() is server_a
|
|
|
|
await stack.enter_async_context(server_b._lifespan_manager())
|
|
# B established its own lifecycle; _current_server now resolves to B.
|
|
ref = _current_server.get()
|
|
assert ref is not None and ref() is server_b
|
|
|
|
async def test_unrelated_server_after_parent_with_mount(self):
|
|
"""A parent with a mounted child must not contaminate later entries.
|
|
|
|
``FastMCPProvider`` sets ``_lifespan_root_active`` while entering the
|
|
wrapped child. The flag must be cleared by the time the parent's
|
|
``_lifespan_manager`` yields, otherwise an unrelated server entered
|
|
later in the same task would see the flag and skip its own root setup.
|
|
"""
|
|
from contextlib import AsyncExitStack
|
|
|
|
from fastmcp.server.dependencies import _current_server
|
|
|
|
parent = FastMCP("Parent")
|
|
child = FastMCP("Child")
|
|
parent.mount(child, "child")
|
|
|
|
unrelated = FastMCP("Unrelated")
|
|
|
|
async with AsyncExitStack() as stack:
|
|
await stack.enter_async_context(parent._lifespan_manager())
|
|
# Parent's mount has run FastMCPProvider.lifespan; the flag set
|
|
# during child entry must have been reset by now.
|
|
await stack.enter_async_context(unrelated._lifespan_manager())
|
|
# Unrelated established its own lifecycle.
|
|
ref = _current_server.get()
|
|
assert ref is not None and ref() is unrelated
|
|
|
|
async def test_nested_grandchild_lifespan_runs(self):
|
|
"""A grandchild's lifespan is entered exactly once and visible to its tools."""
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastmcp.server.context import Context
|
|
|
|
events: list[str] = []
|
|
|
|
@asynccontextmanager
|
|
async def grandchild_lifespan(_mcp: FastMCP) -> AsyncIterator[dict]:
|
|
events.append("enter")
|
|
try:
|
|
yield {"who": "grandchild"}
|
|
finally:
|
|
events.append("exit")
|
|
|
|
parent = FastMCP("Parent")
|
|
child = FastMCP("Child")
|
|
grandchild = FastMCP("Grandchild", lifespan=grandchild_lifespan)
|
|
|
|
@grandchild.tool
|
|
def whoami(ctx: Context) -> dict:
|
|
return ctx.lifespan_context
|
|
|
|
child.mount(grandchild, "g")
|
|
parent.mount(child, "c")
|
|
|
|
async with Client(parent) as client:
|
|
result1 = await client.call_tool("c_g_whoami", {})
|
|
result2 = await client.call_tool("c_g_whoami", {})
|
|
|
|
assert result1.data == {"who": "grandchild"}
|
|
assert result2.data == {"who": "grandchild"}
|
|
# Lifespan is entered once at startup and exited once at teardown,
|
|
# not per-call and not per-mount level.
|
|
assert events == ["enter", "exit"]
|
|
|
|
|
|
class TestMountedServerDocketBehavior:
|
|
"""Regression tests for mounted server lifecycle behavior.
|
|
|
|
These tests guard against architectural changes that could accidentally
|
|
start Docket instances for mounted servers. Mounted servers should only
|
|
run their user-defined lifespan, not the full _lifespan_manager which
|
|
includes Docket creation.
|
|
"""
|
|
|
|
async def test_mounted_server_does_not_have_docket(self):
|
|
"""Test that a mounted server doesn't create its own Docket.
|
|
|
|
MountedProvider.lifespan() should call only the server's _lifespan
|
|
(user-defined lifespan), not _lifespan_manager (which includes Docket).
|
|
"""
|
|
main_app = FastMCP("MainApp")
|
|
sub_app = FastMCP("SubApp")
|
|
|
|
# Need a task-enabled component to trigger Docket initialization
|
|
@main_app.tool(task=True)
|
|
async def _trigger_docket() -> str:
|
|
return "trigger"
|
|
|
|
@sub_app.tool
|
|
def my_tool() -> str:
|
|
return "test"
|
|
|
|
main_app.mount(sub_app, "sub")
|
|
|
|
# After running the main app's lifespan, the sub app should not have
|
|
# its own Docket instance
|
|
async with Client(main_app) as client:
|
|
# The main app should have a docket (created by _lifespan_manager)
|
|
# because it has a task-enabled component
|
|
assert main_app.docket is not None
|
|
|
|
# The mounted sub app should NOT have its own docket
|
|
# It uses the parent's docket for background tasks
|
|
assert sub_app.docket is None
|
|
|
|
# But the tool should still work (prefixed as sub_my_tool)
|
|
result = await client.call_tool("sub_my_tool", {})
|
|
assert result.data == "test"
|
|
|
|
|
|
class TestComponentServicePrefixLess:
|
|
"""Test that enable/disable works with prefix-less mounted servers."""
|
|
|
|
async def test_enable_tool_prefixless_mount(self):
|
|
"""Test enabling a tool on a prefix-less mounted server."""
|
|
main_app = FastMCP("MainApp")
|
|
sub_app = FastMCP("SubApp")
|
|
|
|
@sub_app.tool
|
|
def my_tool() -> str:
|
|
return "test"
|
|
|
|
# Mount without prefix
|
|
main_app.mount(sub_app)
|
|
|
|
# Initially the tool is enabled
|
|
tools = await main_app.list_tools()
|
|
assert any(t.name == "my_tool" for t in tools)
|
|
|
|
# Disable and re-enable
|
|
main_app.disable(names={"my_tool"}, components={"tool"})
|
|
# Verify tool is now disabled
|
|
tools = await main_app.list_tools()
|
|
assert not any(t.name == "my_tool" for t in tools)
|
|
|
|
main_app.enable(names={"my_tool"}, components={"tool"})
|
|
# Verify tool is now enabled
|
|
tools = await main_app.list_tools()
|
|
assert any(t.name == "my_tool" for t in tools)
|
|
|
|
async def test_enable_resource_prefixless_mount(self):
|
|
"""Test enabling a resource on a prefix-less mounted server."""
|
|
main_app = FastMCP("MainApp")
|
|
sub_app = FastMCP("SubApp")
|
|
|
|
@sub_app.resource(uri="data://test")
|
|
def my_resource() -> str:
|
|
return "test data"
|
|
|
|
# Mount without prefix
|
|
main_app.mount(sub_app)
|
|
|
|
# Disable and re-enable
|
|
main_app.disable(names={"data://test"}, components={"resource"})
|
|
# Verify resource is now disabled
|
|
resources = await main_app.list_resources()
|
|
assert not any(str(r.uri) == "data://test" for r in resources)
|
|
|
|
main_app.enable(names={"data://test"}, components={"resource"})
|
|
# Verify resource is now enabled
|
|
resources = await main_app.list_resources()
|
|
assert any(str(r.uri) == "data://test" for r in resources)
|
|
|
|
async def test_enable_prompt_prefixless_mount(self):
|
|
"""Test enabling a prompt on a prefix-less mounted server."""
|
|
main_app = FastMCP("MainApp")
|
|
sub_app = FastMCP("SubApp")
|
|
|
|
@sub_app.prompt
|
|
def my_prompt() -> str:
|
|
return "test prompt"
|
|
|
|
# Mount without prefix
|
|
main_app.mount(sub_app)
|
|
|
|
# Disable and re-enable
|
|
main_app.disable(names={"my_prompt"}, components={"prompt"})
|
|
# Verify prompt is now disabled
|
|
prompts = await main_app.list_prompts()
|
|
assert not any(p.name == "my_prompt" for p in prompts)
|
|
|
|
main_app.enable(names={"my_prompt"}, components={"prompt"})
|
|
# Verify prompt is now enabled
|
|
prompts = await main_app.list_prompts()
|
|
assert any(p.name == "my_prompt" for p in prompts)
|