Convert mounted servers to MountedProvider (#2635)

* Simplify Provider interface and consolidate docket registration

- Remove get_http_routes from Provider (unused)
- Remove ProviderLifespanConfig, _base_lifespan, _register_tasks
- Remove supports_tasks flag from Provider.__init__
- Consolidate all docket registration in server._docket_lifespan()
- Simplify lifespan() to take no parameters
- Move MountedProvider to separate module

* Fix control flow in ComponentService resource methods

* Move providers to server/providers

* Ensure MountedProvider get_* methods go through middleware

* Fix get_resource to only return concrete resources

Reverts template-checking in get_resource that broke task execution.
Tasks need access to the original template, not instantiated resources.

* Move prefix utilities into mounted.py, deprecate import_server

- Add resource prefix functions (add/remove/has_resource_prefix) to mounted.py
- Deprecate import_server with warning to use mount() instead
- Add tool_names uniqueness validation in MountedProvider

* Fix provider iteration order and remove dead _is_mounted flag

- Remove unused _is_mounted flag (MountedProvider.lifespan() calls _lifespan
  not _lifespan_manager, so the flag was never checked)
- Fix provider iteration: change reversed() to forward order in execution
  methods (_call_tool, _read_resource_middleware, _get_prompt_content_middleware)
  to match documented "first non-None wins" semantics
- Fix ComponentService to handle prefix-less mounted servers using
  _strip_tool_prefix()/_strip_resource_prefix() methods
- Update conflict resolution tests to expect first-registered provider wins
- Add regression tests for Docket behavior and prefix-less ComponentService

* Add TaskComponents type and exception handling for provider task registration

- Create TaskComponents dataclass with FunctionTool/FunctionResource/etc. types
  for proper typing of get_tasks() return value
- Add try/except wrapper around provider.get_tasks() in _docket_lifespan for
  consistent error handling (warn + continue or raise based on settings)
- Remove type: ignore comments from server.py task registration loop
This commit is contained in:
Jeremiah Lowin 2025-12-17 22:21:51 -05:00 committed by GitHub
commit ede8ff6703
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1228 additions and 993 deletions

View file

@ -12,10 +12,10 @@ This example demonstrates serving MCP tools from a database. Tools can be added,
```bash
# Reset the database (optional - tools.db is pre-seeded)
uv run examples/dynamic_tools_sqlite/setup_db.py
uv run examples/providers/sqlite/setup_db.py
# Run the server
uv run fastmcp run examples/dynamic_tools_sqlite/server.py
uv run fastmcp run examples/providers/sqlite/server.py
```
## How It Works
@ -50,10 +50,10 @@ While the server is running, you can modify tools in the database:
```bash
# Add a new tool
sqlite3 examples/dynamic_tools_sqlite/tools.db "INSERT INTO tools VALUES ('subtract_numbers', 'Subtract two numbers', '{\"type\":\"object\",\"properties\":{\"a\":{\"type\":\"number\"},\"b\":{\"type\":\"number\"}},\"required\":[\"a\",\"b\"]}', 'subtract', 0, 1)"
sqlite3 examples/providers/sqlite/tools.db "INSERT INTO tools VALUES ('subtract_numbers', 'Subtract two numbers', '{\"type\":\"object\",\"properties\":{\"a\":{\"type\":\"number\"},\"b\":{\"type\":\"number\"}},\"required\":[\"a\",\"b\"]}', 'subtract', 0, 1)"
# Disable a tool
sqlite3 examples/dynamic_tools_sqlite/tools.db "UPDATE tools SET enabled = 0 WHERE name = 'divide_numbers'"
sqlite3 examples/providers/sqlite/tools.db "UPDATE tools SET enabled = 0 WHERE name = 'divide_numbers'"
```
The next `list_tools` or `call_tool` request will reflect these changes.

View file

@ -20,8 +20,9 @@ from typing import Any
import aiosqlite
from rich import print
from fastmcp import Client, FastMCP, Provider
from fastmcp import Client, FastMCP
from fastmcp.server.context import Context
from fastmcp.server.providers import Provider
from fastmcp.tools.tool import Tool, ToolResult
DB_PATH = Path(__file__).parent / "tools.db"

View file

@ -4,7 +4,7 @@
"""
Creates and seeds the tools database.
Run with: uv run examples/dynamic_tools_sqlite/setup_db.py
Run with: uv run examples/providers/sqlite/setup_db.py
"""
import asyncio

View file

@ -14,7 +14,6 @@ if settings.log_enabled:
from fastmcp.server.server import FastMCP
from fastmcp.server.context import Context
from fastmcp.providers import Provider
import fastmcp.server
from fastmcp.client import Client
@ -32,6 +31,5 @@ __all__ = [
"Client",
"Context",
"FastMCP",
"Provider",
"settings",
]

View file

@ -7,7 +7,8 @@ from fastmcp.exceptions import NotFoundError
from fastmcp.prompts.prompt import Prompt
from fastmcp.resources.resource import Resource
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.server import FastMCP, has_resource_prefix, remove_resource_prefix
from fastmcp.server.providers import MountedProvider
from fastmcp.server.server import FastMCP
from fastmcp.tools.tool import Tool
from fastmcp.utilities.logging import get_logger
@ -40,16 +41,14 @@ class ComponentService:
tool.enable()
return tool
# 2. Check mounted servers using the filtered protocol path.
for mounted in reversed(self._server._mounted_servers):
if mounted.prefix:
if key.startswith(f"{mounted.prefix}_"):
tool_key = key.removeprefix(f"{mounted.prefix}_")
mounted_service = ComponentService(mounted.server)
tool = await mounted_service._enable_tool(tool_key)
# 2. Check mounted servers via MountedProvider
for provider in self._server._providers:
if isinstance(provider, MountedProvider):
unprefixed = provider._strip_tool_prefix(key)
if unprefixed is not None:
mounted_service = ComponentService(provider.server)
tool = await mounted_service._enable_tool(unprefixed)
return tool
else:
continue
raise NotFoundError(f"Unknown tool: {key}")
async def _disable_tool(self, key: str) -> Tool:
@ -69,16 +68,14 @@ class ComponentService:
tool.disable()
return tool
# 2. Check mounted servers using the filtered protocol path.
for mounted in reversed(self._server._mounted_servers):
if mounted.prefix:
if key.startswith(f"{mounted.prefix}_"):
tool_key = key.removeprefix(f"{mounted.prefix}_")
mounted_service = ComponentService(mounted.server)
tool = await mounted_service._disable_tool(tool_key)
# 2. Check mounted servers via MountedProvider
for provider in self._server._providers:
if isinstance(provider, MountedProvider):
unprefixed = provider._strip_tool_prefix(key)
if unprefixed is not None:
mounted_service = ComponentService(provider.server)
tool = await mounted_service._disable_tool(unprefixed)
return tool
else:
continue
raise NotFoundError(f"Unknown tool: {key}")
async def _enable_resource(self, key: str) -> Resource | ResourceTemplate:
@ -102,18 +99,16 @@ class ComponentService:
template.enable()
return template
# 2. Check mounted servers using the filtered protocol path.
for mounted in reversed(self._server._mounted_servers):
if mounted.prefix:
if has_resource_prefix(key, mounted.prefix):
key = remove_resource_prefix(key, mounted.prefix)
mounted_service = ComponentService(mounted.server)
# 2. Check mounted servers via MountedProvider
for provider in self._server._providers:
if isinstance(provider, MountedProvider):
unprefixed = provider._strip_resource_prefix(key)
if unprefixed is not None:
mounted_service = ComponentService(provider.server)
mounted_resource: (
Resource | ResourceTemplate
) = await mounted_service._enable_resource(key)
) = await mounted_service._enable_resource(unprefixed)
return mounted_resource
else:
continue
raise NotFoundError(f"Unknown resource: {key}")
async def _disable_resource(self, key: str) -> Resource | ResourceTemplate:
@ -137,18 +132,16 @@ class ComponentService:
template.disable()
return template
# 2. Check mounted servers using the filtered protocol path.
for mounted in reversed(self._server._mounted_servers):
if mounted.prefix:
if has_resource_prefix(key, mounted.prefix):
key = remove_resource_prefix(key, mounted.prefix)
mounted_service = ComponentService(mounted.server)
# 2. Check mounted servers via MountedProvider
for provider in self._server._providers:
if isinstance(provider, MountedProvider):
unprefixed = provider._strip_resource_prefix(key)
if unprefixed is not None:
mounted_service = ComponentService(provider.server)
mounted_resource: (
Resource | ResourceTemplate
) = await mounted_service._disable_resource(key)
) = await mounted_service._disable_resource(unprefixed)
return mounted_resource
else:
continue
raise NotFoundError(f"Unknown resource: {key}")
async def _enable_prompt(self, key: str) -> Prompt:
@ -168,16 +161,14 @@ class ComponentService:
prompt.enable()
return prompt
# 2. Check mounted servers using the filtered protocol path.
for mounted in reversed(self._server._mounted_servers):
if mounted.prefix:
if key.startswith(f"{mounted.prefix}_"):
prompt_key = key.removeprefix(f"{mounted.prefix}_")
mounted_service = ComponentService(mounted.server)
prompt = await mounted_service._enable_prompt(prompt_key)
# 2. Check mounted servers via MountedProvider
for provider in self._server._providers:
if isinstance(provider, MountedProvider):
unprefixed = provider._strip_tool_prefix(key)
if unprefixed is not None:
mounted_service = ComponentService(provider.server)
prompt = await mounted_service._enable_prompt(unprefixed)
return prompt
else:
continue
raise NotFoundError(f"Unknown prompt: {key}")
async def _disable_prompt(self, key: str) -> Prompt:
@ -196,14 +187,12 @@ class ComponentService:
prompt.disable()
return prompt
# 2. Check mounted servers using the filtered protocol path.
for mounted in reversed(self._server._mounted_servers):
if mounted.prefix:
if key.startswith(f"{mounted.prefix}_"):
prompt_key = key.removeprefix(f"{mounted.prefix}_")
mounted_service = ComponentService(mounted.server)
prompt = await mounted_service._disable_prompt(prompt_key)
# 2. Check mounted servers via MountedProvider
for provider in self._server._providers:
if isinstance(provider, MountedProvider):
unprefixed = provider._strip_tool_prefix(key)
if unprefixed is not None:
mounted_service = ComponentService(provider.server)
prompt = await mounted_service._disable_prompt(unprefixed)
return prompt
else:
continue
raise NotFoundError(f"Unknown prompt: {key}")

View file

@ -0,0 +1,36 @@
"""Providers for dynamic MCP components.
This module provides the `Provider` abstraction for providing tools,
resources, and prompts dynamically at runtime.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.providers import Provider
from fastmcp.tools import Tool
class DatabaseProvider(Provider):
def __init__(self, db_url: str):
self.db = Database(db_url)
async def list_tools(self) -> list[Tool]:
rows = await self.db.fetch("SELECT * FROM tools")
return [self._make_tool(row) for row in rows]
async def get_tool(self, name: str) -> Tool | None:
row = await self.db.fetchone("SELECT * FROM tools WHERE name = ?", name)
return self._make_tool(row) if row else None
mcp = FastMCP("Server", providers=[DatabaseProvider(db_url)])
```
"""
from fastmcp.server.providers.base import Components, Provider, TaskComponents
from fastmcp.server.providers.mounted import MountedProvider
__all__ = [
"Components",
"MountedProvider",
"Provider",
"TaskComponents",
]

View file

@ -1,23 +1,24 @@
"""Providers for dynamic MCP components.
"""Base Provider class for dynamic MCP components.
This module provides the `Provider` abstraction for providing tools,
resources, and prompts dynamically at runtime.
Example:
```python
from fastmcp import FastMCP, Provider
from fastmcp.server.context import Context
from fastmcp import FastMCP
from fastmcp.server.providers import Provider
from fastmcp.tools import Tool
class DatabaseProvider(Provider):
def __init__(self, db_url: str):
super().__init__()
self.db = Database(db_url)
async def list_tools(self, context: Context) -> list[Tool]:
async def list_tools(self) -> list[Tool]:
rows = await self.db.fetch("SELECT * FROM tools")
return [self._make_tool(row) for row in rows]
async def get_tool(self, context: Context, name: str) -> Tool | None:
async def get_tool(self, name: str) -> Tool | None:
row = await self.db.fetchone("SELECT * FROM tools WHERE name = ?", name)
return self._make_tool(row) if row else None
@ -27,7 +28,9 @@ Example:
from __future__ import annotations
from collections.abc import Sequence
from collections.abc import AsyncIterator, Sequence
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from fastmcp.prompts.prompt import Prompt, PromptResult
@ -36,11 +39,34 @@ from fastmcp.resources.template import ResourceTemplate
from fastmcp.tools.tool import Tool, ToolResult
if TYPE_CHECKING:
from fastmcp.server.context import Context
from fastmcp.prompts.prompt import FunctionPrompt
from fastmcp.resources.resource import FunctionResource
from fastmcp.resources.template import FunctionResourceTemplate
from fastmcp.tools.tool import FunctionTool
__all__ = [
"Provider",
]
@dataclass
class Components:
"""Collection of MCP components."""
tools: Sequence[Tool] = ()
resources: Sequence[Resource] = ()
templates: Sequence[ResourceTemplate] = ()
prompts: Sequence[Prompt] = ()
@dataclass
class TaskComponents:
"""Collection of function-based components eligible for background task execution.
Used by get_tasks() to return components for Docket registration.
All components have a `.fn` attribute pointing to the underlying callable.
"""
tools: Sequence[FunctionTool] = ()
resources: Sequence[FunctionResource] = ()
templates: Sequence[FunctionResourceTemplate] = ()
prompts: Sequence[FunctionPrompt] = ()
class Provider:
@ -50,9 +76,6 @@ class Provider:
return empty lists / None, so you only need to implement what your provider
supports.
All provider methods receive the FastMCP Context, giving access to
session info, logging, and other request-scoped capabilities.
Provider semantics:
- Return `None` from `get_*` methods to indicate "I don't have it" (search continues)
- Static components (registered via decorators) always take precedence over providers
@ -66,14 +89,14 @@ class Provider:
exceptions are wrapped with optional detail masking.
"""
async def list_tools(self, context: Context) -> Sequence[Tool]:
async def list_tools(self) -> Sequence[Tool]:
"""Return all available tools.
Override to provide tools dynamically.
"""
return []
async def get_tool(self, context: Context, name: str) -> Tool | None:
async def get_tool(self, name: str) -> Tool | None:
"""Get a specific tool by name.
Default implementation lists all tools and finds by name.
@ -82,11 +105,11 @@ class Provider:
Returns:
The Tool if found, or None to continue searching other providers.
"""
tools = await self.list_tools(context)
tools = await self.list_tools()
return next((t for t in tools if t.name == name), None)
async def call_tool(
self, context: Context, name: str, arguments: dict[str, Any]
self, name: str, arguments: dict[str, Any]
) -> ToolResult | None:
"""Execute a tool by name.
@ -96,19 +119,19 @@ class Provider:
Returns:
The ToolResult if found and executed, or None if tool not found.
"""
tool = await self.get_tool(context, name)
tool = await self.get_tool(name)
if tool is None:
return None
return await tool.run(arguments)
async def list_resources(self, context: Context) -> Sequence[Resource]:
async def list_resources(self) -> Sequence[Resource]:
"""Return all available resources.
Override to provide resources dynamically.
"""
return []
async def get_resource(self, context: Context, uri: str) -> Resource | None:
async def get_resource(self, uri: str) -> Resource | None:
"""Get a specific resource by URI.
Default implementation lists all resources and finds by URI.
@ -117,21 +140,17 @@ class Provider:
Returns:
The Resource if found, or None to continue searching other providers.
"""
resources = await self.list_resources(context)
resources = await self.list_resources()
return next((r for r in resources if str(r.uri) == uri), None)
async def list_resource_templates(
self, context: Context
) -> Sequence[ResourceTemplate]:
async def list_resource_templates(self) -> Sequence[ResourceTemplate]:
"""Return all available resource templates.
Override to provide resource templates dynamically.
"""
return []
async def get_resource_template(
self, context: Context, uri: str
) -> ResourceTemplate | None:
async def get_resource_template(self, uri: str) -> ResourceTemplate | None:
"""Get a resource template that matches the given URI.
Default implementation lists all templates and finds one whose pattern
@ -141,13 +160,13 @@ class Provider:
Returns:
The ResourceTemplate if a matching one is found, or None to continue searching.
"""
templates = await self.list_resource_templates(context)
templates = await self.list_resource_templates()
return next(
(t for t in templates if t.matches(uri) is not None),
None,
)
async def read_resource(self, context: Context, uri: str) -> ResourceContent | None:
async def read_resource(self, uri: str) -> ResourceContent | None:
"""Read a concrete resource by URI.
Default implementation gets the resource and reads it.
@ -159,14 +178,12 @@ class Provider:
Returns:
The ResourceContent if found and read, or None if not found.
"""
resource = await self.get_resource(context, uri)
resource = await self.get_resource(uri)
if resource is None:
return None
return await resource._read()
async def read_resource_template(
self, context: Context, uri: str
) -> ResourceContent | None:
async def read_resource_template(self, uri: str) -> ResourceContent | None:
"""Read a resource via a matching template.
Default implementation finds a matching template, creates a resource
@ -177,7 +194,7 @@ class Provider:
The ResourceContent if a matching template is found and read,
or None if no template matches.
"""
template = await self.get_resource_template(context, uri)
template = await self.get_resource_template(uri)
if template is None:
return None
params = template.matches(uri)
@ -186,14 +203,14 @@ class Provider:
resource = await template.create_resource(uri, params)
return await resource._read()
async def list_prompts(self, context: Context) -> Sequence[Prompt]:
async def list_prompts(self) -> Sequence[Prompt]:
"""Return all available prompts.
Override to provide prompts dynamically.
"""
return []
async def get_prompt(self, context: Context, name: str) -> Prompt | None:
async def get_prompt(self, name: str) -> Prompt | None:
"""Get a specific prompt by name.
Default implementation lists all prompts and finds by name.
@ -202,11 +219,11 @@ class Provider:
Returns:
The Prompt if found, or None to continue searching other providers.
"""
prompts = await self.list_prompts(context)
prompts = await self.list_prompts()
return next((p for p in prompts if p.name == name), None)
async def render_prompt(
self, context: Context, name: str, arguments: dict[str, Any] | None
self, name: str, arguments: dict[str, Any] | None
) -> PromptResult | None:
"""Render a prompt by name.
@ -216,7 +233,84 @@ class Provider:
Returns:
The PromptResult if found and rendered, or None if not found.
"""
prompt = await self.get_prompt(context, name)
prompt = await self.get_prompt(name)
if prompt is None:
return None
return await prompt._render(arguments)
# -------------------------------------------------------------------------
# Task registration
# -------------------------------------------------------------------------
async def get_tasks(self) -> TaskComponents:
"""Return components that should be registered as background tasks.
Override to customize which components are task-eligible.
Default calls list_* methods and filters for function-based components
with task_config.mode != 'forbidden'.
Used by the server during startup to register functions with Docket.
"""
from fastmcp.prompts.prompt import FunctionPrompt
from fastmcp.resources.resource import FunctionResource
from fastmcp.resources.template import FunctionResourceTemplate
from fastmcp.tools.tool import FunctionTool
all_tools = await self.list_tools()
all_resources = await self.list_resources()
all_templates = await self.list_resource_templates()
all_prompts = await self.list_prompts()
return TaskComponents(
tools=[
t
for t in all_tools
if isinstance(t, FunctionTool) and t.task_config.mode != "forbidden"
],
resources=[
r
for r in all_resources
if isinstance(r, FunctionResource) and r.task_config.mode != "forbidden"
],
templates=[
t
for t in all_templates
if isinstance(t, FunctionResourceTemplate)
and t.task_config.mode != "forbidden"
],
prompts=[
p
for p in all_prompts
if isinstance(p, FunctionPrompt) and p.task_config.mode != "forbidden"
],
)
# -------------------------------------------------------------------------
# Lifecycle methods
# -------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(self) -> AsyncIterator[None]:
"""User-overridable lifespan for custom setup and teardown.
Override this method to perform provider-specific initialization
like opening database connections, setting up external resources,
or other state management needed for the provider's lifetime.
The lifespan scope matches the server's lifespan - code before yield
runs at startup, code after yield runs at shutdown.
Example:
```python
@asynccontextmanager
async def lifespan(self):
# Setup
self.db = await connect_database()
try:
yield
finally:
# Teardown
await self.db.close()
```
"""
yield

View file

@ -0,0 +1,407 @@
"""MountedProvider for wrapping mounted FastMCP servers.
This module provides the `MountedProvider` class that enables mounting
one FastMCP server onto another, exposing the mounted server's tools,
resources, and prompts through the parent server with optional prefixing.
"""
from __future__ import annotations
import re
from collections.abc import AsyncIterator, Sequence
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any
from fastmcp.exceptions import NotFoundError
from fastmcp.prompts.prompt import Prompt, PromptResult
from fastmcp.resources.resource import Resource, ResourceContent
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.providers.base import Provider, TaskComponents
from fastmcp.tools.tool import Tool, ToolResult
if TYPE_CHECKING:
from fastmcp.prompts.prompt import FunctionPrompt
from fastmcp.resources.resource import FunctionResource
from fastmcp.resources.template import FunctionResourceTemplate
from fastmcp.server.server import FastMCP
from fastmcp.tools.tool import FunctionTool
# Pattern for matching URIs: protocol://path
_URI_PATTERN = re.compile(r"^([^:]+://)(.*?)$")
def add_resource_prefix(uri: str, prefix: str) -> str:
"""Add a prefix to a resource URI using path formatting (resource://prefix/path)."""
if not prefix:
return uri
match = _URI_PATTERN.match(uri)
if not match:
raise ValueError(f"Invalid URI format: {uri}. Expected protocol://path format.")
protocol, path = match.groups()
return f"{protocol}{prefix}/{path}"
def remove_resource_prefix(uri: str, prefix: str) -> str:
"""Remove a prefix from a resource URI."""
if not prefix:
return uri
match = _URI_PATTERN.match(uri)
if not match:
raise ValueError(f"Invalid URI format: {uri}. Expected protocol://path format.")
protocol, path = match.groups()
prefix_pattern = f"^{re.escape(prefix)}/(.*?)$"
path_match = re.match(prefix_pattern, path)
if not path_match:
return uri
return f"{protocol}{path_match.group(1)}"
def has_resource_prefix(uri: str, prefix: str) -> bool:
"""Check if a resource URI has a specific prefix."""
if not prefix:
return False
match = _URI_PATTERN.match(uri)
if not match:
raise ValueError(f"Invalid URI format: {uri}. Expected protocol://path format.")
_, path = match.groups()
prefix_pattern = f"^{re.escape(prefix)}/"
return bool(re.match(prefix_pattern, path))
class MountedProvider(Provider):
"""Provider that wraps a mounted FastMCP server.
This provider enables mounting one FastMCP server onto another, exposing
the mounted server's tools, resources, and prompts through the parent
server with optional prefixing.
The key benefit is that execution methods (`call_tool`, `read_resource`,
`render_prompt`) invoke the mounted server's middleware chain, enabling
full participation in the provider abstraction.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.providers import MountedProvider
main = FastMCP("Main")
sub = FastMCP("Sub")
@sub.tool
def greet(name: str) -> str:
return f"Hello, {name}!"
# Mount with prefix - tool accessible as "sub_greet"
main.add_provider(MountedProvider(sub, prefix="sub"))
```
Note:
Normally you would use `FastMCP.mount()` which handles proxy conversion
and creates the MountedProvider internally.
"""
def __init__(
self,
server: FastMCP[Any],
prefix: str | None = None,
tool_names: dict[str, str] | None = None,
):
"""Initialize a MountedProvider.
Args:
server: The FastMCP server to mount.
prefix: Optional prefix for tool/prompt names and resource URIs.
Tools and prompts use underscore separator: "prefix_name".
Resources use path-style: "protocol://prefix/path".
tool_names: Optional mapping of original tool names to custom names.
Overrides the default prefixed names for specific tools.
"""
super().__init__()
self.server = server
self.prefix = prefix
self.tool_names = tool_names or {}
if len(self.tool_names) != len(set(self.tool_names.values())):
raise ValueError("tool_names values must be unique")
self._reverse_tool_names = {v: k for k, v in self.tool_names.items()}
# -------------------------------------------------------------------------
# Helper methods for prefix handling
# -------------------------------------------------------------------------
def _add_tool_prefix(self, name: str) -> str:
"""Add prefix to a tool or prompt name."""
if self.prefix:
return f"{self.prefix}_{name}"
return name
def _strip_tool_prefix(self, name: str) -> str | None:
"""Strip prefix from a tool or prompt name.
Returns:
The unprefixed name if the name matches this provider's pattern,
or None if it doesn't match (indicating another provider should handle it).
"""
# Check for tool_names override first
if name in self._reverse_tool_names:
return self._reverse_tool_names[name]
# Check prefix pattern
if self.prefix:
expected_prefix = f"{self.prefix}_"
if name.startswith(expected_prefix):
return name[len(expected_prefix) :]
return None # Doesn't match this provider
# No prefix means we always match
return name
def _add_resource_prefix(self, uri: str) -> str:
"""Add prefix to a resource URI."""
if not self.prefix:
return uri
return add_resource_prefix(uri, self.prefix)
def _strip_resource_prefix(self, uri: str) -> str | None:
"""Strip prefix from a resource URI.
Returns:
The unprefixed URI if it matches this provider's pattern,
or None if it doesn't match.
"""
if not self.prefix:
return uri
if not has_resource_prefix(uri, self.prefix):
return None
return remove_resource_prefix(uri, self.prefix)
# -------------------------------------------------------------------------
# Prefix helper methods for components
# -------------------------------------------------------------------------
def _prefix_tool(self, tool: Tool) -> Tool:
"""Apply prefix to a tool."""
if self.tool_names and tool.name in self.tool_names:
new_key = self.tool_names[tool.name]
else:
new_key = self._add_tool_prefix(tool.key)
return tool.model_copy(key=new_key) if new_key != tool.key else tool
def _prefix_resource(self, resource: Resource) -> Resource:
"""Apply prefix to a resource."""
new_key = self._add_resource_prefix(resource.key)
update: dict[str, Any] = {}
if self.prefix and resource.name:
update["name"] = f"{self.prefix}_{resource.name}"
if new_key != resource.key or update:
return resource.model_copy(key=new_key, update=update)
return resource
def _prefix_template(self, template: ResourceTemplate) -> ResourceTemplate:
"""Apply prefix to a resource template."""
new_key = self._add_resource_prefix(template.key)
update: dict[str, Any] = {}
if self.prefix and template.name:
update["name"] = f"{self.prefix}_{template.name}"
if self.prefix and template.uri_template:
update["uri_template"] = self._add_resource_prefix(template.uri_template)
if new_key != template.key or update:
return template.model_copy(key=new_key, update=update)
return template
def _prefix_prompt(self, prompt: Prompt) -> Prompt:
"""Apply prefix to a prompt."""
new_key = self._add_tool_prefix(prompt.key)
return prompt.model_copy(key=new_key) if new_key != prompt.key else prompt
# -------------------------------------------------------------------------
# Tool methods
# -------------------------------------------------------------------------
async def list_tools(self) -> Sequence[Tool]:
"""List all tools from the mounted server with prefixes applied."""
tools = await self.server._list_tools_middleware()
return [self._prefix_tool(tool) for tool in tools]
async def get_tool(self, name: str) -> Tool | None:
"""Get a tool by name, going through middleware."""
# Early exit if name doesn't match our prefix pattern
if self._strip_tool_prefix(name) is None:
return None
tools = await self.list_tools()
return next((t for t in tools if t.key == name), None)
async def call_tool(
self, name: str, arguments: dict[str, Any]
) -> ToolResult | None:
"""Execute a tool through the mounted server's middleware chain."""
unprefixed = self._strip_tool_prefix(name)
if unprefixed is None:
return None # Doesn't match this provider
return await self.server._call_tool_middleware(unprefixed, arguments)
# -------------------------------------------------------------------------
# Resource methods
# -------------------------------------------------------------------------
async def list_resources(self) -> Sequence[Resource]:
"""List all resources from the mounted server with prefixes applied."""
resources = await self.server._list_resources_middleware()
return [self._prefix_resource(resource) for resource in resources]
async def get_resource(self, uri: str) -> Resource | None:
"""Get a concrete resource by URI, going through middleware.
Only returns concrete resources, not resources created from templates.
This preserves the original template for task execution.
"""
# Early exit if URI doesn't match our prefix pattern
if self._strip_resource_prefix(uri) is None:
return None
# Only check concrete resources (not templates)
resources = await self.list_resources()
return next((r for r in resources if r.key == uri), None)
async def read_resource(self, uri: str) -> ResourceContent | None:
"""Read a resource through the mounted server's middleware chain."""
unprefixed = self._strip_resource_prefix(uri)
if unprefixed is None:
return None # Doesn't match this provider
try:
contents = await self.server._read_resource_middleware(unprefixed)
return contents[0] if contents else None
except NotFoundError:
return None
# -------------------------------------------------------------------------
# Resource template methods
# -------------------------------------------------------------------------
async def list_resource_templates(self) -> Sequence[ResourceTemplate]:
"""List all resource templates from the mounted server with prefixes applied."""
templates = await self.server._list_resource_templates_middleware()
return [self._prefix_template(template) for template in templates]
async def get_resource_template(self, uri: str) -> ResourceTemplate | None:
"""Get a resource template that matches the given URI."""
# For templates, we need to check if any template matches the prefixed URI
unprefixed = self._strip_resource_prefix(uri)
if unprefixed is None:
return None
# Use middleware to include templates from nested mounted providers
templates = await self.server._list_resource_templates_middleware()
for template in templates:
if template.matches(unprefixed) is not None:
return self._prefix_template(template)
return None
async def read_resource_template(self, uri: str) -> ResourceContent | None:
"""Read a resource via a matching template through the mounted server."""
# This is handled by read_resource since the server's middleware handles templates
return await self.read_resource(uri)
# -------------------------------------------------------------------------
# Prompt methods
# -------------------------------------------------------------------------
async def list_prompts(self) -> Sequence[Prompt]:
"""List all prompts from the mounted server with prefixes applied."""
prompts = await self.server._list_prompts_middleware()
return [self._prefix_prompt(prompt) for prompt in prompts]
async def get_prompt(self, name: str) -> Prompt | None:
"""Get a prompt by name, going through middleware."""
# Early exit if name doesn't match our prefix pattern
if self._strip_tool_prefix(name) is None:
return None
prompts = await self.list_prompts()
return next((p for p in prompts if p.key == name), None)
async def render_prompt(
self, name: str, arguments: dict[str, Any] | None
) -> PromptResult | None:
"""Render a prompt through the mounted server's middleware chain."""
unprefixed = self._strip_tool_prefix(name)
if unprefixed is None:
return None # Doesn't match this provider
return await self.server._get_prompt_content_middleware(unprefixed, arguments)
# -------------------------------------------------------------------------
# Task registration
# -------------------------------------------------------------------------
async def get_tasks(self) -> TaskComponents:
"""Return task-eligible components, bypassing middleware and applying prefixes.
This override accesses the wrapped server's managers directly to avoid
triggering middleware during registration. It also recursively collects
tasks from nested providers.
"""
from fastmcp.prompts.prompt import FunctionPrompt
from fastmcp.resources.resource import FunctionResource
from fastmcp.resources.template import FunctionResourceTemplate
from fastmcp.tools.tool import FunctionTool
tools: list[FunctionTool] = []
resources: list[FunctionResource] = []
templates: list[FunctionResourceTemplate] = []
prompts: list[FunctionPrompt] = []
# Direct manager access (bypasses middleware)
for tool in self.server._tool_manager._tools.values():
if isinstance(tool, FunctionTool) and tool.task_config.mode != "forbidden":
tools.append(self._prefix_tool(tool)) # type: ignore[arg-type]
for resource in self.server._resource_manager._resources.values():
if (
isinstance(resource, FunctionResource)
and resource.task_config.mode != "forbidden"
):
resources.append(self._prefix_resource(resource)) # type: ignore[arg-type]
for template in self.server._resource_manager._templates.values():
if (
isinstance(template, FunctionResourceTemplate)
and template.task_config.mode != "forbidden"
):
templates.append(self._prefix_template(template)) # type: ignore[arg-type]
for prompt in self.server._prompt_manager._prompts.values():
if (
isinstance(prompt, FunctionPrompt)
and prompt.task_config.mode != "forbidden"
):
prompts.append(self._prefix_prompt(prompt)) # type: ignore[arg-type]
# Recursively get tasks from nested providers and apply our prefix
for provider in self.server._providers:
nested = await provider.get_tasks()
tools.extend(self._prefix_tool(t) for t in nested.tools) # type: ignore[arg-type]
resources.extend(self._prefix_resource(r) for r in nested.resources) # type: ignore[arg-type]
templates.extend(self._prefix_template(t) for t in nested.templates) # type: ignore[arg-type]
prompts.extend(self._prefix_prompt(p) for p in nested.prompts) # type: ignore[arg-type]
return TaskComponents(
tools=tools, resources=resources, templates=templates, prompts=prompts
)
# -------------------------------------------------------------------------
# Lifecycle methods
# -------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(self) -> AsyncIterator[None]:
"""Start the mounted server's user lifespan.
This starts only the wrapped server's user-defined lifespan, NOT its
full _lifespan_manager() (which includes Docket). The parent server's
Docket handles all background tasks.
"""
# Start the wrapped server's user lifespan only
# We pass the server instance to the user's lifespan function
async with self.server._lifespan(self.server):
yield

File diff suppressed because it is too large Load diff

View file

@ -1092,7 +1092,7 @@ class TestInferTransport:
transport = infer_transport(config)
assert isinstance(transport, MCPConfigTransport)
assert isinstance(transport.transport, FastMCPTransport)
assert len(cast(FastMCP, transport.transport.server)._mounted_servers) == 2
assert len(cast(FastMCP, transport.transport.server)._providers) == 2
def test_infer_fastmcp_server(self, fastmcp_server):
"""FastMCP server instances should infer to FastMCPTransport."""

View file

@ -7,6 +7,7 @@ import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.client.transports import FastMCPTransport, SSETransport
from fastmcp.server.providers import MountedProvider
from fastmcp.server.proxy import FastMCPProxy
from fastmcp.tools.tool import Tool
from fastmcp.tools.tool_transform import TransformedTool
@ -308,29 +309,27 @@ class TestMultipleServerMount:
prompt_names = [prompt.name for prompt in prompts]
assert "working_working_prompt" in prompt_names
# Verify that warnings were logged for the unreachable server
warning_messages = [
record.message for record in caplog.records if record.levelname == "WARNING"
# Verify that errors were logged for the unreachable provider
error_messages = [
record.message for record in caplog.records if record.levelname == "ERROR"
]
assert any("Error listing tools from provider" in msg for msg in error_messages)
assert any(
"Failed to list tools from mounted server 'unreachable_proxy'" in msg
for msg in warning_messages
"Error listing resources from provider" in msg for msg in error_messages
)
assert any(
"Failed to list resources from 'unreachable_proxy'" in msg
for msg in warning_messages
)
assert any(
"Failed to list prompts from mounted server 'unreachable_proxy'" in msg
for msg in warning_messages
"Error listing prompts from provider" in msg for msg in error_messages
)
class TestPrefixConflictResolution:
"""Test that later mounted servers win when there are conflicts."""
"""Test that first registered provider wins when there are conflicts.
async def test_later_server_wins_tools_no_prefix(self):
"""Test that later mounted server wins for tools when no prefix is used."""
Provider semantics: 'Providers are queried in registration order; first non-None wins'
"""
async def test_first_server_wins_tools_no_prefix(self):
"""Test that first mounted server wins for tools when no prefix is used."""
main_app = FastMCP("MainApp")
first_app = FastMCP("FirstApp")
second_app = FastMCP("SecondApp")
@ -348,18 +347,18 @@ class TestPrefixConflictResolution:
main_app.mount(second_app)
async with Client(main_app) as client:
# Test that list_tools shows the tool from later server
# Test that list_tools shows the tool
tools = await client.list_tools()
tool_names = [t.name for t in tools]
assert "shared_tool" in tool_names
assert tool_names.count("shared_tool") == 1 # Should only appear once
# Test that calling the tool uses the later server's implementation
# Test that calling the tool uses the first server's implementation
result = await client.call_tool("shared_tool", {})
assert result.data == "Second app tool"
assert result.data == "First app tool"
async def test_later_server_wins_tools_same_prefix(self):
"""Test that later mounted server wins for tools when same prefix is used."""
async def test_first_server_wins_tools_same_prefix(self):
"""Test that first mounted server wins for tools when same prefix is used."""
main_app = FastMCP("MainApp")
first_app = FastMCP("FirstApp")
second_app = FastMCP("SecondApp")
@ -377,18 +376,18 @@ class TestPrefixConflictResolution:
main_app.mount(second_app, "api")
async with Client(main_app) as client:
# Test that list_tools shows the tool from later server
# Test that list_tools shows the tool
tools = await client.list_tools()
tool_names = [t.name for t in tools]
assert "api_shared_tool" in tool_names
assert tool_names.count("api_shared_tool") == 1 # Should only appear once
# Test that calling the tool uses the later server's implementation
# Test that calling the tool uses the first server's implementation
result = await client.call_tool("api_shared_tool", {})
assert result.data == "Second app tool"
assert result.data == "First app tool"
async def test_later_server_wins_resources_no_prefix(self):
"""Test that later mounted server wins for resources when no prefix is used."""
async def test_first_server_wins_resources_no_prefix(self):
"""Test that first mounted server wins for resources when no prefix is used."""
main_app = FastMCP("MainApp")
first_app = FastMCP("FirstApp")
second_app = FastMCP("SecondApp")
@ -406,18 +405,18 @@ class TestPrefixConflictResolution:
main_app.mount(second_app)
async with Client(main_app) as client:
# Test that list_resources shows the resource from later server
# Test that list_resources shows the resource
resources = await client.list_resources()
resource_uris = [str(r.uri) for r in resources]
assert "shared://data" in resource_uris
assert resource_uris.count("shared://data") == 1 # Should only appear once
# Test that reading the resource uses the later server's implementation
# Test that reading the resource uses the first server's implementation
result = await client.read_resource("shared://data")
assert result[0].text == "Second app data" # type: ignore[attr-defined]
assert result[0].text == "First app data" # type: ignore[attr-defined]
async def test_later_server_wins_resources_same_prefix(self):
"""Test that later mounted server wins for resources when same prefix is used."""
async def test_first_server_wins_resources_same_prefix(self):
"""Test that first mounted server wins for resources when same prefix is used."""
main_app = FastMCP("MainApp")
first_app = FastMCP("FirstApp")
second_app = FastMCP("SecondApp")
@ -435,7 +434,7 @@ class TestPrefixConflictResolution:
main_app.mount(second_app, "api")
async with Client(main_app) as client:
# Test that list_resources shows the resource from later server
# Test that list_resources shows the resource
resources = await client.list_resources()
resource_uris = [str(r.uri) for r in resources]
assert "shared://api/data" in resource_uris
@ -443,12 +442,12 @@ class TestPrefixConflictResolution:
resource_uris.count("shared://api/data") == 1
) # Should only appear once
# Test that reading the resource uses the later server's implementation
# Test that reading the resource uses the first server's implementation
result = await client.read_resource("shared://api/data")
assert result[0].text == "Second app data" # type: ignore[attr-defined]
assert result[0].text == "First app data" # type: ignore[attr-defined]
async def test_later_server_wins_resource_templates_no_prefix(self):
"""Test that later mounted server wins for resource templates when no prefix is used."""
async def test_first_server_wins_resource_templates_no_prefix(self):
"""Test that first mounted server wins for resource templates when no prefix is used."""
main_app = FastMCP("MainApp")
first_app = FastMCP("FirstApp")
second_app = FastMCP("SecondApp")
@ -466,7 +465,7 @@ class TestPrefixConflictResolution:
main_app.mount(second_app)
async with Client(main_app) as client:
# Test that list_resource_templates shows the template from later server
# Test that list_resource_templates shows the template
templates = await client.list_resource_templates()
template_uris = [t.uriTemplate for t in templates]
assert "users://{user_id}/profile" in template_uris
@ -474,12 +473,12 @@ class TestPrefixConflictResolution:
template_uris.count("users://{user_id}/profile") == 1
) # Should only appear once
# Test that reading the resource uses the later server's implementation
# Test that reading the resource uses the first server's implementation
result = await client.read_resource("users://123/profile")
assert result[0].text == "Second app user 123" # type: ignore[attr-defined]
assert result[0].text == "First app user 123" # type: ignore[attr-defined]
async def test_later_server_wins_resource_templates_same_prefix(self):
"""Test that later mounted server wins for resource templates when same prefix is used."""
async def test_first_server_wins_resource_templates_same_prefix(self):
"""Test that first mounted server wins for resource templates when same prefix is used."""
main_app = FastMCP("MainApp")
first_app = FastMCP("FirstApp")
second_app = FastMCP("SecondApp")
@ -497,7 +496,7 @@ class TestPrefixConflictResolution:
main_app.mount(second_app, "api")
async with Client(main_app) as client:
# Test that list_resource_templates shows the template from later server
# Test that list_resource_templates shows the template
templates = await client.list_resource_templates()
template_uris = [t.uriTemplate for t in templates]
assert "users://api/{user_id}/profile" in template_uris
@ -505,12 +504,12 @@ class TestPrefixConflictResolution:
template_uris.count("users://api/{user_id}/profile") == 1
) # Should only appear once
# Test that reading the resource uses the later server's implementation
# Test that reading the resource uses the first server's implementation
result = await client.read_resource("users://api/123/profile")
assert result[0].text == "Second app user 123" # type: ignore[attr-defined]
assert result[0].text == "First app user 123" # type: ignore[attr-defined]
async def test_later_server_wins_prompts_no_prefix(self):
"""Test that later mounted server wins for prompts when no prefix is used."""
async def test_first_server_wins_prompts_no_prefix(self):
"""Test that first mounted server wins for prompts when no prefix is used."""
main_app = FastMCP("MainApp")
first_app = FastMCP("FirstApp")
second_app = FastMCP("SecondApp")
@ -528,19 +527,19 @@ class TestPrefixConflictResolution:
main_app.mount(second_app)
async with Client(main_app) as client:
# Test that list_prompts shows the prompt from later server
# Test that list_prompts shows the prompt
prompts = await client.list_prompts()
prompt_names = [p.name for p in prompts]
assert "shared_prompt" in prompt_names
assert prompt_names.count("shared_prompt") == 1 # Should only appear once
# Test that getting the prompt uses the later server's implementation
# Test that getting the prompt uses the first server's implementation
result = await client.get_prompt("shared_prompt", {})
assert result.messages is not None
assert result.messages[0].content.text == "Second app prompt" # type: ignore[attr-defined]
assert result.messages[0].content.text == "First app prompt" # type: ignore[attr-defined]
async def test_later_server_wins_prompts_same_prefix(self):
"""Test that later mounted server wins for prompts when same prefix is used."""
async def test_first_server_wins_prompts_same_prefix(self):
"""Test that first mounted server wins for prompts when same prefix is used."""
main_app = FastMCP("MainApp")
first_app = FastMCP("FirstApp")
second_app = FastMCP("SecondApp")
@ -558,7 +557,7 @@ class TestPrefixConflictResolution:
main_app.mount(second_app, "api")
async with Client(main_app) as client:
# Test that list_prompts shows the prompt from later server
# Test that list_prompts shows the prompt
prompts = await client.list_prompts()
prompt_names = [p.name for p in prompts]
assert "api_shared_prompt" in prompt_names
@ -566,10 +565,10 @@ class TestPrefixConflictResolution:
prompt_names.count("api_shared_prompt") == 1
) # Should only appear once
# Test that getting the prompt uses the later server's implementation
# Test that getting the prompt uses the first server's implementation
result = await client.get_prompt("api_shared_prompt", {})
assert result.messages is not None
assert result.messages[0].content.text == "Second app prompt" # type: ignore[attr-defined]
assert result.messages[0].content.text == "First app prompt" # type: ignore[attr-defined]
class TestDynamicChanges:
@ -852,7 +851,9 @@ class TestAsProxyKwarg:
sub = FastMCP("Sub")
mcp.mount(sub, "sub")
assert mcp._mounted_servers[0].server is sub
provider = mcp._providers[0]
assert isinstance(provider, MountedProvider)
assert provider.server is sub
async def test_as_proxy_false(self):
mcp = FastMCP("Main")
@ -860,7 +861,9 @@ class TestAsProxyKwarg:
mcp.mount(sub, "sub", as_proxy=False)
assert mcp._mounted_servers[0].server is sub
provider = mcp._providers[0]
assert isinstance(provider, MountedProvider)
assert provider.server is sub
async def test_as_proxy_true(self):
mcp = FastMCP("Main")
@ -868,11 +871,17 @@ class TestAsProxyKwarg:
mcp.mount(sub, "sub", as_proxy=True)
assert mcp._mounted_servers[0].server is not sub
assert isinstance(mcp._mounted_servers[0].server, FastMCPProxy)
provider = mcp._providers[0]
assert isinstance(provider, MountedProvider)
assert provider.server is not sub
assert isinstance(provider.server, FastMCPProxy)
async def test_as_proxy_defaults_true_if_lifespan(self):
"""Test that as_proxy defaults to True when server_lifespan is provided."""
async def test_lifespan_server_mounted_directly(self):
"""Test that servers with lifespan are mounted directly (not auto-proxied).
Since MountedProvider now handles lifespan via the provider lifespan interface,
there's no need to auto-convert to a proxy. The server is mounted directly.
"""
@asynccontextmanager
async def server_lifespan(mcp: FastMCP):
@ -883,9 +892,10 @@ class TestAsProxyKwarg:
mcp.mount(sub, "sub")
# Should auto-proxy because lifespan is set
assert mcp._mounted_servers[0].server is not sub
assert isinstance(mcp._mounted_servers[0].server, FastMCPProxy)
# Server should be mounted directly without auto-proxying
provider = mcp._providers[0]
assert isinstance(provider, MountedProvider)
assert provider.server is sub
async def test_as_proxy_ignored_for_proxy_mounts_default(self):
mcp = FastMCP("Main")
@ -894,7 +904,9 @@ class TestAsProxyKwarg:
mcp.mount(sub_proxy, "sub")
assert mcp._mounted_servers[0].server is sub_proxy
provider = mcp._providers[0]
assert isinstance(provider, MountedProvider)
assert provider.server is sub_proxy
async def test_as_proxy_ignored_for_proxy_mounts_false(self):
mcp = FastMCP("Main")
@ -903,7 +915,9 @@ class TestAsProxyKwarg:
mcp.mount(sub_proxy, "sub", as_proxy=False)
assert mcp._mounted_servers[0].server is sub_proxy
provider = mcp._providers[0]
assert isinstance(provider, MountedProvider)
assert provider.server is sub_proxy
async def test_as_proxy_ignored_for_proxy_mounts_true(self):
mcp = FastMCP("Main")
@ -912,7 +926,9 @@ class TestAsProxyKwarg:
mcp.mount(sub_proxy, "sub", as_proxy=True)
assert mcp._mounted_servers[0].server is sub_proxy
provider = mcp._providers[0]
assert isinstance(provider, MountedProvider)
assert provider.server is sub_proxy
async def test_as_proxy_mounts_still_have_live_link(self):
mcp = FastMCP("Main")
@ -1124,81 +1140,30 @@ class TestCustomRouteForwarding:
assert len(routes) == 1
assert routes[0].path == "/test" # type: ignore[attr-defined]
async def test_get_additional_http_routes_with_mounted_server(self):
"""Test _get_additional_http_routes includes routes from mounted servers."""
main_server = FastMCP("MainServer")
sub_server = FastMCP("SubServer")
@sub_server.custom_route("/sub-route", methods=["GET"])
async def sub_route(request):
from starlette.responses import JSONResponse
return JSONResponse({"message": "from sub"})
# Mount the sub server
main_server.mount(sub_server, "sub")
routes = main_server._get_additional_http_routes()
assert len(routes) == 1
assert routes[0].path == "/sub-route" # type: ignore[attr-defined]
async def test_get_additional_http_routes_recursive(self):
"""Test _get_additional_http_routes works recursively with nested mounts."""
main_server = FastMCP("MainServer")
sub_server = FastMCP("SubServer")
nested_server = FastMCP("NestedServer")
@main_server.custom_route("/main-route", methods=["GET"])
async def main_route(request):
from starlette.responses import JSONResponse
return JSONResponse({"message": "from main"})
@sub_server.custom_route("/sub-route", methods=["GET"])
async def sub_route(request):
from starlette.responses import JSONResponse
return JSONResponse({"message": "from sub"})
@nested_server.custom_route("/nested-route", methods=["GET"])
async def nested_route(request):
from starlette.responses import JSONResponse
return JSONResponse({"message": "from nested"})
# Create nested mounting: main -> sub -> nested
sub_server.mount(nested_server, "nested")
main_server.mount(sub_server, "sub")
routes = main_server._get_additional_http_routes()
# Should include all routes
assert len(routes) == 3
route_paths = [route.path for route in routes] # type: ignore[attr-defined]
assert "/main-route" in route_paths
assert "/sub-route" in route_paths
assert "/nested-route" in route_paths
async def test_mounted_servers_tracking(self):
"""Test that _mounted_servers list tracks mounted servers correctly."""
"""Test that _providers list tracks mounted servers correctly."""
main_server = FastMCP("MainServer")
sub_server1 = FastMCP("SubServer1")
sub_server2 = FastMCP("SubServer2")
# Initially no mounted servers
assert len(main_server._mounted_servers) == 0
# Initially no providers
assert len(main_server._providers) == 0
# Mount first server
main_server.mount(sub_server1, "sub1")
assert len(main_server._mounted_servers) == 1
assert main_server._mounted_servers[0].server == sub_server1
assert main_server._mounted_servers[0].prefix == "sub1"
assert len(main_server._providers) == 1
provider1 = main_server._providers[0]
assert isinstance(provider1, MountedProvider)
assert provider1.server == sub_server1
assert provider1.prefix == "sub1"
# Mount second server
main_server.mount(sub_server2, "sub2")
assert len(main_server._mounted_servers) == 2
assert main_server._mounted_servers[1].server == sub_server2
assert main_server._mounted_servers[1].prefix == "sub2"
assert len(main_server._providers) == 2
provider2 = main_server._providers[1]
assert isinstance(provider2, MountedProvider)
assert provider2.server == sub_server2
assert provider2.prefix == "sub2"
async def test_multiple_routes_same_server(self):
"""Test that multiple custom routes from same server are all included."""
@ -1424,3 +1389,117 @@ class TestToolNameOverrides:
async with Client(main) as client:
result = await client.call_tool("renamed", {})
assert result.data == "success"
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")
@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)
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 ComponentService works with prefix-less mounted servers."""
async def test_enable_tool_prefixless_mount(self):
"""Test enabling a tool on a prefix-less mounted server."""
from fastmcp.contrib.component_manager.component_service import ComponentService
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.get_tools()
assert "my_tool" in tools
assert tools["my_tool"].enabled
# Disable and re-enable via ComponentService
service = ComponentService(main_app)
tool = await service._disable_tool("my_tool")
assert not tool.enabled
tool = await service._enable_tool("my_tool")
assert tool.enabled
async def test_enable_resource_prefixless_mount(self):
"""Test enabling a resource on a prefix-less mounted server."""
from fastmcp.contrib.component_manager.component_service import ComponentService
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 via ComponentService
service = ComponentService(main_app)
resource = await service._disable_resource("data://test")
assert not resource.enabled
resource = await service._enable_resource("data://test")
assert resource.enabled
async def test_enable_prompt_prefixless_mount(self):
"""Test enabling a prompt on a prefix-less mounted server."""
from fastmcp.contrib.component_manager.component_service import ComponentService
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 via ComponentService
service = ComponentService(main_app)
prompt = await service._disable_prompt("my_prompt")
assert not prompt.enabled
prompt = await service._enable_prompt("my_prompt")
assert prompt.enabled

View file

@ -7,13 +7,13 @@ import pytest
from mcp.types import AnyUrl, PromptMessage, TextContent
from mcp.types import Tool as MCPTool
from fastmcp import FastMCP, Provider
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.client.client import CallToolResult
from fastmcp.prompts.prompt import FunctionPrompt, Prompt, PromptResult
from fastmcp.resources.resource import FunctionResource, Resource, ResourceContent
from fastmcp.resources.template import FunctionResourceTemplate, ResourceTemplate
from fastmcp.server.context import Context
from fastmcp.server.providers import Provider
from fastmcp.tools.tool import Tool, ToolResult
@ -43,15 +43,16 @@ class SimpleToolProvider(Provider):
"""A simple provider that returns a configurable list of tools."""
def __init__(self, tools: list[Tool] | None = None):
super().__init__()
self._tools = tools or []
self.list_tools_call_count = 0
self.get_tool_call_count = 0
async def list_tools(self, context: Context) -> list[Tool]:
async def list_tools(self) -> list[Tool]:
self.list_tools_call_count += 1
return self._tools
async def get_tool(self, context: Context, name: str) -> Tool | None:
async def get_tool(self, name: str) -> Tool | None:
self.get_tool_call_count += 1
return next((t for t in self._tools if t.name == name), None)
@ -60,10 +61,11 @@ class ListOnlyProvider(Provider):
"""A provider that only implements list_tools (uses default get_tool)."""
def __init__(self, tools: list[Tool]):
super().__init__()
self._tools = tools
self.list_tools_call_count = 0
async def list_tools(self, context: Context) -> list[Tool]:
async def list_tools(self) -> list[Tool]:
self.list_tools_call_count += 1
return self._tools
@ -150,8 +152,9 @@ class TestProvider:
await client.list_tools()
await client.list_tools()
# Provider should have been called 3 times
assert provider.list_tools_call_count == 3
# Provider should have been called 4 times
# (1 from get_tasks() during docket registration + 3 from client)
assert provider.list_tools_call_count == 4
async def test_call_dynamic_tool(
self, base_server: FastMCP, dynamic_tools: list[Tool]
@ -210,11 +213,12 @@ class TestProvider:
async with Client(base_server) as client:
await client.call_tool(name="dynamic_multiply", arguments={"a": 2, "b": 3})
# get_tool is called twice:
# 1. Server calls get_tool() to check _should_enable_component filter
# 2. Default call_tool() implementation calls get_tool() internally
# get_tool is called three times:
# 1. Server.get_tool() for task config check calls provider.get_tool()
# 2. _call_tool() calls provider.get_tool() to check _should_enable_component
# 3. Default call_tool() implementation calls get_tool() internally
# Key point: list_tools is NOT called during tool execution (efficient lookup)
assert provider.get_tool_call_count == 2
assert provider.get_tool_call_count == 3
async def test_default_get_tool_falls_back_to_list(self, base_server: FastMCP):
"""Test that BaseToolProvider's default get_tool calls list_tools."""
@ -298,17 +302,13 @@ class TestProviderClass:
)
provider = ListOnlyProvider(tools=[tool])
# Create a context for direct testing
mcp = FastMCP("TestServer")
ctx = Context(mcp)
# Default get_tool should find by name
found = await provider.get_tool(ctx, "test")
found = await provider.get_tool("test")
assert found is not None
assert found.name == "test"
# Should return None for unknown names
not_found = await provider.get_tool(ctx, "unknown")
not_found = await provider.get_tool("unknown")
assert not_found is None
@ -389,6 +389,7 @@ class TestProviderExecutionMethods:
"""Provider that wraps tool execution with custom logic."""
def __init__(self):
super().__init__()
self.call_count = 0
self._tool = SimpleTool(
name="custom_tool",
@ -397,20 +398,20 @@ class TestProviderExecutionMethods:
operation="add",
)
async def list_tools(self, context: Context) -> Sequence[Tool]:
async def list_tools(self) -> Sequence[Tool]:
return [self._tool]
async def get_tool(self, context: Context, name: str) -> Tool | None:
async def get_tool(self, name: str) -> Tool | None:
if name == "custom_tool":
return self._tool
return None
async def call_tool(
self, context: Context, name: str, arguments: dict[str, Any]
self, name: str, arguments: dict[str, Any]
) -> ToolResult | None:
# Custom behavior: track calls and modify result
self.call_count += 1
tool = await self.get_tool(context, name)
tool = await self.get_tool(name)
if tool is None:
return None
result = await tool.run(arguments)
@ -434,7 +435,7 @@ class TestProviderExecutionMethods:
"""Test that default read_resource uses get_resource and reads it."""
class ResourceProvider(Provider):
async def list_resources(self, context: Context) -> Sequence[Resource]:
async def list_resources(self) -> Sequence[Resource]:
return [
FunctionResource(
uri=AnyUrl("test://data"),
@ -459,7 +460,7 @@ class TestProviderExecutionMethods:
class CustomReadProvider(Provider):
"""Provider that transforms resource content."""
async def list_resources(self, context: Context) -> Sequence[Resource]:
async def list_resources(self) -> Sequence[Resource]:
return [
FunctionResource(
uri=AnyUrl("test://data"),
@ -468,9 +469,7 @@ class TestProviderExecutionMethods:
)
]
async def read_resource(
self, context: Context, uri: str
) -> ResourceContent | None:
async def read_resource(self, uri: str) -> ResourceContent | None:
if uri == "test://data":
# Custom behavior: return transformed content
return ResourceContent(content="TRANSFORMED")
@ -490,9 +489,7 @@ class TestProviderExecutionMethods:
"""Test that read_resource_template handles template-based resources."""
class TemplateProvider(Provider):
async def list_resource_templates(
self, context: Context
) -> Sequence[ResourceTemplate]:
async def list_resource_templates(self) -> Sequence[ResourceTemplate]:
return [
FunctionResourceTemplate.from_function(
fn=lambda name: f"content of {name}",
@ -515,7 +512,7 @@ class TestProviderExecutionMethods:
"""Test that default render_prompt uses get_prompt and renders it."""
class PromptProvider(Provider):
async def list_prompts(self, context: Context) -> Sequence[Prompt]:
async def list_prompts(self) -> Sequence[Prompt]:
return [
FunctionPrompt.from_function(
fn=lambda name: f"Hello, {name}!",
@ -540,7 +537,7 @@ class TestProviderExecutionMethods:
class CustomRenderProvider(Provider):
"""Provider that adds prefix to all prompts."""
async def list_prompts(self, context: Context) -> Sequence[Prompt]:
async def list_prompts(self) -> Sequence[Prompt]:
return [
FunctionPrompt.from_function(
fn=lambda: "original message",
@ -550,7 +547,7 @@ class TestProviderExecutionMethods:
]
async def render_prompt(
self, context: Context, name: str, arguments: dict[str, Any] | None
self, name: str, arguments: dict[str, Any] | None
) -> PromptResult | None:
if name == "test_prompt":
# Custom behavior: add prefix

View file

@ -11,7 +11,7 @@ from fastmcp import Client, FastMCP
from fastmcp.exceptions import NotFoundError
from fastmcp.prompts.prompt import FunctionPrompt, Prompt
from fastmcp.resources import Resource, ResourceContent, ResourceTemplate
from fastmcp.server.server import (
from fastmcp.server.providers.mounted import (
add_resource_prefix,
has_resource_prefix,
remove_resource_prefix,
@ -1329,8 +1329,6 @@ class TestResourcePrefixMounting:
self, uri, prefix, expected_match, expected_strip
):
"""Test that resource prefix utility functions correctly match and strip resource prefixes."""
from fastmcp.server.server import has_resource_prefix, remove_resource_prefix
# Test matching
assert has_resource_prefix(uri, prefix) == expected_match

View file

@ -1057,8 +1057,8 @@ class TestMountedComponentsRaiseOnLoadError:
# Create a failing mounted server by corrupting it
parent_mcp.mount(child_mcp, prefix="child")
# Corrupt the parent's mounted servers to make it fail during loading
parent_mcp._mounted_servers.append("invalid") # type: ignore
# Corrupt the parent's providers to make it fail during loading
parent_mcp._providers.append("invalid") # type: ignore
# Should not raise, just warn; use server middleware path now
tools = await parent_mcp._list_tools_middleware()
@ -1071,13 +1071,13 @@ class TestMountedComponentsRaiseOnLoadError:
# Create a failing mounted server
parent_mcp.mount(child_mcp, prefix="child")
# Corrupt the parent's mounted servers to make it fail during loading
parent_mcp._mounted_servers.append("invalid") # type: ignore
# Corrupt the parent's providers to make it fail during loading
parent_mcp._providers.append("invalid") # type: ignore
# Use temporary settings context manager
with temporary_settings(mounted_components_raise_on_load_error=True):
# Should raise the exception
with pytest.raises(
AttributeError, match="'str' object has no attribute 'server'"
AttributeError, match="'str' object has no attribute 'list_tools'"
):
await parent_mcp._list_tools_middleware()