From 115ec5cf5816f5102fde85c1058f8029a599234c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:05:58 -0500 Subject: [PATCH 1/8] 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 --- examples/providers/sqlite/README.md | 8 +- examples/providers/sqlite/setup_db.py | 2 +- .../component_manager/component_service.py | 135 +-- src/fastmcp/providers/__init__.py | 34 + .../{providers.py => providers/base.py} | 156 ++- src/fastmcp/providers/mounted.py | 387 ++++++++ src/fastmcp/server/server.py | 919 +++++++----------- tests/client/test_client.py | 2 +- tests/server/test_mount.py | 136 +-- tests/server/test_providers.py | 58 +- tests/tools/test_tool_manager.py | 10 +- 11 files changed, 1024 insertions(+), 823 deletions(-) create mode 100644 src/fastmcp/providers/__init__.py rename src/fastmcp/{providers.py => providers/base.py} (55%) create mode 100644 src/fastmcp/providers/mounted.py diff --git a/examples/providers/sqlite/README.md b/examples/providers/sqlite/README.md index 86daad327f..9175a5e435 100644 --- a/examples/providers/sqlite/README.md +++ b/examples/providers/sqlite/README.md @@ -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. diff --git a/examples/providers/sqlite/setup_db.py b/examples/providers/sqlite/setup_db.py index ac73fc257f..beeb4283e3 100644 --- a/examples/providers/sqlite/setup_db.py +++ b/examples/providers/sqlite/setup_db.py @@ -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 diff --git a/src/fastmcp/contrib/component_manager/component_service.py b/src/fastmcp/contrib/component_manager/component_service.py index 9fb2e4fbae..21a9f5369b 100644 --- a/src/fastmcp/contrib/component_manager/component_service.py +++ b/src/fastmcp/contrib/component_manager/component_service.py @@ -5,6 +5,7 @@ from fastmcp.exceptions import NotFoundError from fastmcp.prompts.prompt import Prompt +from fastmcp.providers import MountedProvider from fastmcp.resources.resource import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.server import FastMCP, has_resource_prefix, remove_resource_prefix @@ -40,16 +41,17 @@ async def _enable_tool(self, key: str) -> Tool: 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) - return tool - else: - continue + # 2. Check mounted servers via MountedProvider + for provider in reversed(self._server._providers): + if isinstance(provider, MountedProvider): + if provider.prefix: + if key.startswith(f"{provider.prefix}_"): + tool_key = key.removeprefix(f"{provider.prefix}_") + mounted_service = ComponentService(provider.server) + tool = await mounted_service._enable_tool(tool_key) + return tool + else: + continue raise NotFoundError(f"Unknown tool: {key}") async def _disable_tool(self, key: str) -> Tool: @@ -69,16 +71,17 @@ async def _disable_tool(self, key: str) -> Tool: 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) - return tool - else: - continue + # 2. Check mounted servers via MountedProvider + for provider in reversed(self._server._providers): + if isinstance(provider, MountedProvider): + if provider.prefix: + if key.startswith(f"{provider.prefix}_"): + tool_key = key.removeprefix(f"{provider.prefix}_") + mounted_service = ComponentService(provider.server) + tool = await mounted_service._disable_tool(tool_key) + return tool + else: + continue raise NotFoundError(f"Unknown tool: {key}") async def _enable_resource(self, key: str) -> Resource | ResourceTemplate: @@ -102,18 +105,19 @@ async def _enable_resource(self, key: str) -> Resource | ResourceTemplate: 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) - mounted_resource: ( - Resource | ResourceTemplate - ) = await mounted_service._enable_resource(key) - return mounted_resource - else: - continue + # 2. Check mounted servers via MountedProvider + for provider in reversed(self._server._providers): + if isinstance(provider, MountedProvider): + if provider.prefix: + if has_resource_prefix(key, provider.prefix): + resource_key = remove_resource_prefix(key, provider.prefix) + mounted_service = ComponentService(provider.server) + mounted_resource: ( + Resource | ResourceTemplate + ) = await mounted_service._enable_resource(resource_key) + return mounted_resource + else: + continue raise NotFoundError(f"Unknown resource: {key}") async def _disable_resource(self, key: str) -> Resource | ResourceTemplate: @@ -137,18 +141,19 @@ async def _disable_resource(self, key: str) -> Resource | ResourceTemplate: 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) - mounted_resource: ( - Resource | ResourceTemplate - ) = await mounted_service._disable_resource(key) - return mounted_resource - else: - continue + # 2. Check mounted servers via MountedProvider + for provider in reversed(self._server._providers): + if isinstance(provider, MountedProvider): + if provider.prefix: + if has_resource_prefix(key, provider.prefix): + resource_key = remove_resource_prefix(key, provider.prefix) + mounted_service = ComponentService(provider.server) + mounted_resource: ( + Resource | ResourceTemplate + ) = await mounted_service._disable_resource(resource_key) + return mounted_resource + else: + continue raise NotFoundError(f"Unknown resource: {key}") async def _enable_prompt(self, key: str) -> Prompt: @@ -168,16 +173,17 @@ async def _enable_prompt(self, key: str) -> Prompt: 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) - return prompt - else: - continue + # 2. Check mounted servers via MountedProvider + for provider in reversed(self._server._providers): + if isinstance(provider, MountedProvider): + if provider.prefix: + if key.startswith(f"{provider.prefix}_"): + prompt_key = key.removeprefix(f"{provider.prefix}_") + mounted_service = ComponentService(provider.server) + prompt = await mounted_service._enable_prompt(prompt_key) + return prompt + else: + continue raise NotFoundError(f"Unknown prompt: {key}") async def _disable_prompt(self, key: str) -> Prompt: @@ -196,14 +202,15 @@ async def _disable_prompt(self, key: str) -> Prompt: 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) - return prompt - else: - continue + # 2. Check mounted servers via MountedProvider + for provider in reversed(self._server._providers): + if isinstance(provider, MountedProvider): + if provider.prefix: + if key.startswith(f"{provider.prefix}_"): + prompt_key = key.removeprefix(f"{provider.prefix}_") + mounted_service = ComponentService(provider.server) + prompt = await mounted_service._disable_prompt(prompt_key) + return prompt + else: + continue raise NotFoundError(f"Unknown prompt: {key}") diff --git a/src/fastmcp/providers/__init__.py b/src/fastmcp/providers/__init__.py new file mode 100644 index 0000000000..145ff58277 --- /dev/null +++ b/src/fastmcp/providers/__init__.py @@ -0,0 +1,34 @@ +"""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, 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.providers.base import Components, Provider +from fastmcp.providers.mounted import MountedProvider + +__all__ = [ + "Components", + "MountedProvider", + "Provider", +] diff --git a/src/fastmcp/providers.py b/src/fastmcp/providers/base.py similarity index 55% rename from src/fastmcp/providers.py rename to src/fastmcp/providers/base.py index e7246b444f..2b7a805dd2 100644 --- a/src/fastmcp/providers.py +++ b/src/fastmcp/providers/base.py @@ -1,4 +1,4 @@ -"""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. @@ -6,18 +6,18 @@ Example: ```python from fastmcp import FastMCP, Provider - from fastmcp.server.context import Context 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,20 +27,28 @@ async def get_tool(self, context: Context, name: str) -> Tool | None: from __future__ import annotations -from collections.abc import Sequence -from typing import TYPE_CHECKING, Any +from collections.abc import AsyncIterator, Sequence +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import Any from fastmcp.prompts.prompt import Prompt, PromptResult from fastmcp.resources.resource import Resource, ResourceContent from fastmcp.resources.template import ResourceTemplate from fastmcp.tools.tool import Tool, ToolResult -if TYPE_CHECKING: - from fastmcp.server.context import Context -__all__ = [ - "Provider", -] +@dataclass +class Components: + """Collection of MCP components. + + Used by get_tasks() to return task-eligible components for Docket registration. + """ + + tools: Sequence[Tool] = () + resources: Sequence[Resource] = () + templates: Sequence[ResourceTemplate] = () + prompts: Sequence[Prompt] = () class Provider: @@ -50,9 +58,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 +71,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 +87,11 @@ async def get_tool(self, context: Context, name: str) -> Tool | None: 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 +101,19 @@ async def call_tool( 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 +122,17 @@ async def get_resource(self, context: Context, uri: str) -> Resource | None: 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 +142,13 @@ async def get_resource_template( 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 +160,12 @@ async def read_resource(self, context: Context, uri: str) -> ResourceContent | N 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 +176,7 @@ async def read_resource_template( 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 +185,14 @@ async def read_resource_template( 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 +201,11 @@ async def get_prompt(self, context: Context, name: str) -> Prompt | None: 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 +215,84 @@ async def render_prompt( 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) -> Components: + """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 Components( + 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 diff --git a/src/fastmcp/providers/mounted.py b/src/fastmcp/providers/mounted.py new file mode 100644 index 0000000000..89e0b3da09 --- /dev/null +++ b/src/fastmcp/providers/mounted.py @@ -0,0 +1,387 @@ +"""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 + +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.providers.base import Components, Provider +from fastmcp.resources.resource import Resource, ResourceContent +from fastmcp.resources.template import ResourceTemplate +from fastmcp.tools.tool import Tool, ToolResult + +if TYPE_CHECKING: + from fastmcp.server.server import FastMCP + + +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.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 {} + 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 + # Import here to avoid circular dependency + from fastmcp.server.server import add_resource_prefix + + 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 + # Import here to avoid circular dependency + from fastmcp.server.server import has_resource_prefix, remove_resource_prefix + + 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, checking if it matches our prefix pattern.""" + unprefixed = self._strip_tool_prefix(name) + if unprefixed is None: + return None # Doesn't match this provider + + try: + tool = await self.server.get_tool(unprefixed) + # Return with prefixed key for parent's filter checking + prefixed_key = name # The name we received is already the prefixed form + if tool.key != prefixed_key: + tool = tool.model_copy(key=prefixed_key) + return tool + except NotFoundError: + return 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, checking if it matches our prefix pattern. + + This only returns concrete resources, not resources created from templates. + For templates, use get_resource_template() instead. + """ + unprefixed = self._strip_resource_prefix(uri) + if unprefixed is None: + return None # Doesn't match this provider + + # Only check concrete resources (not templates that match the URI) + # This preserves the original template for task execution + resources = await self.server.get_resources() + if unprefixed not in resources: + return None + resource = resources[unprefixed] + + # Return with prefixed key for parent's filter checking + return self._prefix_resource(resource) + + 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, checking if it matches our prefix pattern.""" + unprefixed = self._strip_tool_prefix(name) + if unprefixed is None: + return None # Doesn't match this provider + + try: + prompt = await self.server.get_prompt(unprefixed) + # Return with prefixed key for parent's filter checking + prefixed_key = name + if prompt.key != prefixed_key: + prompt = prompt.model_copy(key=prefixed_key) + return prompt + except NotFoundError: + return 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) -> Components: + """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[Tool] = [] + resources: list[Resource] = [] + templates: list[ResourceTemplate] = [] + prompts: list[Prompt] = [] + + # 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)) + + 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)) + + 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)) + + 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)) + + # 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) + resources.extend(self._prefix_resource(r) for r in nested.resources) + templates.extend(self._prefix_template(t) for t in nested.templates) + prompts.extend(self._prefix_prompt(p) for p in nested.prompts) + + return Components( + 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 diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 4a252e7dbf..47a7400707 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -22,7 +22,6 @@ asynccontextmanager, suppress, ) -from dataclasses import dataclass from functools import partial from pathlib import Path from typing import TYPE_CHECKING, Any, Generic, Literal, cast, overload @@ -227,7 +226,6 @@ def __init__( self._docket = None self._additional_http_routes: list[BaseRoute] = [] - self._mounted_servers: list[MountedServer] = [] self._providers: list[Provider] = list(providers or []) self._is_mounted: bool = ( False # Set to True when this server is mounted on another @@ -477,12 +475,21 @@ async def _docket_lifespan(self) -> AsyncIterator[None]: ): docket.register(template.fn) - # Also register functions from mounted servers so tasks can - # execute in the parent's Docket context - for mounted in self._mounted_servers: - await self._register_mounted_server_functions( - mounted.server, docket, mounted.prefix, mounted.tool_names - ) + # Register provider components + for provider in self._providers: + tasks = await provider.get_tasks() + for tool in tasks.tools: + named_fn = _create_named_fn_wrapper(tool.fn, tool.key) # type: ignore[attr-defined] + docket.register(named_fn) + for resource in tasks.resources: + named_fn = _create_named_fn_wrapper(resource.fn, resource.name) # type: ignore[attr-defined] + docket.register(named_fn) + for template in tasks.templates: + named_fn = _create_named_fn_wrapper(template.fn, template.name) # type: ignore[attr-defined] + docket.register(named_fn) + for prompt in tasks.prompts: + named_fn = _create_named_fn_wrapper(prompt.fn, prompt.key) # type: ignore[attr-defined] + docket.register(named_fn) # Set Docket in ContextVar so CurrentDocket can access it docket_token = _current_docket.set(docket) @@ -522,81 +529,6 @@ async def _docket_lifespan(self) -> AsyncIterator[None]: # Reset server ContextVar _current_server.reset(server_token) - async def _register_mounted_server_functions( - self, - server: FastMCP, - docket: Docket, - prefix: str | None, - tool_names: dict[str, str] | None = None, - ) -> None: - """Register task-enabled functions from a mounted server with Docket. - - This enables background task execution for mounted server components - through the parent server's Docket context. - - Args: - server: The mounted server whose functions to register - docket: The Docket instance to register with - prefix: The mount prefix to prepend to function names (matches - client-facing tool/prompt names) - tool_names: Optional mapping of original tool names to custom names - """ - # Register tools with prefixed names to avoid collisions - for tool in server._tool_manager._tools.values(): - if isinstance(tool, FunctionTool) and tool.task_config.mode != "forbidden": - # Apply tool_names override first, then prefix (matches get_tools logic) - if tool_names and tool.key in tool_names: - fn_name = tool_names[tool.key] - elif prefix: - fn_name = f"{prefix}_{tool.key}" - else: - fn_name = tool.key - named_fn = _create_named_fn_wrapper(tool.fn, fn_name) - docket.register(named_fn) - - # Register prompts with prefixed names - for prompt in server._prompt_manager._prompts.values(): - if ( - isinstance(prompt, FunctionPrompt) - and prompt.task_config.mode != "forbidden" - ): - fn_name = f"{prefix}_{prompt.key}" if prefix else prompt.key - named_fn = _create_named_fn_wrapper( - cast(Callable[..., Awaitable[Any]], prompt.fn), fn_name - ) - docket.register(named_fn) - - # Register resources with prefixed names (use name, not key/URI) - for resource in server._resource_manager._resources.values(): - if ( - isinstance(resource, FunctionResource) - and resource.task_config.mode != "forbidden" - ): - fn_name = f"{prefix}_{resource.name}" if prefix else resource.name - named_fn = _create_named_fn_wrapper(resource.fn, fn_name) - docket.register(named_fn) - - # Register resource templates with prefixed names (use name, not key/URI) - for template in server._resource_manager._templates.values(): - if ( - isinstance(template, FunctionResourceTemplate) - and template.task_config.mode != "forbidden" - ): - fn_name = f"{prefix}_{template.name}" if prefix else template.name - named_fn = _create_named_fn_wrapper(template.fn, fn_name) - docket.register(named_fn) - - # Recursively register from nested mounted servers with accumulated prefix - for nested in server._mounted_servers: - nested_prefix = ( - f"{prefix}_{nested.prefix}" - if prefix and nested.prefix - else (prefix or nested.prefix) - ) - await self._register_mounted_server_functions( - nested.server, docket, nested_prefix, nested.tool_names - ) - @asynccontextmanager async def _lifespan_manager(self) -> AsyncIterator[None]: if self._lifespan_result_set: @@ -611,10 +543,9 @@ async def _lifespan_manager(self) -> AsyncIterator[None]: self._lifespan_result_set = True async with AsyncExitStack[bool | None]() as stack: - for server in self._mounted_servers: - await stack.enter_async_context( - cm=server.server._lifespan_manager() - ) + # Start lifespans for all providers + for provider in self._providers: + await stack.enter_async_context(provider.lifespan()) self._started.set() try: @@ -806,8 +737,10 @@ async def handler(req: mcp.types.GetPromptRequest) -> mcp.types.ServerResult: # Check for task metadata and route appropriately async with fastmcp.server.context.Context(fastmcp=self): - prompts = await self.get_prompts() - prompt = prompts.get(name) + try: + prompt = await self.get_prompt(name) + except NotFoundError: + prompt = None if ( prompt and self._should_enable_component(prompt) @@ -928,24 +861,19 @@ def add_provider(self, provider: Provider) -> None: self._providers.append(provider) async def get_tools(self) -> dict[str, Tool]: - """Get all tools (unfiltered), including mounted servers, indexed by key.""" + """Get all tools (unfiltered), including from providers, indexed by key.""" all_tools = dict(await self._tool_manager.get_tools()) - for mounted in self._mounted_servers: + # Get tools from providers (including MountedProvider) + for provider in self._providers: try: - child_tools = await mounted.server.get_tools() - for key, tool in child_tools.items(): - # Check for manual override first, then apply prefix - if mounted.tool_names and key in mounted.tool_names: - new_key = mounted.tool_names[key] - elif mounted.prefix: - new_key = f"{mounted.prefix}_{key}" - else: - new_key = key - all_tools[new_key] = tool.model_copy(key=new_key) + provider_tools = await provider.list_tools() + for tool in provider_tools: + all_tools[tool.key] = tool except Exception as e: + provider_name = getattr(provider, "server", provider).__class__.__name__ logger.warning( - f"Failed to get tools from mounted server {mounted.server.name!r}: {e}" + f"Failed to get tools from provider {provider_name!r}: {e}" ) if fastmcp.settings.mounted_components_raise_on_load_error: raise @@ -954,10 +882,23 @@ async def get_tools(self) -> dict[str, Tool]: return all_tools async def get_tool(self, key: str) -> Tool: - tools = await self.get_tools() - if key not in tools: - raise NotFoundError(f"Unknown tool: {key}") - return tools[key] + """Get a tool by key, checking local tools first then providers.""" + # Check local tools first + try: + return await self._tool_manager.get_tool(key) + except NotFoundError: + pass + + # Try providers + for provider in self._providers: + try: + tool = await provider.get_tool(key) + if tool is not None: + return tool + except NotFoundError: + continue + + raise NotFoundError(f"Unknown tool: {key}") async def _get_tool_with_task_config(self, key: str) -> Tool | None: """Get a tool by key, returning None if not found. @@ -973,43 +914,56 @@ async def _get_tool_with_task_config(self, key: str) -> Tool | None: async def _get_resource_or_template_or_none( self, uri: str ) -> Resource | ResourceTemplate | None: - """Get a resource or template by URI, searching recursively. Returns None if not found.""" - try: - return await self.get_resource(uri) - except NotFoundError: - pass + """Get a resource or template by URI. Returns None if not found. - templates = await self.get_resource_templates() - for template in templates.values(): + Returns the original ResourceTemplate (not a Resource created from it) + to preserve the registered function for task execution. + """ + # Check local concrete resources first + local_resources = await self._resource_manager.get_resources() + if uri in local_resources: + return local_resources[uri] + + # Check local templates - return the template itself, not a created resource + local_templates = await self._resource_manager.get_resource_templates() + for template in local_templates.values(): if template.matches(uri): return template + # Check providers + for provider in self._providers: + try: + resource = await provider.get_resource(uri) + if resource is not None: + return resource + except NotFoundError: + continue + + # Check provider templates + for provider in self._providers: + try: + template = await provider.get_resource_template(uri) + if template is not None: + return template + except NotFoundError: + continue + return None async def get_resources(self) -> dict[str, Resource]: - """Get all resources (unfiltered), including mounted servers, indexed by key.""" + """Get all resources (unfiltered), including from providers, indexed by key.""" all_resources = dict(await self._resource_manager.get_resources()) - for mounted in self._mounted_servers: + # Get resources from providers (including MountedProvider) + for provider in self._providers: try: - child_resources = await mounted.server.get_resources() - for key, resource in child_resources.items(): - new_key = ( - add_resource_prefix(key, mounted.prefix) - if mounted.prefix - else key - ) - update = ( - {"name": f"{mounted.prefix}_{resource.name}"} - if mounted.prefix and resource.name - else {} - ) - all_resources[new_key] = resource.model_copy( - key=new_key, update=update - ) + provider_resources = await provider.list_resources() + for resource in provider_resources: + all_resources[resource.key] = resource except Exception as e: + provider_name = getattr(provider, "server", provider).__class__.__name__ logger.warning( - f"Failed to get resources from mounted server {mounted.server.name!r}: {e}" + f"Failed to get resources from provider {provider_name!r}: {e}" ) if fastmcp.settings.mounted_components_raise_on_load_error: raise @@ -1018,36 +972,38 @@ async def get_resources(self) -> dict[str, Resource]: return all_resources async def get_resource(self, key: str) -> Resource: - resources = await self.get_resources() - if key not in resources: - raise NotFoundError(f"Unknown resource: {key}") - return resources[key] + """Get a resource by key, checking local resources first then providers.""" + # Check local resources first + try: + return await self._resource_manager.get_resource(key) + except NotFoundError: + pass + + # Try providers + for provider in self._providers: + try: + resource = await provider.get_resource(key) + if resource is not None: + return resource + except NotFoundError: + continue + + raise NotFoundError(f"Unknown resource: {key}") async def get_resource_templates(self) -> dict[str, ResourceTemplate]: - """Get all resource templates (unfiltered), including mounted servers, indexed by key.""" + """Get all resource templates (unfiltered), including from providers, indexed by key.""" all_templates = dict(await self._resource_manager.get_resource_templates()) - for mounted in self._mounted_servers: + # Get templates from providers (including MountedProvider) + for provider in self._providers: try: - child_templates = await mounted.server.get_resource_templates() - for key, template in child_templates.items(): - new_key = ( - add_resource_prefix(key, mounted.prefix) - if mounted.prefix - else key - ) - update: dict[str, Any] = {} - if mounted.prefix: - if template.name: - update["name"] = f"{mounted.prefix}_{template.name}" - # Update uri_template so matches() works with prefixed URIs - update["uri_template"] = new_key - all_templates[new_key] = template.model_copy( - key=new_key, update=update - ) + provider_templates = await provider.list_resource_templates() + for template in provider_templates: + all_templates[template.key] = template except Exception as e: + provider_name = getattr(provider, "server", provider).__class__.__name__ logger.warning( - f"Failed to get resource templates from mounted server {mounted.server.name!r}: {e}" + f"Failed to get resource templates from provider {provider_name!r}: {e}" ) if fastmcp.settings.mounted_components_raise_on_load_error: raise @@ -1057,24 +1013,37 @@ async def get_resource_templates(self) -> dict[str, ResourceTemplate]: async def get_resource_template(self, key: str) -> ResourceTemplate: """Get a registered resource template by key.""" - templates = await self.get_resource_templates() - if key not in templates: - raise NotFoundError(f"Unknown resource template: {key}") - return templates[key] + # Check local templates first + local_templates = await self._resource_manager.get_resource_templates() + if key in local_templates: + return local_templates[key] + + # Try providers + for provider in self._providers: + try: + # For templates, we use get_resource_template which matches by URI + template = await provider.get_resource_template(key) + if template is not None: + return template + except NotFoundError: + continue + + raise NotFoundError(f"Unknown resource template: {key}") async def get_prompts(self) -> dict[str, Prompt]: - """Get all prompts (unfiltered), including mounted servers, indexed by key.""" + """Get all prompts (unfiltered), including from providers, indexed by key.""" all_prompts = dict(await self._prompt_manager.get_prompts()) - for mounted in self._mounted_servers: + # Get prompts from providers (including MountedProvider) + for provider in self._providers: try: - child_prompts = await mounted.server.get_prompts() - for key, prompt in child_prompts.items(): - new_key = f"{mounted.prefix}_{key}" if mounted.prefix else key - all_prompts[new_key] = prompt.model_copy(key=new_key) + provider_prompts = await provider.list_prompts() + for prompt in provider_prompts: + all_prompts[prompt.key] = prompt except Exception as e: + provider_name = getattr(provider, "server", provider).__class__.__name__ logger.warning( - f"Failed to get prompts from mounted server {mounted.server.name!r}: {e}" + f"Failed to get prompts from provider {provider_name!r}: {e}" ) if fastmcp.settings.mounted_components_raise_on_load_error: raise @@ -1083,10 +1052,23 @@ async def get_prompts(self) -> dict[str, Prompt]: return all_prompts async def get_prompt(self, key: str) -> Prompt: - prompts = await self.get_prompts() - if key not in prompts: - raise NotFoundError(f"Unknown prompt: {key}") - return prompts[key] + """Get a prompt by key, checking local prompts first then providers.""" + # Check local prompts first + try: + return await self._prompt_manager.get_prompt(key) + except NotFoundError: + pass + + # Try providers + for provider in self._providers: + try: + prompt = await provider.get_prompt(key) + if prompt is not None: + return prompt + except NotFoundError: + continue + + raise NotFoundError(f"Unknown prompt: {key}") def custom_route( self, @@ -1139,22 +1121,15 @@ def decorator( return decorator def _get_additional_http_routes(self) -> list[BaseRoute]: - """Get all additional HTTP routes including from mounted servers. + """Get all additional HTTP routes including from providers. Returns a list of all custom HTTP routes from this server and - recursively from all mounted servers. + from all providers that have HTTP routes (e.g., MountedProvider). Returns: List of Starlette BaseRoute objects """ - routes = list(self._additional_http_routes) - - # Recursively get routes from mounted servers - for mounted_server in self._mounted_servers: - mounted_routes = mounted_server.server._get_additional_http_routes() - routes.extend(mounted_routes) - - return routes + return list(self._additional_http_routes) async def _list_tools_mcp(self) -> list[SDKTool]: """ @@ -1204,64 +1179,33 @@ async def _list_tools( """ # 1. Get local tools and filter them local_tools = await self._tool_manager.get_tools() - filtered_local = [ - tool for tool in local_tools.values() if self._should_enable_component(tool) - ] - - # 2. Get tools from mounted servers - # Mounted servers apply their own filtering, but we also apply parent's filtering - # Use a dict to implement "later wins" deduplication by key - all_tools: dict[str, Tool] = {tool.key: tool for tool in filtered_local} + local_tools_dict: dict[str, Tool] = { + tool.key: tool + for tool in local_tools.values() + if self._should_enable_component(tool) + } - for mounted in self._mounted_servers: + # 2. Get tools from providers (later providers win for deduplication) + provider_tools_dict: dict[str, Tool] = {} + for provider in self._providers: try: - child_tools = await mounted.server._list_tools_middleware() - for tool in child_tools: - # Apply parent server's filtering to mounted components - if not self._should_enable_component(tool): - continue - - # Check for manual override first, then apply prefix - if mounted.tool_names and tool.key in mounted.tool_names: - key = mounted.tool_names[tool.key] - elif mounted.prefix: - key = f"{mounted.prefix}_{tool.key}" - else: - key = tool.key - - if key != tool.key: - tool = tool.model_copy(key=key) - # Later mounted servers override earlier ones - all_tools[key] = tool - except Exception as e: - server_name = getattr( - getattr(mounted, "server", None), "name", repr(mounted) - ) - logger.warning( - f"Failed to list tools from mounted server {server_name!r}: {e}" - ) + provider_tools = await provider.list_tools() + for tool in provider_tools: + if self._should_enable_component(tool): + # Later providers override earlier ones + provider_tools_dict[tool.key] = tool + except Exception: + logger.exception("Error listing tools from provider") if fastmcp.settings.mounted_components_raise_on_load_error: raise - continue - # 3. Get tools from component providers (providers listed first, static wins for execution) - provider_tools_list: list[Tool] = [] - ctx = context.fastmcp_context - if ctx is not None: - for provider in self._providers: - try: - provider_tools = await provider.list_tools(ctx) - for tool in provider_tools: - if self._should_enable_component(tool): - # Don't duplicate if static tool exists with same key - if tool.key not in all_tools: - provider_tools_list.append(tool) - except Exception: - logger.exception("Error listing tools from provider") + # Remove provider tools that conflict with local tools (local wins) + for key in local_tools_dict: + provider_tools_dict.pop(key, None) # Provider tools come first in the list (for visibility), - # but static tools take precedence for execution - return provider_tools_list + list(all_tools.values()) + # but local tools take precedence for execution + return list(provider_tools_dict.values()) + list(local_tools_dict.values()) async def _list_resources_mcp(self) -> list[SDKResource]: """ @@ -1311,63 +1255,35 @@ async def _list_resources( """ # 1. Filter local resources local_resources = await self._resource_manager.get_resources() - filtered_local = [ - resource + local_resources_dict: dict[str, Resource] = { + resource.key: resource for resource in local_resources.values() if self._should_enable_component(resource) - ] - - # 2. Get from mounted servers with resource prefix handling - # Mounted servers apply their own filtering, but we also apply parent's filtering - # Use a dict to implement "later wins" deduplication by key - all_resources: dict[str, Resource] = { - resource.key: resource for resource in filtered_local } - for mounted in self._mounted_servers: + # 2. Get resources from providers (later providers win for deduplication) + provider_resources_dict: dict[str, Resource] = {} + for provider in self._providers: try: - child_resources = await mounted.server._list_resources_middleware() - for resource in child_resources: - # Apply parent server's filtering to mounted components - if not self._should_enable_component(resource): - continue - - key = resource.key - if mounted.prefix: - key = add_resource_prefix(resource.key, mounted.prefix) - resource = resource.model_copy( - key=key, - update={"name": f"{mounted.prefix}_{resource.name}"}, - ) - # Later mounted servers override earlier ones - all_resources[key] = resource - except Exception as e: - server_name = getattr( - getattr(mounted, "server", None), "name", repr(mounted) - ) - logger.warning(f"Failed to list resources from {server_name!r}: {e}") + provider_resources = await provider.list_resources() + for resource in provider_resources: + if self._should_enable_component(resource): + # Later providers override earlier ones + provider_resources_dict[resource.key] = resource + except Exception: + logger.exception("Error listing resources from provider") if fastmcp.settings.mounted_components_raise_on_load_error: raise - continue - # 3. Get resources from component providers (providers listed first) - provider_resources_list: list[Resource] = [] - ctx = context.fastmcp_context - if ctx is not None: - for provider in self._providers: - try: - provider_resources = await provider.list_resources(ctx) - for resource in provider_resources: - if self._should_enable_component(resource): - # Don't duplicate if static resource exists with same key - if resource.key not in all_resources: - provider_resources_list.append(resource) - except Exception: - logger.exception("Error listing resources from provider") + # Remove provider resources that conflict with local resources (local wins) + for key in local_resources_dict: + provider_resources_dict.pop(key, None) # Provider resources come first in the list (for visibility), - # but static resources take precedence for read operations - return provider_resources_list + list(all_resources.values()) + # but local resources take precedence for read operations + return list(provider_resources_dict.values()) + list( + local_resources_dict.values() + ) async def _list_resource_templates_mcp(self) -> list[SDKResourceTemplate]: """ @@ -1418,64 +1334,33 @@ async def _list_resource_templates( """ # 1. Filter local templates local_templates = await self._resource_manager.get_resource_templates() - filtered_local = [ - template + local_templates_dict: dict[str, ResourceTemplate] = { + template.key: template for template in local_templates.values() if self._should_enable_component(template) - ] - - # 2. Get from mounted servers with resource prefix handling - # Mounted servers apply their own filtering, but we also apply parent's filtering - # Use a dict to implement "later wins" deduplication by key - all_templates: dict[str, ResourceTemplate] = { - template.key: template for template in filtered_local } - for mounted in self._mounted_servers: + # 2. Get resource templates from providers (later providers win for deduplication) + provider_templates_dict: dict[str, ResourceTemplate] = {} + for provider in self._providers: try: - child_templates = ( - await mounted.server._list_resource_templates_middleware() - ) - for template in child_templates: - # Apply parent server's filtering to mounted components - if not self._should_enable_component(template): - continue - - key = template.key - if mounted.prefix: - key = add_resource_prefix(template.key, mounted.prefix) - template = template.model_copy( - key=key, - update={"name": f"{mounted.prefix}_{template.name}"}, - ) - # Later mounted servers override earlier ones - all_templates[key] = template - except Exception as e: - server_name = getattr( - getattr(mounted, "server", None), "name", repr(mounted) - ) - logger.warning( - f"Failed to list resource templates from {server_name!r}: {e}" - ) + provider_templates = await provider.list_resource_templates() + for template in provider_templates: + if self._should_enable_component(template): + # Later providers override earlier ones + provider_templates_dict[template.key] = template + except Exception: + logger.exception("Error listing resource templates from provider") if fastmcp.settings.mounted_components_raise_on_load_error: raise - continue - # 3. Get resource templates from providers (listed first, static wins for execution) - provider_templates_list: list[ResourceTemplate] = [] - ctx = context.fastmcp_context - if ctx is not None: - for provider in self._providers: - try: - provider_templates = await provider.list_resource_templates(ctx) - for template in provider_templates: - if self._should_enable_component(template): - if template.key not in all_templates: - provider_templates_list.append(template) - except Exception: - logger.exception("Error listing resource templates from provider") + # Remove provider templates that conflict with local templates (local wins) + for key in local_templates_dict: + provider_templates_dict.pop(key, None) - return provider_templates_list + list(all_templates.values()) + return list(provider_templates_dict.values()) + list( + local_templates_dict.values() + ) async def _list_prompts_mcp(self) -> list[SDKPrompt]: """ @@ -1526,66 +1411,33 @@ async def _list_prompts( """ # 1. Filter local prompts local_prompts = await self._prompt_manager.get_prompts() - filtered_local = [ - prompt + local_prompts_dict: dict[str, Prompt] = { + prompt.key: prompt for prompt in local_prompts.values() if self._should_enable_component(prompt) - ] - - # 2. Get from mounted servers - # Mounted servers apply their own filtering, but we also apply parent's filtering - # Use a dict to implement "later wins" deduplication by key - all_prompts: dict[str, Prompt] = { - prompt.key: prompt for prompt in filtered_local } - for mounted in self._mounted_servers: + # 2. Get prompts from providers (later providers win for deduplication) + provider_prompts_dict: dict[str, Prompt] = {} + for provider in self._providers: try: - child_prompts = await mounted.server._list_prompts_middleware() - for prompt in child_prompts: - # Apply parent server's filtering to mounted components - if not self._should_enable_component(prompt): - continue - - # Apply prefix to prompt key - if mounted.prefix: - key = f"{mounted.prefix}_{prompt.key}" - else: - key = prompt.key - - if key != prompt.key: - prompt = prompt.model_copy(key=key) - # Later mounted servers override earlier ones - all_prompts[key] = prompt - except Exception as e: - server_name = getattr( - getattr(mounted, "server", None), "name", repr(mounted) - ) - logger.warning( - f"Failed to list prompts from mounted server {server_name!r}: {e}" - ) + provider_prompts = await provider.list_prompts() + for prompt in provider_prompts: + if self._should_enable_component(prompt): + # Later providers override earlier ones + provider_prompts_dict[prompt.key] = prompt + except Exception: + logger.exception("Error listing prompts from provider") if fastmcp.settings.mounted_components_raise_on_load_error: raise - continue - # 3. Get prompts from component providers (providers listed first) - provider_prompts_list: list[Prompt] = [] - ctx = context.fastmcp_context - if ctx is not None: - for provider in self._providers: - try: - provider_prompts = await provider.list_prompts(ctx) - for prompt in provider_prompts: - if self._should_enable_component(prompt): - # Don't duplicate if static prompt exists with same key - if prompt.key not in all_prompts: - provider_prompts_list.append(prompt) - except Exception: - logger.exception("Error listing prompts from provider") + # Remove provider prompts that conflict with local prompts (local wins) + for key in local_prompts_dict: + provider_prompts_dict.pop(key, None) # Provider prompts come first in the list (for visibility), - # but static prompts take precedence for render operations - return provider_prompts_list + list(all_prompts.values()) + # but local prompts take precedence for render operations + return list(provider_prompts_dict.values()) + list(local_prompts_dict.values()) async def _call_tool_mcp( self, key: str, arguments: dict[str, Any] @@ -1707,43 +1559,7 @@ async def _call_tool( """ tool_name = context.message.name - # Try mounted servers in reverse order (later wins) - for mounted in reversed(self._mounted_servers): - try_name = tool_name - - # First check if tool_name is an overridden name (reverse lookup) - if mounted.tool_names: - for orig_key, override_name in mounted.tool_names.items(): - if override_name == tool_name: - try_name = orig_key - break - else: - # Not an override, try standard prefix stripping - if mounted.prefix: - if not tool_name.startswith(f"{mounted.prefix}_"): - continue - try_name = tool_name[len(mounted.prefix) + 1 :] - elif mounted.prefix: - if not tool_name.startswith(f"{mounted.prefix}_"): - continue - try_name = tool_name[len(mounted.prefix) + 1 :] - - try: - # First, get the tool to check if parent's filter allows it - # Use get_tool() instead of _tool_manager.get_tool() to support - # nested mounted servers (tools mounted more than 2 levels deep) - tool = await mounted.server.get_tool(try_name) - if not self._should_enable_component(tool): - # Parent filter blocks this tool, continue searching - continue - - return await mounted.server._call_tool_middleware( - try_name, context.message.arguments or {} - ) - except NotFoundError: - continue - - # Try local tools (static tools take precedence) + # Try local tools first (static tools take precedence) try: tool = await self._tool_manager.get_tool(tool_name) if self._should_enable_component(tool): @@ -1753,30 +1569,28 @@ async def _call_tool( except NotFoundError: pass - # Try component providers - ctx = context.fastmcp_context - if ctx is not None: - for provider in self._providers: - try: - tool = await provider.get_tool(ctx, tool_name) - if tool is not None and self._should_enable_component(tool): - result = await provider.call_tool( - ctx, tool_name, context.message.arguments or {} - ) - if result is not None: - return result - except (ValidationError, PydanticValidationError): - # Validation errors are never masked - logger.exception(f"Error validating tool {tool_name!r}") - raise - except ToolError: - logger.exception(f"Error calling tool {tool_name!r}") - raise - except Exception as e: - logger.exception(f"Error calling tool {tool_name!r} from provider") - if self._mask_error_details: - raise ToolError(f"Error calling tool {tool_name!r}") from e - raise ToolError(f"Error calling tool {tool_name!r}: {e}") from e + # Try component providers in reverse order (later providers win) + for provider in reversed(self._providers): + try: + tool = await provider.get_tool(tool_name) + if tool is not None and self._should_enable_component(tool): + result = await provider.call_tool( + tool_name, context.message.arguments or {} + ) + if result is not None: + return result + except (ValidationError, PydanticValidationError): + # Validation errors are never masked + logger.exception(f"Error validating tool {tool_name!r}") + raise + except ToolError: + logger.exception(f"Error calling tool {tool_name!r}") + raise + except Exception as e: + logger.exception(f"Error calling tool {tool_name!r} from provider") + if self._mask_error_details: + raise ToolError(f"Error calling tool {tool_name!r}") from e + raise ToolError(f"Error calling tool {tool_name!r}: {e}") from e raise NotFoundError(f"Unknown tool: {tool_name!r}") @@ -1851,30 +1665,7 @@ async def _read_resource( """ uri_str = str(context.message.uri) - # Try mounted servers in reverse order (later wins) - for mounted in reversed(self._mounted_servers): - key = uri_str - if mounted.prefix: - if not has_resource_prefix(key, mounted.prefix): - continue - key = remove_resource_prefix(key, mounted.prefix) - - # First, get the resource/template to check if parent's filter allows it - # Use get_resource_or_template to support nested mounted servers - # (resources/templates mounted more than 2 levels deep) - resource = await mounted.server._get_resource_or_template_or_none(key) - if resource is None: - continue - if not self._should_enable_component(resource): - # Parent filter blocks this resource, continue searching - continue - try: - result = list(await mounted.server._read_resource_middleware(key)) - return result - except NotFoundError: - continue - - # Try local resources last (mounted servers override local) + # Try local resources first (static resources take precedence) try: resource = await self._resource_manager.get_resource(uri_str) if self._should_enable_component(resource): @@ -1896,54 +1687,41 @@ async def _read_resource( content = await self._execute_resource(resource, uri_str) return [content] - # Try component providers (concrete resources) - ctx = context.fastmcp_context - if ctx is not None: - for provider in self._providers: - try: - resource = await provider.get_resource(ctx, uri_str) - if resource is not None and self._should_enable_component(resource): - content = await provider.read_resource(ctx, uri_str) - if content is not None: - return [content] - except ResourceError: - logger.exception(f"Error reading resource {uri_str!r}") - raise - except Exception as e: - logger.exception( - f"Error reading resource {uri_str!r} from provider" - ) - if self._mask_error_details: - raise ResourceError( - f"Error reading resource {uri_str!r}" - ) from e - raise ResourceError( - f"Error reading resource {uri_str!r}: {e}" - ) from e - - # Try component providers (templates) - if ctx is not None: - for provider in self._providers: - try: - template = await provider.get_resource_template(ctx, uri_str) - if template is not None and self._should_enable_component(template): - content = await provider.read_resource_template(ctx, uri_str) - if content is not None: - return [content] - except ResourceError: - logger.exception(f"Error reading resource {uri_str!r}") - raise - except Exception as e: - logger.exception( - f"Error reading resource {uri_str!r} from provider template" - ) - if self._mask_error_details: - raise ResourceError( - f"Error reading resource {uri_str!r}" - ) from e - raise ResourceError( - f"Error reading resource {uri_str!r}: {e}" - ) from e + # Try component providers in reverse order (later providers win) - concrete resources + for provider in reversed(self._providers): + try: + resource = await provider.get_resource(uri_str) + if resource is not None and self._should_enable_component(resource): + content = await provider.read_resource(uri_str) + if content is not None: + return [content] + except ResourceError: + logger.exception(f"Error reading resource {uri_str!r}") + raise + except Exception as e: + logger.exception(f"Error reading resource {uri_str!r} from provider") + if self._mask_error_details: + raise ResourceError(f"Error reading resource {uri_str!r}") from e + raise ResourceError(f"Error reading resource {uri_str!r}: {e}") from e + + # Try component providers in reverse order (templates) + for provider in reversed(self._providers): + try: + template = await provider.get_resource_template(uri_str) + if template is not None and self._should_enable_component(template): + content = await provider.read_resource_template(uri_str) + if content is not None: + return [content] + except ResourceError: + logger.exception(f"Error reading resource {uri_str!r}") + raise + except Exception as e: + logger.exception( + f"Error reading resource {uri_str!r} from provider template" + ) + if self._mask_error_details: + raise ResourceError(f"Error reading resource {uri_str!r}") from e + raise ResourceError(f"Error reading resource {uri_str!r}: {e}") from e raise NotFoundError(f"Unknown resource: {uri_str!r}") @@ -2021,29 +1799,7 @@ async def _get_prompt( ) -> PromptResult: name = context.message.name - # Try mounted servers in reverse order (later wins) - for mounted in reversed(self._mounted_servers): - try_name = name - if mounted.prefix: - if not name.startswith(f"{mounted.prefix}_"): - continue - try_name = name[len(mounted.prefix) + 1 :] - - try: - # First, get the prompt to check if parent's filter allows it - # Use get_prompt() instead of _prompt_manager.get_prompt() to support - # nested mounted servers (prompts mounted more than 2 levels deep) - prompt = await mounted.server.get_prompt(try_name) - if not self._should_enable_component(prompt): - # Parent filter blocks this prompt, continue searching - continue - return await mounted.server._get_prompt_content_middleware( - try_name, context.message.arguments - ) - except NotFoundError: - continue - - # Try local prompts (static prompts take precedence) + # Try local prompts first (static prompts take precedence) try: prompt = await self._prompt_manager.get_prompt(name) if self._should_enable_component(prompt): @@ -2053,26 +1809,24 @@ async def _get_prompt( except NotFoundError: pass - # Try component providers - ctx = context.fastmcp_context - if ctx is not None: - for provider in self._providers: - try: - prompt = await provider.get_prompt(ctx, name) - if prompt is not None and self._should_enable_component(prompt): - result = await provider.render_prompt( - ctx, name, context.message.arguments - ) - if result is not None: - return result - except PromptError: - logger.exception(f"Error rendering prompt {name!r}") - raise - except Exception as e: - logger.exception(f"Error rendering prompt {name!r} from provider") - if self._mask_error_details: - raise PromptError(f"Error rendering prompt {name!r}") from e - raise PromptError(f"Error rendering prompt {name!r}: {e}") from e + # Try providers in reverse order (later providers win) + for provider in reversed(self._providers): + try: + prompt = await provider.get_prompt(name) + if prompt is not None and self._should_enable_component(prompt): + result = await provider.render_prompt( + name, context.message.arguments + ) + if result is not None: + return result + except PromptError: + logger.exception(f"Error rendering prompt {name!r}") + raise + except Exception as e: + logger.exception(f"Error rendering prompt {name!r} from provider") + if self._mask_error_details: + raise PromptError(f"Error rendering prompt {name!r}") from e + raise PromptError(f"Error rendering prompt {name!r}: {e}") from e raise NotFoundError(f"Unknown prompt: {name!r}") @@ -2928,50 +2682,42 @@ def mount( and prompts are accessible with their original names. Multiple servers can be mounted without prefixes, and they will be tried in order until a match is found. - There are two modes for mounting servers: - 1. Direct mounting (default when server has no custom lifespan): The parent server - directly accesses the mounted server's objects in-memory for better performance. - In this mode, no client lifecycle events occur on the mounted server, including - lifespan execution. - - 2. Proxy mounting (default when server has a custom lifespan): The parent server - treats the mounted server as a separate entity and communicates with it via a - Client transport. This preserves all client-facing behaviors, including lifespan - execution, but with slightly higher overhead. + The mounted server's lifespan is executed when the parent server starts, and its + middleware chain is invoked for all operations (tool calls, resource reads, prompts). Args: server: The FastMCP server to mount. prefix: Optional prefix to use for the mounted server's objects. If None, the server's objects are accessible with their original names. - as_proxy: Whether to treat the mounted server as a proxy. If None (default), - automatically determined based on whether the server has a custom lifespan - (True if it has a custom lifespan, False otherwise). + as_proxy: Deprecated. Mounted servers now always have their lifespan and + middleware invoked. To create a proxy server, use FastMCP.as_proxy() + explicitly before mounting. tool_names: Optional mapping of original tool names to custom names. Use this to override prefixed names. Keys are the original tool names from the mounted server. """ - from fastmcp.server.proxy import FastMCPProxy + import warnings - # if as_proxy is not specified and the server has a custom lifespan, - # we should treat it as a proxy - if as_proxy is None: - as_proxy = server._lifespan != default_lifespan + from fastmcp.providers import MountedProvider - if as_proxy and not isinstance(server, FastMCPProxy): - server = FastMCP.as_proxy(server) + if as_proxy is not None: + warnings.warn( + "as_proxy is deprecated and will be removed in a future version. " + "Mounted servers now always have their lifespan and middleware invoked. " + "To create a proxy server, use FastMCP.as_proxy() explicitly.", + DeprecationWarning, + stacklevel=2, + ) + # Still honor the flag for backward compatibility + if as_proxy: + from fastmcp.server.proxy import FastMCPProxy - # Mark the server as mounted so it skips creating its own Docket/Worker. - # The parent's Docket handles task execution, avoiding race conditions - # with multiple workers competing for tasks from the same queue. - server._is_mounted = True + if not isinstance(server, FastMCPProxy): + server = FastMCP.as_proxy(server) - # Delegate mounting to all three managers - mounted_server = MountedServer( - prefix=prefix, - server=server, - tool_names=tool_names, - ) - self._mounted_servers.append(mounted_server) + # Create a MountedProvider and add it to providers + provider = MountedProvider(server, prefix, tool_names) + self._providers.append(provider) async def import_server( self, @@ -3225,13 +2971,6 @@ def generate_name(cls, name: str | None = None) -> str: return f"{class_name}-{name}-{secrets.token_hex(2)}" -@dataclass -class MountedServer: - prefix: str | None - server: FastMCP[Any] - tool_names: dict[str, str] | None = None - - def add_resource_prefix(uri: str, prefix: str) -> str: """Add a prefix to a resource URI using path formatting (resource://prefix/path). diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 2d43772b1b..521a35408e 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -1092,7 +1092,7 @@ def test_infer_composite_client(self): 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.""" diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index 5c23eaf80e..c01d1baa3d 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -7,6 +7,7 @@ from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.client.transports import FastMCPTransport, SSETransport +from fastmcp.providers import MountedProvider from fastmcp.server.proxy import FastMCPProxy from fastmcp.tools.tool import Tool from fastmcp.tools.tool_transform import TransformedTool @@ -308,21 +309,16 @@ def working_prompt() -> str: 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 ) @@ -852,7 +848,9 @@ async def test_as_proxy_defaults_false(self): 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 +858,9 @@ async def test_as_proxy_false(self): 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 +868,17 @@ async def test_as_proxy_true(self): 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 +889,10 @@ async def server_lifespan(mcp: FastMCP): 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 +901,9 @@ async def test_as_proxy_ignored_for_proxy_mounts_default(self): 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 +912,9 @@ async def test_as_proxy_ignored_for_proxy_mounts_false(self): 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 +923,9 @@ async def test_as_proxy_ignored_for_proxy_mounts_true(self): 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 +1137,30 @@ async def test_route(request): 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.""" diff --git a/tests/server/test_providers.py b/tests/server/test_providers.py index e5489cbb5d..19c3fa1ffa 100644 --- a/tests/server/test_providers.py +++ b/tests/server/test_providers.py @@ -13,7 +13,6 @@ 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.tools.tool import Tool, ToolResult @@ -43,15 +42,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 +60,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 +151,9 @@ async def test_list_tools_calls_provider_each_time( 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 +212,12 @@ async def test_call_tool_uses_get_tool_for_efficient_lookup( 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 +301,13 @@ async def test_default_get_tool_works(self): ) 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 +388,7 @@ class CustomCallProvider(Provider): """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 +397,20 @@ def __init__(self): 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 +434,7 @@ async def test_read_resource_default_implementation(self): """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 +459,7 @@ async def test_read_resource_custom_implementation(self): 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 +468,7 @@ async def list_resources(self, context: Context) -> Sequence[Resource]: ) ] - 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 +488,7 @@ async def test_read_resource_template_default(self): """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 +511,7 @@ async def test_render_prompt_default_implementation(self): """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 +536,7 @@ async def test_render_prompt_custom_implementation(self): 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 +546,7 @@ async def list_prompts(self, context: Context) -> Sequence[Prompt]: ] 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 diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py index 128bdea79b..500852c0a5 100644 --- a/tests/tools/test_tool_manager.py +++ b/tests/tools/test_tool_manager.py @@ -1057,8 +1057,8 @@ async def test_mounted_components_raise_on_load_error_default_false(self): # 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 @@ async def test_mounted_components_raise_on_load_error_true(self): # 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() From ba70dc5ba5bc9e30ad5033aff7ca2d0e69b57ad8 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 17 Dec 2025 09:35:51 -0500 Subject: [PATCH 2/8] Fix control flow in ComponentService resource methods --- .../contrib/component_manager/component_service.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/fastmcp/contrib/component_manager/component_service.py b/src/fastmcp/contrib/component_manager/component_service.py index 21a9f5369b..437ab20cea 100644 --- a/src/fastmcp/contrib/component_manager/component_service.py +++ b/src/fastmcp/contrib/component_manager/component_service.py @@ -116,8 +116,8 @@ async def _enable_resource(self, key: str) -> Resource | ResourceTemplate: Resource | ResourceTemplate ) = await mounted_service._enable_resource(resource_key) return mounted_resource - else: - continue + else: + continue raise NotFoundError(f"Unknown resource: {key}") async def _disable_resource(self, key: str) -> Resource | ResourceTemplate: @@ -152,8 +152,8 @@ async def _disable_resource(self, key: str) -> Resource | ResourceTemplate: Resource | ResourceTemplate ) = await mounted_service._disable_resource(resource_key) return mounted_resource - else: - continue + else: + continue raise NotFoundError(f"Unknown resource: {key}") async def _enable_prompt(self, key: str) -> Prompt: From 5466bb7c3ce28c028fe675964273be19ff4c9f0e Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 17 Dec 2025 09:47:46 -0500 Subject: [PATCH 3/8] Move providers to server/providers --- examples/providers/sqlite/server.py | 3 ++- src/fastmcp/__init__.py | 2 -- src/fastmcp/contrib/component_manager/component_service.py | 2 +- src/fastmcp/{ => server}/providers/__init__.py | 7 ++++--- src/fastmcp/{ => server}/providers/base.py | 3 ++- src/fastmcp/{ => server}/providers/mounted.py | 4 ++-- src/fastmcp/server/server.py | 4 ++-- tests/server/test_mount.py | 2 +- tests/server/test_providers.py | 3 ++- 9 files changed, 16 insertions(+), 14 deletions(-) rename src/fastmcp/{ => server}/providers/__init__.py (80%) rename src/fastmcp/{ => server}/providers/base.py (99%) rename src/fastmcp/{ => server}/providers/mounted.py (99%) diff --git a/examples/providers/sqlite/server.py b/examples/providers/sqlite/server.py index d8d7f99b29..3caa8bde06 100644 --- a/examples/providers/sqlite/server.py +++ b/examples/providers/sqlite/server.py @@ -20,8 +20,9 @@ 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" diff --git a/src/fastmcp/__init__.py b/src/fastmcp/__init__.py index 50dac02e09..14c0abc5b4 100644 --- a/src/fastmcp/__init__.py +++ b/src/fastmcp/__init__.py @@ -14,7 +14,6 @@ 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 @@ "Client", "Context", "FastMCP", - "Provider", "settings", ] diff --git a/src/fastmcp/contrib/component_manager/component_service.py b/src/fastmcp/contrib/component_manager/component_service.py index 437ab20cea..c8ae1c0a0a 100644 --- a/src/fastmcp/contrib/component_manager/component_service.py +++ b/src/fastmcp/contrib/component_manager/component_service.py @@ -5,9 +5,9 @@ from fastmcp.exceptions import NotFoundError from fastmcp.prompts.prompt import Prompt -from fastmcp.providers import MountedProvider from fastmcp.resources.resource import Resource from fastmcp.resources.template import ResourceTemplate +from fastmcp.server.providers import MountedProvider from fastmcp.server.server import FastMCP, has_resource_prefix, remove_resource_prefix from fastmcp.tools.tool import Tool from fastmcp.utilities.logging import get_logger diff --git a/src/fastmcp/providers/__init__.py b/src/fastmcp/server/providers/__init__.py similarity index 80% rename from src/fastmcp/providers/__init__.py rename to src/fastmcp/server/providers/__init__.py index 145ff58277..f23f01b2ff 100644 --- a/src/fastmcp/providers/__init__.py +++ b/src/fastmcp/server/providers/__init__.py @@ -5,7 +5,8 @@ Example: ```python - from fastmcp import FastMCP, Provider + from fastmcp import FastMCP + from fastmcp.server.providers import Provider from fastmcp.tools import Tool class DatabaseProvider(Provider): @@ -24,8 +25,8 @@ async def get_tool(self, name: str) -> Tool | None: ``` """ -from fastmcp.providers.base import Components, Provider -from fastmcp.providers.mounted import MountedProvider +from fastmcp.server.providers.base import Components, Provider +from fastmcp.server.providers.mounted import MountedProvider __all__ = [ "Components", diff --git a/src/fastmcp/providers/base.py b/src/fastmcp/server/providers/base.py similarity index 99% rename from src/fastmcp/providers/base.py rename to src/fastmcp/server/providers/base.py index 2b7a805dd2..3fcbf5e0c3 100644 --- a/src/fastmcp/providers/base.py +++ b/src/fastmcp/server/providers/base.py @@ -5,7 +5,8 @@ Example: ```python - from fastmcp import FastMCP, Provider + from fastmcp import FastMCP + from fastmcp.server.providers import Provider from fastmcp.tools import Tool class DatabaseProvider(Provider): diff --git a/src/fastmcp/providers/mounted.py b/src/fastmcp/server/providers/mounted.py similarity index 99% rename from src/fastmcp/providers/mounted.py rename to src/fastmcp/server/providers/mounted.py index 89e0b3da09..0f098b1f21 100644 --- a/src/fastmcp/providers/mounted.py +++ b/src/fastmcp/server/providers/mounted.py @@ -13,9 +13,9 @@ from fastmcp.exceptions import NotFoundError from fastmcp.prompts.prompt import Prompt, PromptResult -from fastmcp.providers.base import Components, Provider from fastmcp.resources.resource import Resource, ResourceContent from fastmcp.resources.template import ResourceTemplate +from fastmcp.server.providers.base import Components, Provider from fastmcp.tools.tool import Tool, ToolResult if TYPE_CHECKING: @@ -36,7 +36,7 @@ class MountedProvider(Provider): Example: ```python from fastmcp import FastMCP - from fastmcp.providers import MountedProvider + from fastmcp.server.providers import MountedProvider main = FastMCP("Main") sub = FastMCP("Sub") diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 47a7400707..4d1dc6966f 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -69,7 +69,6 @@ from fastmcp.prompts import Prompt from fastmcp.prompts.prompt import FunctionPrompt, PromptResult from fastmcp.prompts.prompt_manager import PromptManager -from fastmcp.providers import Provider from fastmcp.resources.resource import FunctionResource, Resource, ResourceContent from fastmcp.resources.resource_manager import ResourceManager from fastmcp.resources.template import FunctionResourceTemplate, ResourceTemplate @@ -82,6 +81,7 @@ ) from fastmcp.server.low_level import LowLevelServer from fastmcp.server.middleware import Middleware, MiddlewareContext +from fastmcp.server.providers import Provider from fastmcp.server.tasks.capabilities import get_task_capabilities from fastmcp.server.tasks.config import TaskConfig from fastmcp.server.tasks.handlers import ( @@ -2698,7 +2698,7 @@ def mount( """ import warnings - from fastmcp.providers import MountedProvider + from fastmcp.server.providers import MountedProvider if as_proxy is not None: warnings.warn( diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index c01d1baa3d..032384be07 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -7,7 +7,7 @@ from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.client.transports import FastMCPTransport, SSETransport -from fastmcp.providers import MountedProvider +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 diff --git a/tests/server/test_providers.py b/tests/server/test_providers.py index 19c3fa1ffa..dee1f32736 100644 --- a/tests/server/test_providers.py +++ b/tests/server/test_providers.py @@ -7,12 +7,13 @@ 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.providers import Provider from fastmcp.tools.tool import Tool, ToolResult From 58d74849250b9ace57da5ebc3e7834a368903c03 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:46:10 -0500 Subject: [PATCH 4/8] Ensure MountedProvider get_* methods go through middleware --- src/fastmcp/server/providers/mounted.py | 68 ++++++++++--------------- 1 file changed, 28 insertions(+), 40 deletions(-) diff --git a/src/fastmcp/server/providers/mounted.py b/src/fastmcp/server/providers/mounted.py index 0f098b1f21..944093c1e7 100644 --- a/src/fastmcp/server/providers/mounted.py +++ b/src/fastmcp/server/providers/mounted.py @@ -181,20 +181,12 @@ async def list_tools(self) -> Sequence[Tool]: return [self._prefix_tool(tool) for tool in tools] async def get_tool(self, name: str) -> Tool | None: - """Get a tool by name, checking if it matches our prefix pattern.""" - unprefixed = self._strip_tool_prefix(name) - if unprefixed is None: - return None # Doesn't match this provider - - try: - tool = await self.server.get_tool(unprefixed) - # Return with prefixed key for parent's filter checking - prefixed_key = name # The name we received is already the prefixed form - if tool.key != prefixed_key: - tool = tool.model_copy(key=prefixed_key) - return tool - except NotFoundError: + """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] @@ -216,24 +208,28 @@ async def list_resources(self) -> Sequence[Resource]: return [self._prefix_resource(resource) for resource in resources] async def get_resource(self, uri: str) -> Resource | None: - """Get a concrete resource by URI, checking if it matches our prefix pattern. + """Get a resource by URI, going through middleware. - This only returns concrete resources, not resources created from templates. - For templates, use get_resource_template() instead. + Checks concrete resources first, then templates. """ - unprefixed = self._strip_resource_prefix(uri) - if unprefixed is None: - return None # Doesn't match this provider - - # Only check concrete resources (not templates that match the URI) - # This preserves the original template for task execution - resources = await self.server.get_resources() - if unprefixed not in resources: + # Early exit if URI doesn't match our prefix pattern + if self._strip_resource_prefix(uri) is None: return None - resource = resources[unprefixed] - # Return with prefixed key for parent's filter checking - return self._prefix_resource(resource) + # Check concrete resources first + resources = await self.list_resources() + resource = next((r for r in resources if r.key == uri), None) + if resource: + return resource + + # Also check templates + template = await self.get_resource_template(uri) + if template is None: + return None + params = template.matches(uri) + if params is None: + return None + return await template.create_resource(uri, params) async def read_resource(self, uri: str) -> ResourceContent | None: """Read a resource through the mounted server's middleware chain.""" @@ -285,20 +281,12 @@ async def list_prompts(self) -> Sequence[Prompt]: return [self._prefix_prompt(prompt) for prompt in prompts] async def get_prompt(self, name: str) -> Prompt | None: - """Get a prompt by name, checking if it matches our prefix pattern.""" - unprefixed = self._strip_tool_prefix(name) - if unprefixed is None: - return None # Doesn't match this provider - - try: - prompt = await self.server.get_prompt(unprefixed) - # Return with prefixed key for parent's filter checking - prefixed_key = name - if prompt.key != prefixed_key: - prompt = prompt.model_copy(key=prefixed_key) - return prompt - except NotFoundError: + """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 From cacce67b0ac62d3425da2eed9a50a5b5db0bd575 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:51:38 -0500 Subject: [PATCH 5/8] 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. --- src/fastmcp/server/providers/mounted.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/src/fastmcp/server/providers/mounted.py b/src/fastmcp/server/providers/mounted.py index 944093c1e7..485fc68b77 100644 --- a/src/fastmcp/server/providers/mounted.py +++ b/src/fastmcp/server/providers/mounted.py @@ -208,28 +208,18 @@ async def list_resources(self) -> Sequence[Resource]: return [self._prefix_resource(resource) for resource in resources] async def get_resource(self, uri: str) -> Resource | None: - """Get a resource by URI, going through middleware. + """Get a concrete resource by URI, going through middleware. - Checks concrete resources first, then templates. + 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 - # Check concrete resources first + # Only check concrete resources (not templates) resources = await self.list_resources() - resource = next((r for r in resources if r.key == uri), None) - if resource: - return resource - - # Also check templates - template = await self.get_resource_template(uri) - if template is None: - return None - params = template.matches(uri) - if params is None: - return None - return await template.create_resource(uri, params) + 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.""" From e6b16ff13913e79cd32d4eee5b6d495ac89a7e45 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:48:42 -0500 Subject: [PATCH 6/8] 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 --- .../component_manager/component_service.py | 3 +- src/fastmcp/server/providers/mounted.py | 50 ++++++- src/fastmcp/server/server.py | 135 ++---------------- tests/server/test_server.py | 4 +- 4 files changed, 61 insertions(+), 131 deletions(-) diff --git a/src/fastmcp/contrib/component_manager/component_service.py b/src/fastmcp/contrib/component_manager/component_service.py index c8ae1c0a0a..1b4277da3c 100644 --- a/src/fastmcp/contrib/component_manager/component_service.py +++ b/src/fastmcp/contrib/component_manager/component_service.py @@ -8,7 +8,8 @@ from fastmcp.resources.resource import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.providers import MountedProvider -from fastmcp.server.server import FastMCP, has_resource_prefix, remove_resource_prefix +from fastmcp.server.providers.mounted import has_resource_prefix, remove_resource_prefix +from fastmcp.server.server import FastMCP from fastmcp.tools.tool import Tool from fastmcp.utilities.logging import get_logger diff --git a/src/fastmcp/server/providers/mounted.py b/src/fastmcp/server/providers/mounted.py index 485fc68b77..8ce2527674 100644 --- a/src/fastmcp/server/providers/mounted.py +++ b/src/fastmcp/server/providers/mounted.py @@ -7,6 +7,7 @@ from __future__ import annotations +import re from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Any @@ -21,6 +22,47 @@ if TYPE_CHECKING: from fastmcp.server.server import FastMCP +# 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. @@ -74,6 +116,8 @@ def __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()} # ------------------------------------------------------------------------- @@ -111,9 +155,6 @@ def _add_resource_prefix(self, uri: str) -> str: """Add prefix to a resource URI.""" if not self.prefix: return uri - # Import here to avoid circular dependency - from fastmcp.server.server import add_resource_prefix - return add_resource_prefix(uri, self.prefix) def _strip_resource_prefix(self, uri: str) -> str | None: @@ -125,9 +166,6 @@ def _strip_resource_prefix(self, uri: str) -> str | None: """ if not self.prefix: return uri - # Import here to avoid circular dependency - from fastmcp.server.server import has_resource_prefix, remove_resource_prefix - if not has_resource_prefix(uri, self.prefix): return None return remove_resource_prefix(uri, self.prefix) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 4d1dc6966f..551c6c0c7b 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -2728,6 +2728,10 @@ async def import_server( Import the MCP objects from another FastMCP server into this one, optionally with a given prefix. + .. deprecated:: + Use :meth:`mount` instead. ``import_server`` will be removed in a + future version. + Note that when a server is *imported*, its objects are immediately registered to the importing server. This is a one-time operation and future changes to the imported server will not be reflected in the @@ -2755,6 +2759,16 @@ async def import_server( prefix: Optional prefix to use for the imported server's objects. If None, objects are imported with their original names. """ + import warnings + + from fastmcp.server.providers.mounted import add_resource_prefix + + warnings.warn( + "import_server is deprecated, use mount() instead", + DeprecationWarning, + stacklevel=2, + ) + # Import tools from the server for key, tool in (await server.get_tools()).items(): if prefix: @@ -2969,124 +2983,3 @@ def generate_name(cls, name: str | None = None) -> str: return f"{class_name}-{secrets.token_hex(2)}" else: return f"{class_name}-{name}-{secrets.token_hex(2)}" - - -def add_resource_prefix(uri: str, prefix: str) -> str: - """Add a prefix to a resource URI using path formatting (resource://prefix/path). - - Args: - uri: The original resource URI - prefix: The prefix to add - - Returns: - The resource URI with the prefix added - - Examples: - ```python - add_resource_prefix("resource://path/to/resource", "prefix") - "resource://prefix/path/to/resource" - ``` - With absolute path: - ```python - add_resource_prefix("resource:///absolute/path", "prefix") - "resource://prefix//absolute/path" - ``` - - Raises: - ValueError: If the URI doesn't match the expected protocol://path format - """ - if not prefix: - return uri - - # Split the URI into protocol and path - match = URI_PATTERN.match(uri) - if not match: - raise ValueError(f"Invalid URI format: {uri}. Expected protocol://path format.") - - protocol, path = match.groups() - - # Add the prefix to the path - return f"{protocol}{prefix}/{path}" - - -def remove_resource_prefix(uri: str, prefix: str) -> str: - """Remove a prefix from a resource URI. - - Args: - uri: The resource URI with a prefix - prefix: The prefix to remove - - Returns: - The resource URI with the prefix removed - - Examples: - ```python - remove_resource_prefix("resource://prefix/path/to/resource", "prefix") - "resource://path/to/resource" - ``` - With absolute path: - ```python - remove_resource_prefix("resource://prefix//absolute/path", "prefix") - "resource:///absolute/path" - ``` - - Raises: - ValueError: If the URI doesn't match the expected protocol://path format - """ - if not prefix: - return uri - - # Split the URI into protocol and path - match = URI_PATTERN.match(uri) - if not match: - raise ValueError(f"Invalid URI format: {uri}. Expected protocol://path format.") - - protocol, path = match.groups() - - # Check if the path starts with the prefix followed by a / - prefix_pattern = f"^{re.escape(prefix)}/(.*?)$" - path_match = re.match(prefix_pattern, path) - if not path_match: - return uri - - # Return the URI without the prefix - 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. - - Args: - uri: The resource URI to check - prefix: The prefix to look for - - Returns: - True if the URI has the specified prefix, False otherwise - - Examples: - ```python - has_resource_prefix("resource://prefix/path/to/resource", "prefix") - True - ``` - With other path: - ```python - has_resource_prefix("resource://other/path/to/resource", "prefix") - False - ``` - - Raises: - ValueError: If the URI doesn't match the expected protocol://path format - """ - if not prefix: - return False - - # Split the URI into protocol and path - match = URI_PATTERN.match(uri) - if not match: - raise ValueError(f"Invalid URI format: {uri}. Expected protocol://path format.") - - _, path = match.groups() - - # Check if the path starts with the prefix followed by a / - prefix_pattern = f"^{re.escape(prefix)}/" - return bool(re.match(prefix_pattern, path)) diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 16e3a33e53..22e884f0e7 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -11,7 +11,7 @@ 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 @@ async def test_mounted_server_matching_and_stripping( 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 From 5931612b66b3cc183e16deaf2794b9c519f6ef8d Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 17 Dec 2025 21:56:55 -0500 Subject: [PATCH 7/8] 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 --- .../component_manager/component_service.py | 99 ++++----- src/fastmcp/server/server.py | 26 +-- tests/server/test_mount.py | 199 ++++++++++++++---- 3 files changed, 206 insertions(+), 118 deletions(-) diff --git a/src/fastmcp/contrib/component_manager/component_service.py b/src/fastmcp/contrib/component_manager/component_service.py index 1b4277da3c..4b991d86ef 100644 --- a/src/fastmcp/contrib/component_manager/component_service.py +++ b/src/fastmcp/contrib/component_manager/component_service.py @@ -8,7 +8,6 @@ from fastmcp.resources.resource import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.providers import MountedProvider -from fastmcp.server.providers.mounted import has_resource_prefix, remove_resource_prefix from fastmcp.server.server import FastMCP from fastmcp.tools.tool import Tool from fastmcp.utilities.logging import get_logger @@ -43,16 +42,13 @@ async def _enable_tool(self, key: str) -> Tool: return tool # 2. Check mounted servers via MountedProvider - for provider in reversed(self._server._providers): + for provider in self._server._providers: if isinstance(provider, MountedProvider): - if provider.prefix: - if key.startswith(f"{provider.prefix}_"): - tool_key = key.removeprefix(f"{provider.prefix}_") - mounted_service = ComponentService(provider.server) - tool = await mounted_service._enable_tool(tool_key) - return tool - else: - continue + 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 raise NotFoundError(f"Unknown tool: {key}") async def _disable_tool(self, key: str) -> Tool: @@ -73,16 +69,13 @@ async def _disable_tool(self, key: str) -> Tool: return tool # 2. Check mounted servers via MountedProvider - for provider in reversed(self._server._providers): + for provider in self._server._providers: if isinstance(provider, MountedProvider): - if provider.prefix: - if key.startswith(f"{provider.prefix}_"): - tool_key = key.removeprefix(f"{provider.prefix}_") - mounted_service = ComponentService(provider.server) - tool = await mounted_service._disable_tool(tool_key) - return tool - else: - continue + 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 raise NotFoundError(f"Unknown tool: {key}") async def _enable_resource(self, key: str) -> Resource | ResourceTemplate: @@ -107,18 +100,15 @@ async def _enable_resource(self, key: str) -> Resource | ResourceTemplate: return template # 2. Check mounted servers via MountedProvider - for provider in reversed(self._server._providers): + for provider in self._server._providers: if isinstance(provider, MountedProvider): - if provider.prefix: - if has_resource_prefix(key, provider.prefix): - resource_key = remove_resource_prefix(key, provider.prefix) - mounted_service = ComponentService(provider.server) - mounted_resource: ( - Resource | ResourceTemplate - ) = await mounted_service._enable_resource(resource_key) - return mounted_resource - else: - continue + 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(unprefixed) + return mounted_resource raise NotFoundError(f"Unknown resource: {key}") async def _disable_resource(self, key: str) -> Resource | ResourceTemplate: @@ -143,18 +133,15 @@ async def _disable_resource(self, key: str) -> Resource | ResourceTemplate: return template # 2. Check mounted servers via MountedProvider - for provider in reversed(self._server._providers): + for provider in self._server._providers: if isinstance(provider, MountedProvider): - if provider.prefix: - if has_resource_prefix(key, provider.prefix): - resource_key = remove_resource_prefix(key, provider.prefix) - mounted_service = ComponentService(provider.server) - mounted_resource: ( - Resource | ResourceTemplate - ) = await mounted_service._disable_resource(resource_key) - return mounted_resource - else: - continue + 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(unprefixed) + return mounted_resource raise NotFoundError(f"Unknown resource: {key}") async def _enable_prompt(self, key: str) -> Prompt: @@ -175,16 +162,13 @@ async def _enable_prompt(self, key: str) -> Prompt: return prompt # 2. Check mounted servers via MountedProvider - for provider in reversed(self._server._providers): + for provider in self._server._providers: if isinstance(provider, MountedProvider): - if provider.prefix: - if key.startswith(f"{provider.prefix}_"): - prompt_key = key.removeprefix(f"{provider.prefix}_") - mounted_service = ComponentService(provider.server) - prompt = await mounted_service._enable_prompt(prompt_key) - return prompt - else: - continue + 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 raise NotFoundError(f"Unknown prompt: {key}") async def _disable_prompt(self, key: str) -> Prompt: @@ -204,14 +188,11 @@ async def _disable_prompt(self, key: str) -> Prompt: return prompt # 2. Check mounted servers via MountedProvider - for provider in reversed(self._server._providers): + for provider in self._server._providers: if isinstance(provider, MountedProvider): - if provider.prefix: - if key.startswith(f"{provider.prefix}_"): - prompt_key = key.removeprefix(f"{provider.prefix}_") - mounted_service = ComponentService(provider.server) - prompt = await mounted_service._disable_prompt(prompt_key) - return prompt - else: - continue + 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 raise NotFoundError(f"Unknown prompt: {key}") diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 551c6c0c7b..6d29863cf8 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -227,9 +227,6 @@ def __init__( self._additional_http_routes: list[BaseRoute] = [] self._providers: list[Provider] = list(providers or []) - self._is_mounted: bool = ( - False # Set to True when this server is mounted on another - ) self._tool_manager: ToolManager = ToolManager( duplicate_behavior=on_duplicate_tools, mask_error_details=mask_error_details, @@ -428,13 +425,6 @@ async def _docket_lifespan(self) -> AsyncIterator[None]: server_token = _current_server.set(weakref.ref(self)) try: - # For directly mounted servers, the parent's Docket/Worker handles all - # task execution. Skip creating our own to avoid race conditions with - # multiple workers competing for tasks from the same queue. - if self._is_mounted: - yield - return - # Create Docket instance using configured name and URL async with Docket( name=settings.docket.name, @@ -1569,8 +1559,8 @@ async def _call_tool( except NotFoundError: pass - # Try component providers in reverse order (later providers win) - for provider in reversed(self._providers): + # Try component providers (first registered wins) + for provider in self._providers: try: tool = await provider.get_tool(tool_name) if tool is not None and self._should_enable_component(tool): @@ -1687,8 +1677,8 @@ async def _read_resource( content = await self._execute_resource(resource, uri_str) return [content] - # Try component providers in reverse order (later providers win) - concrete resources - for provider in reversed(self._providers): + # Try component providers (first registered wins) - concrete resources + for provider in self._providers: try: resource = await provider.get_resource(uri_str) if resource is not None and self._should_enable_component(resource): @@ -1704,8 +1694,8 @@ async def _read_resource( raise ResourceError(f"Error reading resource {uri_str!r}") from e raise ResourceError(f"Error reading resource {uri_str!r}: {e}") from e - # Try component providers in reverse order (templates) - for provider in reversed(self._providers): + # Try component providers (first registered wins) - templates + for provider in self._providers: try: template = await provider.get_resource_template(uri_str) if template is not None and self._should_enable_component(template): @@ -1809,8 +1799,8 @@ async def _get_prompt( except NotFoundError: pass - # Try providers in reverse order (later providers win) - for provider in reversed(self._providers): + # Try component providers (first registered wins) + for provider in self._providers: try: prompt = await provider.get_prompt(name) if prompt is not None and self._should_enable_component(prompt): diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index 032384be07..7026429a5c 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -323,10 +323,13 @@ def working_prompt() -> str: 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") @@ -344,18 +347,18 @@ def second_shared_tool() -> str: 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") @@ -373,18 +376,18 @@ def second_shared_tool() -> str: 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") @@ -402,18 +405,18 @@ def second_resource(): 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") @@ -431,7 +434,7 @@ def second_resource(): 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 @@ -439,12 +442,12 @@ def second_resource(): 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") @@ -462,7 +465,7 @@ def second_template(user_id: str): 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 @@ -470,12 +473,12 @@ def second_template(user_id: str): 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") @@ -493,7 +496,7 @@ def second_template(user_id: str): 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 @@ -501,12 +504,12 @@ def second_template(user_id: str): 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") @@ -524,19 +527,19 @@ def second_shared_prompt() -> str: 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") @@ -554,7 +557,7 @@ def second_shared_prompt() -> str: 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 @@ -562,10 +565,10 @@ def second_shared_prompt() -> str: 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: @@ -1386,3 +1389,117 @@ def original_tool() -> str: 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 From 7edb1ba79b5efc0fe8c40c9fa7d0d385227adaef Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 17 Dec 2025 22:08:39 -0500 Subject: [PATCH 8/8] 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 --- src/fastmcp/server/providers/__init__.py | 3 +- src/fastmcp/server/providers/base.py | 31 +++++++++++++----- src/fastmcp/server/providers/mounted.py | 34 +++++++++++--------- src/fastmcp/server/server.py | 40 ++++++++++++++++-------- 4 files changed, 72 insertions(+), 36 deletions(-) diff --git a/src/fastmcp/server/providers/__init__.py b/src/fastmcp/server/providers/__init__.py index f23f01b2ff..94d0a4397e 100644 --- a/src/fastmcp/server/providers/__init__.py +++ b/src/fastmcp/server/providers/__init__.py @@ -25,11 +25,12 @@ async def get_tool(self, name: str) -> Tool | None: ``` """ -from fastmcp.server.providers.base import Components, Provider +from fastmcp.server.providers.base import Components, Provider, TaskComponents from fastmcp.server.providers.mounted import MountedProvider __all__ = [ "Components", "MountedProvider", "Provider", + "TaskComponents", ] diff --git a/src/fastmcp/server/providers/base.py b/src/fastmcp/server/providers/base.py index 3fcbf5e0c3..acf6a31cd5 100644 --- a/src/fastmcp/server/providers/base.py +++ b/src/fastmcp/server/providers/base.py @@ -31,20 +31,23 @@ async def get_tool(self, name: str) -> Tool | None: from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any from fastmcp.prompts.prompt import Prompt, PromptResult from fastmcp.resources.resource import Resource, ResourceContent from fastmcp.resources.template import ResourceTemplate 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.tools.tool import FunctionTool + @dataclass class Components: - """Collection of MCP components. - - Used by get_tasks() to return task-eligible components for Docket registration. - """ + """Collection of MCP components.""" tools: Sequence[Tool] = () resources: Sequence[Resource] = () @@ -52,6 +55,20 @@ class Components: 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: """Base class for dynamic component providers. @@ -225,7 +242,7 @@ async def render_prompt( # Task registration # ------------------------------------------------------------------------- - async def get_tasks(self) -> Components: + async def get_tasks(self) -> TaskComponents: """Return components that should be registered as background tasks. Override to customize which components are task-eligible. @@ -244,7 +261,7 @@ async def get_tasks(self) -> Components: all_templates = await self.list_resource_templates() all_prompts = await self.list_prompts() - return Components( + return TaskComponents( tools=[ t for t in all_tools diff --git a/src/fastmcp/server/providers/mounted.py b/src/fastmcp/server/providers/mounted.py index 8ce2527674..490e8fddd9 100644 --- a/src/fastmcp/server/providers/mounted.py +++ b/src/fastmcp/server/providers/mounted.py @@ -16,11 +16,15 @@ 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 Components, Provider +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"^([^:]+://)(.*?)$") @@ -330,7 +334,7 @@ async def render_prompt( # Task registration # ------------------------------------------------------------------------- - async def get_tasks(self) -> Components: + 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 @@ -342,46 +346,46 @@ async def get_tasks(self) -> Components: from fastmcp.resources.template import FunctionResourceTemplate from fastmcp.tools.tool import FunctionTool - tools: list[Tool] = [] - resources: list[Resource] = [] - templates: list[ResourceTemplate] = [] - prompts: list[Prompt] = [] + 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)) + 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)) + 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)) + 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)) + 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) - resources.extend(self._prefix_resource(r) for r in nested.resources) - templates.extend(self._prefix_template(t) for t in nested.templates) - prompts.extend(self._prefix_prompt(p) for p in nested.prompts) + 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 Components( + return TaskComponents( tools=tools, resources=resources, templates=templates, prompts=prompts ) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 6d29863cf8..f77d43435a 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -467,19 +467,33 @@ async def _docket_lifespan(self) -> AsyncIterator[None]: # Register provider components for provider in self._providers: - tasks = await provider.get_tasks() - for tool in tasks.tools: - named_fn = _create_named_fn_wrapper(tool.fn, tool.key) # type: ignore[attr-defined] - docket.register(named_fn) - for resource in tasks.resources: - named_fn = _create_named_fn_wrapper(resource.fn, resource.name) # type: ignore[attr-defined] - docket.register(named_fn) - for template in tasks.templates: - named_fn = _create_named_fn_wrapper(template.fn, template.name) # type: ignore[attr-defined] - docket.register(named_fn) - for prompt in tasks.prompts: - named_fn = _create_named_fn_wrapper(prompt.fn, prompt.key) # type: ignore[attr-defined] - docket.register(named_fn) + try: + tasks = await provider.get_tasks() + for tool in tasks.tools: + named_fn = _create_named_fn_wrapper(tool.fn, tool.key) + docket.register(named_fn) + for resource in tasks.resources: + named_fn = _create_named_fn_wrapper( + resource.fn, resource.name + ) + docket.register(named_fn) + for template in tasks.templates: + named_fn = _create_named_fn_wrapper( + template.fn, template.name + ) + docket.register(named_fn) + for prompt in tasks.prompts: + named_fn = _create_named_fn_wrapper(prompt.fn, prompt.key) + docket.register(named_fn) + except Exception as e: + provider_name = getattr( + provider, "server", provider + ).__class__.__name__ + logger.warning( + f"Failed to register tasks from provider {provider_name!r}: {e}" + ) + if fastmcp.settings.mounted_components_raise_on_load_error: + raise # Set Docket in ContextVar so CurrentDocket can access it docket_token = _current_docket.set(docket)