mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Convert mounted servers to MountedProvider (#2635)
* Simplify Provider interface and consolidate docket registration - Remove get_http_routes from Provider (unused) - Remove ProviderLifespanConfig, _base_lifespan, _register_tasks - Remove supports_tasks flag from Provider.__init__ - Consolidate all docket registration in server._docket_lifespan() - Simplify lifespan() to take no parameters - Move MountedProvider to separate module * Fix control flow in ComponentService resource methods * Move providers to server/providers * Ensure MountedProvider get_* methods go through middleware * Fix get_resource to only return concrete resources Reverts template-checking in get_resource that broke task execution. Tasks need access to the original template, not instantiated resources. * Move prefix utilities into mounted.py, deprecate import_server - Add resource prefix functions (add/remove/has_resource_prefix) to mounted.py - Deprecate import_server with warning to use mount() instead - Add tool_names uniqueness validation in MountedProvider * Fix provider iteration order and remove dead _is_mounted flag - Remove unused _is_mounted flag (MountedProvider.lifespan() calls _lifespan not _lifespan_manager, so the flag was never checked) - Fix provider iteration: change reversed() to forward order in execution methods (_call_tool, _read_resource_middleware, _get_prompt_content_middleware) to match documented "first non-None wins" semantics - Fix ComponentService to handle prefix-less mounted servers using _strip_tool_prefix()/_strip_resource_prefix() methods - Update conflict resolution tests to expect first-registered provider wins - Add regression tests for Docket behavior and prefix-less ComponentService * Add TaskComponents type and exception handling for provider task registration - Create TaskComponents dataclass with FunctionTool/FunctionResource/etc. types for proper typing of get_tasks() return value - Add try/except wrapper around provider.get_tasks() in _docket_lifespan for consistent error handling (warn + continue or raise based on settings) - Remove type: ignore comments from server.py task registration loop
This commit is contained in:
parent
0186121f4b
commit
ede8ff6703
14 changed files with 1228 additions and 993 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -20,8 +20,9 @@ from typing import Any
|
|||
import aiosqlite
|
||||
from rich import print
|
||||
|
||||
from fastmcp import Client, FastMCP, Provider
|
||||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.server.context import Context
|
||||
from fastmcp.server.providers import Provider
|
||||
from fastmcp.tools.tool import Tool, ToolResult
|
||||
|
||||
DB_PATH = Path(__file__).parent / "tools.db"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ if settings.log_enabled:
|
|||
|
||||
from fastmcp.server.server import FastMCP
|
||||
from fastmcp.server.context import Context
|
||||
from fastmcp.providers import Provider
|
||||
import fastmcp.server
|
||||
|
||||
from fastmcp.client import Client
|
||||
|
|
@ -32,6 +31,5 @@ __all__ = [
|
|||
"Client",
|
||||
"Context",
|
||||
"FastMCP",
|
||||
"Provider",
|
||||
"settings",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ from fastmcp.exceptions import NotFoundError
|
|||
from fastmcp.prompts.prompt import Prompt
|
||||
from fastmcp.resources.resource import Resource
|
||||
from fastmcp.resources.template import ResourceTemplate
|
||||
from fastmcp.server.server import FastMCP, has_resource_prefix, remove_resource_prefix
|
||||
from fastmcp.server.providers import MountedProvider
|
||||
from fastmcp.server.server import FastMCP
|
||||
from fastmcp.tools.tool import Tool
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
|
|
@ -40,16 +41,14 @@ class ComponentService:
|
|||
tool.enable()
|
||||
return tool
|
||||
|
||||
# 2. Check mounted servers using the filtered protocol path.
|
||||
for mounted in reversed(self._server._mounted_servers):
|
||||
if mounted.prefix:
|
||||
if key.startswith(f"{mounted.prefix}_"):
|
||||
tool_key = key.removeprefix(f"{mounted.prefix}_")
|
||||
mounted_service = ComponentService(mounted.server)
|
||||
tool = await mounted_service._enable_tool(tool_key)
|
||||
# 2. Check mounted servers via MountedProvider
|
||||
for provider in self._server._providers:
|
||||
if isinstance(provider, MountedProvider):
|
||||
unprefixed = provider._strip_tool_prefix(key)
|
||||
if unprefixed is not None:
|
||||
mounted_service = ComponentService(provider.server)
|
||||
tool = await mounted_service._enable_tool(unprefixed)
|
||||
return tool
|
||||
else:
|
||||
continue
|
||||
raise NotFoundError(f"Unknown tool: {key}")
|
||||
|
||||
async def _disable_tool(self, key: str) -> Tool:
|
||||
|
|
@ -69,16 +68,14 @@ class ComponentService:
|
|||
tool.disable()
|
||||
return tool
|
||||
|
||||
# 2. Check mounted servers using the filtered protocol path.
|
||||
for mounted in reversed(self._server._mounted_servers):
|
||||
if mounted.prefix:
|
||||
if key.startswith(f"{mounted.prefix}_"):
|
||||
tool_key = key.removeprefix(f"{mounted.prefix}_")
|
||||
mounted_service = ComponentService(mounted.server)
|
||||
tool = await mounted_service._disable_tool(tool_key)
|
||||
# 2. Check mounted servers via MountedProvider
|
||||
for provider in self._server._providers:
|
||||
if isinstance(provider, MountedProvider):
|
||||
unprefixed = provider._strip_tool_prefix(key)
|
||||
if unprefixed is not None:
|
||||
mounted_service = ComponentService(provider.server)
|
||||
tool = await mounted_service._disable_tool(unprefixed)
|
||||
return tool
|
||||
else:
|
||||
continue
|
||||
raise NotFoundError(f"Unknown tool: {key}")
|
||||
|
||||
async def _enable_resource(self, key: str) -> Resource | ResourceTemplate:
|
||||
|
|
@ -102,18 +99,16 @@ class ComponentService:
|
|||
template.enable()
|
||||
return template
|
||||
|
||||
# 2. Check mounted servers using the filtered protocol path.
|
||||
for mounted in reversed(self._server._mounted_servers):
|
||||
if mounted.prefix:
|
||||
if has_resource_prefix(key, mounted.prefix):
|
||||
key = remove_resource_prefix(key, mounted.prefix)
|
||||
mounted_service = ComponentService(mounted.server)
|
||||
# 2. Check mounted servers via MountedProvider
|
||||
for provider in self._server._providers:
|
||||
if isinstance(provider, MountedProvider):
|
||||
unprefixed = provider._strip_resource_prefix(key)
|
||||
if unprefixed is not None:
|
||||
mounted_service = ComponentService(provider.server)
|
||||
mounted_resource: (
|
||||
Resource | ResourceTemplate
|
||||
) = await mounted_service._enable_resource(key)
|
||||
) = await mounted_service._enable_resource(unprefixed)
|
||||
return mounted_resource
|
||||
else:
|
||||
continue
|
||||
raise NotFoundError(f"Unknown resource: {key}")
|
||||
|
||||
async def _disable_resource(self, key: str) -> Resource | ResourceTemplate:
|
||||
|
|
@ -137,18 +132,16 @@ class ComponentService:
|
|||
template.disable()
|
||||
return template
|
||||
|
||||
# 2. Check mounted servers using the filtered protocol path.
|
||||
for mounted in reversed(self._server._mounted_servers):
|
||||
if mounted.prefix:
|
||||
if has_resource_prefix(key, mounted.prefix):
|
||||
key = remove_resource_prefix(key, mounted.prefix)
|
||||
mounted_service = ComponentService(mounted.server)
|
||||
# 2. Check mounted servers via MountedProvider
|
||||
for provider in self._server._providers:
|
||||
if isinstance(provider, MountedProvider):
|
||||
unprefixed = provider._strip_resource_prefix(key)
|
||||
if unprefixed is not None:
|
||||
mounted_service = ComponentService(provider.server)
|
||||
mounted_resource: (
|
||||
Resource | ResourceTemplate
|
||||
) = await mounted_service._disable_resource(key)
|
||||
) = await mounted_service._disable_resource(unprefixed)
|
||||
return mounted_resource
|
||||
else:
|
||||
continue
|
||||
raise NotFoundError(f"Unknown resource: {key}")
|
||||
|
||||
async def _enable_prompt(self, key: str) -> Prompt:
|
||||
|
|
@ -168,16 +161,14 @@ class ComponentService:
|
|||
prompt.enable()
|
||||
return prompt
|
||||
|
||||
# 2. Check mounted servers using the filtered protocol path.
|
||||
for mounted in reversed(self._server._mounted_servers):
|
||||
if mounted.prefix:
|
||||
if key.startswith(f"{mounted.prefix}_"):
|
||||
prompt_key = key.removeprefix(f"{mounted.prefix}_")
|
||||
mounted_service = ComponentService(mounted.server)
|
||||
prompt = await mounted_service._enable_prompt(prompt_key)
|
||||
# 2. Check mounted servers via MountedProvider
|
||||
for provider in self._server._providers:
|
||||
if isinstance(provider, MountedProvider):
|
||||
unprefixed = provider._strip_tool_prefix(key)
|
||||
if unprefixed is not None:
|
||||
mounted_service = ComponentService(provider.server)
|
||||
prompt = await mounted_service._enable_prompt(unprefixed)
|
||||
return prompt
|
||||
else:
|
||||
continue
|
||||
raise NotFoundError(f"Unknown prompt: {key}")
|
||||
|
||||
async def _disable_prompt(self, key: str) -> Prompt:
|
||||
|
|
@ -196,14 +187,12 @@ class ComponentService:
|
|||
prompt.disable()
|
||||
return prompt
|
||||
|
||||
# 2. Check mounted servers using the filtered protocol path.
|
||||
for mounted in reversed(self._server._mounted_servers):
|
||||
if mounted.prefix:
|
||||
if key.startswith(f"{mounted.prefix}_"):
|
||||
prompt_key = key.removeprefix(f"{mounted.prefix}_")
|
||||
mounted_service = ComponentService(mounted.server)
|
||||
prompt = await mounted_service._disable_prompt(prompt_key)
|
||||
# 2. Check mounted servers via MountedProvider
|
||||
for provider in self._server._providers:
|
||||
if isinstance(provider, MountedProvider):
|
||||
unprefixed = provider._strip_tool_prefix(key)
|
||||
if unprefixed is not None:
|
||||
mounted_service = ComponentService(provider.server)
|
||||
prompt = await mounted_service._disable_prompt(unprefixed)
|
||||
return prompt
|
||||
else:
|
||||
continue
|
||||
raise NotFoundError(f"Unknown prompt: {key}")
|
||||
|
|
|
|||
36
src/fastmcp/server/providers/__init__.py
Normal file
36
src/fastmcp/server/providers/__init__.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"""Providers for dynamic MCP components.
|
||||
|
||||
This module provides the `Provider` abstraction for providing tools,
|
||||
resources, and prompts dynamically at runtime.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.providers import Provider
|
||||
from fastmcp.tools import Tool
|
||||
|
||||
class DatabaseProvider(Provider):
|
||||
def __init__(self, db_url: str):
|
||||
self.db = Database(db_url)
|
||||
|
||||
async def list_tools(self) -> list[Tool]:
|
||||
rows = await self.db.fetch("SELECT * FROM tools")
|
||||
return [self._make_tool(row) for row in rows]
|
||||
|
||||
async def get_tool(self, name: str) -> Tool | None:
|
||||
row = await self.db.fetchone("SELECT * FROM tools WHERE name = ?", name)
|
||||
return self._make_tool(row) if row else None
|
||||
|
||||
mcp = FastMCP("Server", providers=[DatabaseProvider(db_url)])
|
||||
```
|
||||
"""
|
||||
|
||||
from fastmcp.server.providers.base import Components, Provider, TaskComponents
|
||||
from fastmcp.server.providers.mounted import MountedProvider
|
||||
|
||||
__all__ = [
|
||||
"Components",
|
||||
"MountedProvider",
|
||||
"Provider",
|
||||
"TaskComponents",
|
||||
]
|
||||
|
|
@ -1,23 +1,24 @@
|
|||
"""Providers for dynamic MCP components.
|
||||
"""Base Provider class for dynamic MCP components.
|
||||
|
||||
This module provides the `Provider` abstraction for providing tools,
|
||||
resources, and prompts dynamically at runtime.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP, Provider
|
||||
from fastmcp.server.context import Context
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.providers import Provider
|
||||
from fastmcp.tools import Tool
|
||||
|
||||
class DatabaseProvider(Provider):
|
||||
def __init__(self, db_url: str):
|
||||
super().__init__()
|
||||
self.db = Database(db_url)
|
||||
|
||||
async def list_tools(self, context: Context) -> list[Tool]:
|
||||
async def list_tools(self) -> list[Tool]:
|
||||
rows = await self.db.fetch("SELECT * FROM tools")
|
||||
return [self._make_tool(row) for row in rows]
|
||||
|
||||
async def get_tool(self, context: Context, name: str) -> Tool | None:
|
||||
async def get_tool(self, name: str) -> Tool | None:
|
||||
row = await self.db.fetchone("SELECT * FROM tools WHERE name = ?", name)
|
||||
return self._make_tool(row) if row else None
|
||||
|
||||
|
|
@ -27,7 +28,9 @@ Example:
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastmcp.prompts.prompt import Prompt, PromptResult
|
||||
|
|
@ -36,11 +39,34 @@ from fastmcp.resources.template import ResourceTemplate
|
|||
from fastmcp.tools.tool import Tool, ToolResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.server.context import Context
|
||||
from fastmcp.prompts.prompt import FunctionPrompt
|
||||
from fastmcp.resources.resource import FunctionResource
|
||||
from fastmcp.resources.template import FunctionResourceTemplate
|
||||
from fastmcp.tools.tool import FunctionTool
|
||||
|
||||
__all__ = [
|
||||
"Provider",
|
||||
]
|
||||
|
||||
@dataclass
|
||||
class Components:
|
||||
"""Collection of MCP components."""
|
||||
|
||||
tools: Sequence[Tool] = ()
|
||||
resources: Sequence[Resource] = ()
|
||||
templates: Sequence[ResourceTemplate] = ()
|
||||
prompts: Sequence[Prompt] = ()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskComponents:
|
||||
"""Collection of function-based components eligible for background task execution.
|
||||
|
||||
Used by get_tasks() to return components for Docket registration.
|
||||
All components have a `.fn` attribute pointing to the underlying callable.
|
||||
"""
|
||||
|
||||
tools: Sequence[FunctionTool] = ()
|
||||
resources: Sequence[FunctionResource] = ()
|
||||
templates: Sequence[FunctionResourceTemplate] = ()
|
||||
prompts: Sequence[FunctionPrompt] = ()
|
||||
|
||||
|
||||
class Provider:
|
||||
|
|
@ -50,9 +76,6 @@ class Provider:
|
|||
return empty lists / None, so you only need to implement what your provider
|
||||
supports.
|
||||
|
||||
All provider methods receive the FastMCP Context, giving access to
|
||||
session info, logging, and other request-scoped capabilities.
|
||||
|
||||
Provider semantics:
|
||||
- Return `None` from `get_*` methods to indicate "I don't have it" (search continues)
|
||||
- Static components (registered via decorators) always take precedence over providers
|
||||
|
|
@ -66,14 +89,14 @@ class Provider:
|
|||
exceptions are wrapped with optional detail masking.
|
||||
"""
|
||||
|
||||
async def list_tools(self, context: Context) -> Sequence[Tool]:
|
||||
async def list_tools(self) -> Sequence[Tool]:
|
||||
"""Return all available tools.
|
||||
|
||||
Override to provide tools dynamically.
|
||||
"""
|
||||
return []
|
||||
|
||||
async def get_tool(self, context: Context, name: str) -> Tool | None:
|
||||
async def get_tool(self, name: str) -> Tool | None:
|
||||
"""Get a specific tool by name.
|
||||
|
||||
Default implementation lists all tools and finds by name.
|
||||
|
|
@ -82,11 +105,11 @@ class Provider:
|
|||
Returns:
|
||||
The Tool if found, or None to continue searching other providers.
|
||||
"""
|
||||
tools = await self.list_tools(context)
|
||||
tools = await self.list_tools()
|
||||
return next((t for t in tools if t.name == name), None)
|
||||
|
||||
async def call_tool(
|
||||
self, context: Context, name: str, arguments: dict[str, Any]
|
||||
self, name: str, arguments: dict[str, Any]
|
||||
) -> ToolResult | None:
|
||||
"""Execute a tool by name.
|
||||
|
||||
|
|
@ -96,19 +119,19 @@ class Provider:
|
|||
Returns:
|
||||
The ToolResult if found and executed, or None if tool not found.
|
||||
"""
|
||||
tool = await self.get_tool(context, name)
|
||||
tool = await self.get_tool(name)
|
||||
if tool is None:
|
||||
return None
|
||||
return await tool.run(arguments)
|
||||
|
||||
async def list_resources(self, context: Context) -> Sequence[Resource]:
|
||||
async def list_resources(self) -> Sequence[Resource]:
|
||||
"""Return all available resources.
|
||||
|
||||
Override to provide resources dynamically.
|
||||
"""
|
||||
return []
|
||||
|
||||
async def get_resource(self, context: Context, uri: str) -> Resource | None:
|
||||
async def get_resource(self, uri: str) -> Resource | None:
|
||||
"""Get a specific resource by URI.
|
||||
|
||||
Default implementation lists all resources and finds by URI.
|
||||
|
|
@ -117,21 +140,17 @@ class Provider:
|
|||
Returns:
|
||||
The Resource if found, or None to continue searching other providers.
|
||||
"""
|
||||
resources = await self.list_resources(context)
|
||||
resources = await self.list_resources()
|
||||
return next((r for r in resources if str(r.uri) == uri), None)
|
||||
|
||||
async def list_resource_templates(
|
||||
self, context: Context
|
||||
) -> Sequence[ResourceTemplate]:
|
||||
async def list_resource_templates(self) -> Sequence[ResourceTemplate]:
|
||||
"""Return all available resource templates.
|
||||
|
||||
Override to provide resource templates dynamically.
|
||||
"""
|
||||
return []
|
||||
|
||||
async def get_resource_template(
|
||||
self, context: Context, uri: str
|
||||
) -> ResourceTemplate | None:
|
||||
async def get_resource_template(self, uri: str) -> ResourceTemplate | None:
|
||||
"""Get a resource template that matches the given URI.
|
||||
|
||||
Default implementation lists all templates and finds one whose pattern
|
||||
|
|
@ -141,13 +160,13 @@ class Provider:
|
|||
Returns:
|
||||
The ResourceTemplate if a matching one is found, or None to continue searching.
|
||||
"""
|
||||
templates = await self.list_resource_templates(context)
|
||||
templates = await self.list_resource_templates()
|
||||
return next(
|
||||
(t for t in templates if t.matches(uri) is not None),
|
||||
None,
|
||||
)
|
||||
|
||||
async def read_resource(self, context: Context, uri: str) -> ResourceContent | None:
|
||||
async def read_resource(self, uri: str) -> ResourceContent | None:
|
||||
"""Read a concrete resource by URI.
|
||||
|
||||
Default implementation gets the resource and reads it.
|
||||
|
|
@ -159,14 +178,12 @@ class Provider:
|
|||
Returns:
|
||||
The ResourceContent if found and read, or None if not found.
|
||||
"""
|
||||
resource = await self.get_resource(context, uri)
|
||||
resource = await self.get_resource(uri)
|
||||
if resource is None:
|
||||
return None
|
||||
return await resource._read()
|
||||
|
||||
async def read_resource_template(
|
||||
self, context: Context, uri: str
|
||||
) -> ResourceContent | None:
|
||||
async def read_resource_template(self, uri: str) -> ResourceContent | None:
|
||||
"""Read a resource via a matching template.
|
||||
|
||||
Default implementation finds a matching template, creates a resource
|
||||
|
|
@ -177,7 +194,7 @@ class Provider:
|
|||
The ResourceContent if a matching template is found and read,
|
||||
or None if no template matches.
|
||||
"""
|
||||
template = await self.get_resource_template(context, uri)
|
||||
template = await self.get_resource_template(uri)
|
||||
if template is None:
|
||||
return None
|
||||
params = template.matches(uri)
|
||||
|
|
@ -186,14 +203,14 @@ class Provider:
|
|||
resource = await template.create_resource(uri, params)
|
||||
return await resource._read()
|
||||
|
||||
async def list_prompts(self, context: Context) -> Sequence[Prompt]:
|
||||
async def list_prompts(self) -> Sequence[Prompt]:
|
||||
"""Return all available prompts.
|
||||
|
||||
Override to provide prompts dynamically.
|
||||
"""
|
||||
return []
|
||||
|
||||
async def get_prompt(self, context: Context, name: str) -> Prompt | None:
|
||||
async def get_prompt(self, name: str) -> Prompt | None:
|
||||
"""Get a specific prompt by name.
|
||||
|
||||
Default implementation lists all prompts and finds by name.
|
||||
|
|
@ -202,11 +219,11 @@ class Provider:
|
|||
Returns:
|
||||
The Prompt if found, or None to continue searching other providers.
|
||||
"""
|
||||
prompts = await self.list_prompts(context)
|
||||
prompts = await self.list_prompts()
|
||||
return next((p for p in prompts if p.name == name), None)
|
||||
|
||||
async def render_prompt(
|
||||
self, context: Context, name: str, arguments: dict[str, Any] | None
|
||||
self, name: str, arguments: dict[str, Any] | None
|
||||
) -> PromptResult | None:
|
||||
"""Render a prompt by name.
|
||||
|
||||
|
|
@ -216,7 +233,84 @@ class Provider:
|
|||
Returns:
|
||||
The PromptResult if found and rendered, or None if not found.
|
||||
"""
|
||||
prompt = await self.get_prompt(context, name)
|
||||
prompt = await self.get_prompt(name)
|
||||
if prompt is None:
|
||||
return None
|
||||
return await prompt._render(arguments)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Task registration
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def get_tasks(self) -> TaskComponents:
|
||||
"""Return components that should be registered as background tasks.
|
||||
|
||||
Override to customize which components are task-eligible.
|
||||
Default calls list_* methods and filters for function-based components
|
||||
with task_config.mode != 'forbidden'.
|
||||
|
||||
Used by the server during startup to register functions with Docket.
|
||||
"""
|
||||
from fastmcp.prompts.prompt import FunctionPrompt
|
||||
from fastmcp.resources.resource import FunctionResource
|
||||
from fastmcp.resources.template import FunctionResourceTemplate
|
||||
from fastmcp.tools.tool import FunctionTool
|
||||
|
||||
all_tools = await self.list_tools()
|
||||
all_resources = await self.list_resources()
|
||||
all_templates = await self.list_resource_templates()
|
||||
all_prompts = await self.list_prompts()
|
||||
|
||||
return TaskComponents(
|
||||
tools=[
|
||||
t
|
||||
for t in all_tools
|
||||
if isinstance(t, FunctionTool) and t.task_config.mode != "forbidden"
|
||||
],
|
||||
resources=[
|
||||
r
|
||||
for r in all_resources
|
||||
if isinstance(r, FunctionResource) and r.task_config.mode != "forbidden"
|
||||
],
|
||||
templates=[
|
||||
t
|
||||
for t in all_templates
|
||||
if isinstance(t, FunctionResourceTemplate)
|
||||
and t.task_config.mode != "forbidden"
|
||||
],
|
||||
prompts=[
|
||||
p
|
||||
for p in all_prompts
|
||||
if isinstance(p, FunctionPrompt) and p.task_config.mode != "forbidden"
|
||||
],
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Lifecycle methods
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(self) -> AsyncIterator[None]:
|
||||
"""User-overridable lifespan for custom setup and teardown.
|
||||
|
||||
Override this method to perform provider-specific initialization
|
||||
like opening database connections, setting up external resources,
|
||||
or other state management needed for the provider's lifetime.
|
||||
|
||||
The lifespan scope matches the server's lifespan - code before yield
|
||||
runs at startup, code after yield runs at shutdown.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@asynccontextmanager
|
||||
async def lifespan(self):
|
||||
# Setup
|
||||
self.db = await connect_database()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Teardown
|
||||
await self.db.close()
|
||||
```
|
||||
"""
|
||||
yield
|
||||
407
src/fastmcp/server/providers/mounted.py
Normal file
407
src/fastmcp/server/providers/mounted.py
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
"""MountedProvider for wrapping mounted FastMCP servers.
|
||||
|
||||
This module provides the `MountedProvider` class that enables mounting
|
||||
one FastMCP server onto another, exposing the mounted server's tools,
|
||||
resources, and prompts through the parent server with optional prefixing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastmcp.exceptions import NotFoundError
|
||||
from fastmcp.prompts.prompt import Prompt, PromptResult
|
||||
from fastmcp.resources.resource import Resource, ResourceContent
|
||||
from fastmcp.resources.template import ResourceTemplate
|
||||
from fastmcp.server.providers.base import Provider, TaskComponents
|
||||
from fastmcp.tools.tool import Tool, ToolResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.prompts.prompt import FunctionPrompt
|
||||
from fastmcp.resources.resource import FunctionResource
|
||||
from fastmcp.resources.template import FunctionResourceTemplate
|
||||
from fastmcp.server.server import FastMCP
|
||||
from fastmcp.tools.tool import FunctionTool
|
||||
|
||||
# Pattern for matching URIs: protocol://path
|
||||
_URI_PATTERN = re.compile(r"^([^:]+://)(.*?)$")
|
||||
|
||||
|
||||
def add_resource_prefix(uri: str, prefix: str) -> str:
|
||||
"""Add a prefix to a resource URI using path formatting (resource://prefix/path)."""
|
||||
if not prefix:
|
||||
return uri
|
||||
match = _URI_PATTERN.match(uri)
|
||||
if not match:
|
||||
raise ValueError(f"Invalid URI format: {uri}. Expected protocol://path format.")
|
||||
protocol, path = match.groups()
|
||||
return f"{protocol}{prefix}/{path}"
|
||||
|
||||
|
||||
def remove_resource_prefix(uri: str, prefix: str) -> str:
|
||||
"""Remove a prefix from a resource URI."""
|
||||
if not prefix:
|
||||
return uri
|
||||
match = _URI_PATTERN.match(uri)
|
||||
if not match:
|
||||
raise ValueError(f"Invalid URI format: {uri}. Expected protocol://path format.")
|
||||
protocol, path = match.groups()
|
||||
prefix_pattern = f"^{re.escape(prefix)}/(.*?)$"
|
||||
path_match = re.match(prefix_pattern, path)
|
||||
if not path_match:
|
||||
return uri
|
||||
return f"{protocol}{path_match.group(1)}"
|
||||
|
||||
|
||||
def has_resource_prefix(uri: str, prefix: str) -> bool:
|
||||
"""Check if a resource URI has a specific prefix."""
|
||||
if not prefix:
|
||||
return False
|
||||
match = _URI_PATTERN.match(uri)
|
||||
if not match:
|
||||
raise ValueError(f"Invalid URI format: {uri}. Expected protocol://path format.")
|
||||
_, path = match.groups()
|
||||
prefix_pattern = f"^{re.escape(prefix)}/"
|
||||
return bool(re.match(prefix_pattern, path))
|
||||
|
||||
|
||||
class MountedProvider(Provider):
|
||||
"""Provider that wraps a mounted FastMCP server.
|
||||
|
||||
This provider enables mounting one FastMCP server onto another, exposing
|
||||
the mounted server's tools, resources, and prompts through the parent
|
||||
server with optional prefixing.
|
||||
|
||||
The key benefit is that execution methods (`call_tool`, `read_resource`,
|
||||
`render_prompt`) invoke the mounted server's middleware chain, enabling
|
||||
full participation in the provider abstraction.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.providers import MountedProvider
|
||||
|
||||
main = FastMCP("Main")
|
||||
sub = FastMCP("Sub")
|
||||
|
||||
@sub.tool
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
# Mount with prefix - tool accessible as "sub_greet"
|
||||
main.add_provider(MountedProvider(sub, prefix="sub"))
|
||||
```
|
||||
|
||||
Note:
|
||||
Normally you would use `FastMCP.mount()` which handles proxy conversion
|
||||
and creates the MountedProvider internally.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server: FastMCP[Any],
|
||||
prefix: str | None = None,
|
||||
tool_names: dict[str, str] | None = None,
|
||||
):
|
||||
"""Initialize a MountedProvider.
|
||||
|
||||
Args:
|
||||
server: The FastMCP server to mount.
|
||||
prefix: Optional prefix for tool/prompt names and resource URIs.
|
||||
Tools and prompts use underscore separator: "prefix_name".
|
||||
Resources use path-style: "protocol://prefix/path".
|
||||
tool_names: Optional mapping of original tool names to custom names.
|
||||
Overrides the default prefixed names for specific tools.
|
||||
"""
|
||||
super().__init__()
|
||||
self.server = server
|
||||
self.prefix = prefix
|
||||
self.tool_names = tool_names or {}
|
||||
if len(self.tool_names) != len(set(self.tool_names.values())):
|
||||
raise ValueError("tool_names values must be unique")
|
||||
self._reverse_tool_names = {v: k for k, v in self.tool_names.items()}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Helper methods for prefix handling
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _add_tool_prefix(self, name: str) -> str:
|
||||
"""Add prefix to a tool or prompt name."""
|
||||
if self.prefix:
|
||||
return f"{self.prefix}_{name}"
|
||||
return name
|
||||
|
||||
def _strip_tool_prefix(self, name: str) -> str | None:
|
||||
"""Strip prefix from a tool or prompt name.
|
||||
|
||||
Returns:
|
||||
The unprefixed name if the name matches this provider's pattern,
|
||||
or None if it doesn't match (indicating another provider should handle it).
|
||||
"""
|
||||
# Check for tool_names override first
|
||||
if name in self._reverse_tool_names:
|
||||
return self._reverse_tool_names[name]
|
||||
|
||||
# Check prefix pattern
|
||||
if self.prefix:
|
||||
expected_prefix = f"{self.prefix}_"
|
||||
if name.startswith(expected_prefix):
|
||||
return name[len(expected_prefix) :]
|
||||
return None # Doesn't match this provider
|
||||
|
||||
# No prefix means we always match
|
||||
return name
|
||||
|
||||
def _add_resource_prefix(self, uri: str) -> str:
|
||||
"""Add prefix to a resource URI."""
|
||||
if not self.prefix:
|
||||
return uri
|
||||
return add_resource_prefix(uri, self.prefix)
|
||||
|
||||
def _strip_resource_prefix(self, uri: str) -> str | None:
|
||||
"""Strip prefix from a resource URI.
|
||||
|
||||
Returns:
|
||||
The unprefixed URI if it matches this provider's pattern,
|
||||
or None if it doesn't match.
|
||||
"""
|
||||
if not self.prefix:
|
||||
return uri
|
||||
if not has_resource_prefix(uri, self.prefix):
|
||||
return None
|
||||
return remove_resource_prefix(uri, self.prefix)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Prefix helper methods for components
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _prefix_tool(self, tool: Tool) -> Tool:
|
||||
"""Apply prefix to a tool."""
|
||||
if self.tool_names and tool.name in self.tool_names:
|
||||
new_key = self.tool_names[tool.name]
|
||||
else:
|
||||
new_key = self._add_tool_prefix(tool.key)
|
||||
return tool.model_copy(key=new_key) if new_key != tool.key else tool
|
||||
|
||||
def _prefix_resource(self, resource: Resource) -> Resource:
|
||||
"""Apply prefix to a resource."""
|
||||
new_key = self._add_resource_prefix(resource.key)
|
||||
update: dict[str, Any] = {}
|
||||
if self.prefix and resource.name:
|
||||
update["name"] = f"{self.prefix}_{resource.name}"
|
||||
if new_key != resource.key or update:
|
||||
return resource.model_copy(key=new_key, update=update)
|
||||
return resource
|
||||
|
||||
def _prefix_template(self, template: ResourceTemplate) -> ResourceTemplate:
|
||||
"""Apply prefix to a resource template."""
|
||||
new_key = self._add_resource_prefix(template.key)
|
||||
update: dict[str, Any] = {}
|
||||
if self.prefix and template.name:
|
||||
update["name"] = f"{self.prefix}_{template.name}"
|
||||
if self.prefix and template.uri_template:
|
||||
update["uri_template"] = self._add_resource_prefix(template.uri_template)
|
||||
if new_key != template.key or update:
|
||||
return template.model_copy(key=new_key, update=update)
|
||||
return template
|
||||
|
||||
def _prefix_prompt(self, prompt: Prompt) -> Prompt:
|
||||
"""Apply prefix to a prompt."""
|
||||
new_key = self._add_tool_prefix(prompt.key)
|
||||
return prompt.model_copy(key=new_key) if new_key != prompt.key else prompt
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tool methods
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def list_tools(self) -> Sequence[Tool]:
|
||||
"""List all tools from the mounted server with prefixes applied."""
|
||||
tools = await self.server._list_tools_middleware()
|
||||
return [self._prefix_tool(tool) for tool in tools]
|
||||
|
||||
async def get_tool(self, name: str) -> Tool | None:
|
||||
"""Get a tool by name, going through middleware."""
|
||||
# Early exit if name doesn't match our prefix pattern
|
||||
if self._strip_tool_prefix(name) is None:
|
||||
return None
|
||||
tools = await self.list_tools()
|
||||
return next((t for t in tools if t.key == name), None)
|
||||
|
||||
async def call_tool(
|
||||
self, name: str, arguments: dict[str, Any]
|
||||
) -> ToolResult | None:
|
||||
"""Execute a tool through the mounted server's middleware chain."""
|
||||
unprefixed = self._strip_tool_prefix(name)
|
||||
if unprefixed is None:
|
||||
return None # Doesn't match this provider
|
||||
|
||||
return await self.server._call_tool_middleware(unprefixed, arguments)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Resource methods
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def list_resources(self) -> Sequence[Resource]:
|
||||
"""List all resources from the mounted server with prefixes applied."""
|
||||
resources = await self.server._list_resources_middleware()
|
||||
return [self._prefix_resource(resource) for resource in resources]
|
||||
|
||||
async def get_resource(self, uri: str) -> Resource | None:
|
||||
"""Get a concrete resource by URI, going through middleware.
|
||||
|
||||
Only returns concrete resources, not resources created from templates.
|
||||
This preserves the original template for task execution.
|
||||
"""
|
||||
# Early exit if URI doesn't match our prefix pattern
|
||||
if self._strip_resource_prefix(uri) is None:
|
||||
return None
|
||||
|
||||
# Only check concrete resources (not templates)
|
||||
resources = await self.list_resources()
|
||||
return next((r for r in resources if r.key == uri), None)
|
||||
|
||||
async def read_resource(self, uri: str) -> ResourceContent | None:
|
||||
"""Read a resource through the mounted server's middleware chain."""
|
||||
unprefixed = self._strip_resource_prefix(uri)
|
||||
if unprefixed is None:
|
||||
return None # Doesn't match this provider
|
||||
|
||||
try:
|
||||
contents = await self.server._read_resource_middleware(unprefixed)
|
||||
return contents[0] if contents else None
|
||||
except NotFoundError:
|
||||
return None
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Resource template methods
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def list_resource_templates(self) -> Sequence[ResourceTemplate]:
|
||||
"""List all resource templates from the mounted server with prefixes applied."""
|
||||
templates = await self.server._list_resource_templates_middleware()
|
||||
return [self._prefix_template(template) for template in templates]
|
||||
|
||||
async def get_resource_template(self, uri: str) -> ResourceTemplate | None:
|
||||
"""Get a resource template that matches the given URI."""
|
||||
# For templates, we need to check if any template matches the prefixed URI
|
||||
unprefixed = self._strip_resource_prefix(uri)
|
||||
if unprefixed is None:
|
||||
return None
|
||||
|
||||
# Use middleware to include templates from nested mounted providers
|
||||
templates = await self.server._list_resource_templates_middleware()
|
||||
for template in templates:
|
||||
if template.matches(unprefixed) is not None:
|
||||
return self._prefix_template(template)
|
||||
return None
|
||||
|
||||
async def read_resource_template(self, uri: str) -> ResourceContent | None:
|
||||
"""Read a resource via a matching template through the mounted server."""
|
||||
# This is handled by read_resource since the server's middleware handles templates
|
||||
return await self.read_resource(uri)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Prompt methods
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def list_prompts(self) -> Sequence[Prompt]:
|
||||
"""List all prompts from the mounted server with prefixes applied."""
|
||||
prompts = await self.server._list_prompts_middleware()
|
||||
return [self._prefix_prompt(prompt) for prompt in prompts]
|
||||
|
||||
async def get_prompt(self, name: str) -> Prompt | None:
|
||||
"""Get a prompt by name, going through middleware."""
|
||||
# Early exit if name doesn't match our prefix pattern
|
||||
if self._strip_tool_prefix(name) is None:
|
||||
return None
|
||||
prompts = await self.list_prompts()
|
||||
return next((p for p in prompts if p.key == name), None)
|
||||
|
||||
async def render_prompt(
|
||||
self, name: str, arguments: dict[str, Any] | None
|
||||
) -> PromptResult | None:
|
||||
"""Render a prompt through the mounted server's middleware chain."""
|
||||
unprefixed = self._strip_tool_prefix(name)
|
||||
if unprefixed is None:
|
||||
return None # Doesn't match this provider
|
||||
|
||||
return await self.server._get_prompt_content_middleware(unprefixed, arguments)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Task registration
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def get_tasks(self) -> TaskComponents:
|
||||
"""Return task-eligible components, bypassing middleware and applying prefixes.
|
||||
|
||||
This override accesses the wrapped server's managers directly to avoid
|
||||
triggering middleware during registration. It also recursively collects
|
||||
tasks from nested providers.
|
||||
"""
|
||||
from fastmcp.prompts.prompt import FunctionPrompt
|
||||
from fastmcp.resources.resource import FunctionResource
|
||||
from fastmcp.resources.template import FunctionResourceTemplate
|
||||
from fastmcp.tools.tool import FunctionTool
|
||||
|
||||
tools: list[FunctionTool] = []
|
||||
resources: list[FunctionResource] = []
|
||||
templates: list[FunctionResourceTemplate] = []
|
||||
prompts: list[FunctionPrompt] = []
|
||||
|
||||
# Direct manager access (bypasses middleware)
|
||||
for tool in self.server._tool_manager._tools.values():
|
||||
if isinstance(tool, FunctionTool) and tool.task_config.mode != "forbidden":
|
||||
tools.append(self._prefix_tool(tool)) # type: ignore[arg-type]
|
||||
|
||||
for resource in self.server._resource_manager._resources.values():
|
||||
if (
|
||||
isinstance(resource, FunctionResource)
|
||||
and resource.task_config.mode != "forbidden"
|
||||
):
|
||||
resources.append(self._prefix_resource(resource)) # type: ignore[arg-type]
|
||||
|
||||
for template in self.server._resource_manager._templates.values():
|
||||
if (
|
||||
isinstance(template, FunctionResourceTemplate)
|
||||
and template.task_config.mode != "forbidden"
|
||||
):
|
||||
templates.append(self._prefix_template(template)) # type: ignore[arg-type]
|
||||
|
||||
for prompt in self.server._prompt_manager._prompts.values():
|
||||
if (
|
||||
isinstance(prompt, FunctionPrompt)
|
||||
and prompt.task_config.mode != "forbidden"
|
||||
):
|
||||
prompts.append(self._prefix_prompt(prompt)) # type: ignore[arg-type]
|
||||
|
||||
# Recursively get tasks from nested providers and apply our prefix
|
||||
for provider in self.server._providers:
|
||||
nested = await provider.get_tasks()
|
||||
tools.extend(self._prefix_tool(t) for t in nested.tools) # type: ignore[arg-type]
|
||||
resources.extend(self._prefix_resource(r) for r in nested.resources) # type: ignore[arg-type]
|
||||
templates.extend(self._prefix_template(t) for t in nested.templates) # type: ignore[arg-type]
|
||||
prompts.extend(self._prefix_prompt(p) for p in nested.prompts) # type: ignore[arg-type]
|
||||
|
||||
return TaskComponents(
|
||||
tools=tools, resources=resources, templates=templates, prompts=prompts
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Lifecycle methods
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(self) -> AsyncIterator[None]:
|
||||
"""Start the mounted server's user lifespan.
|
||||
|
||||
This starts only the wrapped server's user-defined lifespan, NOT its
|
||||
full _lifespan_manager() (which includes Docket). The parent server's
|
||||
Docket handles all background tasks.
|
||||
"""
|
||||
# Start the wrapped server's user lifespan only
|
||||
# We pass the server instance to the user's lifespan function
|
||||
async with self.server._lifespan(self.server):
|
||||
yield
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1092,7 +1092,7 @@ class TestInferTransport:
|
|||
transport = infer_transport(config)
|
||||
assert isinstance(transport, MCPConfigTransport)
|
||||
assert isinstance(transport.transport, FastMCPTransport)
|
||||
assert len(cast(FastMCP, transport.transport.server)._mounted_servers) == 2
|
||||
assert len(cast(FastMCP, transport.transport.server)._providers) == 2
|
||||
|
||||
def test_infer_fastmcp_server(self, fastmcp_server):
|
||||
"""FastMCP server instances should infer to FastMCPTransport."""
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import pytest
|
|||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.client.transports import FastMCPTransport, SSETransport
|
||||
from fastmcp.server.providers import MountedProvider
|
||||
from fastmcp.server.proxy import FastMCPProxy
|
||||
from fastmcp.tools.tool import Tool
|
||||
from fastmcp.tools.tool_transform import TransformedTool
|
||||
|
|
@ -308,29 +309,27 @@ class TestMultipleServerMount:
|
|||
prompt_names = [prompt.name for prompt in prompts]
|
||||
assert "working_working_prompt" in prompt_names
|
||||
|
||||
# Verify that warnings were logged for the unreachable server
|
||||
warning_messages = [
|
||||
record.message for record in caplog.records if record.levelname == "WARNING"
|
||||
# Verify that errors were logged for the unreachable provider
|
||||
error_messages = [
|
||||
record.message for record in caplog.records if record.levelname == "ERROR"
|
||||
]
|
||||
assert any("Error listing tools from provider" in msg for msg in error_messages)
|
||||
assert any(
|
||||
"Failed to list tools from mounted server 'unreachable_proxy'" in msg
|
||||
for msg in warning_messages
|
||||
"Error listing resources from provider" in msg for msg in error_messages
|
||||
)
|
||||
assert any(
|
||||
"Failed to list resources from 'unreachable_proxy'" in msg
|
||||
for msg in warning_messages
|
||||
)
|
||||
assert any(
|
||||
"Failed to list prompts from mounted server 'unreachable_proxy'" in msg
|
||||
for msg in warning_messages
|
||||
"Error listing prompts from provider" in msg for msg in error_messages
|
||||
)
|
||||
|
||||
|
||||
class TestPrefixConflictResolution:
|
||||
"""Test that later mounted servers win when there are conflicts."""
|
||||
"""Test that first registered provider wins when there are conflicts.
|
||||
|
||||
async def test_later_server_wins_tools_no_prefix(self):
|
||||
"""Test that later mounted server wins for tools when no prefix is used."""
|
||||
Provider semantics: 'Providers are queried in registration order; first non-None wins'
|
||||
"""
|
||||
|
||||
async def test_first_server_wins_tools_no_prefix(self):
|
||||
"""Test that first mounted server wins for tools when no prefix is used."""
|
||||
main_app = FastMCP("MainApp")
|
||||
first_app = FastMCP("FirstApp")
|
||||
second_app = FastMCP("SecondApp")
|
||||
|
|
@ -348,18 +347,18 @@ class TestPrefixConflictResolution:
|
|||
main_app.mount(second_app)
|
||||
|
||||
async with Client(main_app) as client:
|
||||
# Test that list_tools shows the tool from later server
|
||||
# Test that list_tools shows the tool
|
||||
tools = await client.list_tools()
|
||||
tool_names = [t.name for t in tools]
|
||||
assert "shared_tool" in tool_names
|
||||
assert tool_names.count("shared_tool") == 1 # Should only appear once
|
||||
|
||||
# Test that calling the tool uses the later server's implementation
|
||||
# Test that calling the tool uses the first server's implementation
|
||||
result = await client.call_tool("shared_tool", {})
|
||||
assert result.data == "Second app tool"
|
||||
assert result.data == "First app tool"
|
||||
|
||||
async def test_later_server_wins_tools_same_prefix(self):
|
||||
"""Test that later mounted server wins for tools when same prefix is used."""
|
||||
async def test_first_server_wins_tools_same_prefix(self):
|
||||
"""Test that first mounted server wins for tools when same prefix is used."""
|
||||
main_app = FastMCP("MainApp")
|
||||
first_app = FastMCP("FirstApp")
|
||||
second_app = FastMCP("SecondApp")
|
||||
|
|
@ -377,18 +376,18 @@ class TestPrefixConflictResolution:
|
|||
main_app.mount(second_app, "api")
|
||||
|
||||
async with Client(main_app) as client:
|
||||
# Test that list_tools shows the tool from later server
|
||||
# Test that list_tools shows the tool
|
||||
tools = await client.list_tools()
|
||||
tool_names = [t.name for t in tools]
|
||||
assert "api_shared_tool" in tool_names
|
||||
assert tool_names.count("api_shared_tool") == 1 # Should only appear once
|
||||
|
||||
# Test that calling the tool uses the later server's implementation
|
||||
# Test that calling the tool uses the first server's implementation
|
||||
result = await client.call_tool("api_shared_tool", {})
|
||||
assert result.data == "Second app tool"
|
||||
assert result.data == "First app tool"
|
||||
|
||||
async def test_later_server_wins_resources_no_prefix(self):
|
||||
"""Test that later mounted server wins for resources when no prefix is used."""
|
||||
async def test_first_server_wins_resources_no_prefix(self):
|
||||
"""Test that first mounted server wins for resources when no prefix is used."""
|
||||
main_app = FastMCP("MainApp")
|
||||
first_app = FastMCP("FirstApp")
|
||||
second_app = FastMCP("SecondApp")
|
||||
|
|
@ -406,18 +405,18 @@ class TestPrefixConflictResolution:
|
|||
main_app.mount(second_app)
|
||||
|
||||
async with Client(main_app) as client:
|
||||
# Test that list_resources shows the resource from later server
|
||||
# Test that list_resources shows the resource
|
||||
resources = await client.list_resources()
|
||||
resource_uris = [str(r.uri) for r in resources]
|
||||
assert "shared://data" in resource_uris
|
||||
assert resource_uris.count("shared://data") == 1 # Should only appear once
|
||||
|
||||
# Test that reading the resource uses the later server's implementation
|
||||
# Test that reading the resource uses the first server's implementation
|
||||
result = await client.read_resource("shared://data")
|
||||
assert result[0].text == "Second app data" # type: ignore[attr-defined]
|
||||
assert result[0].text == "First app data" # type: ignore[attr-defined]
|
||||
|
||||
async def test_later_server_wins_resources_same_prefix(self):
|
||||
"""Test that later mounted server wins for resources when same prefix is used."""
|
||||
async def test_first_server_wins_resources_same_prefix(self):
|
||||
"""Test that first mounted server wins for resources when same prefix is used."""
|
||||
main_app = FastMCP("MainApp")
|
||||
first_app = FastMCP("FirstApp")
|
||||
second_app = FastMCP("SecondApp")
|
||||
|
|
@ -435,7 +434,7 @@ class TestPrefixConflictResolution:
|
|||
main_app.mount(second_app, "api")
|
||||
|
||||
async with Client(main_app) as client:
|
||||
# Test that list_resources shows the resource from later server
|
||||
# Test that list_resources shows the resource
|
||||
resources = await client.list_resources()
|
||||
resource_uris = [str(r.uri) for r in resources]
|
||||
assert "shared://api/data" in resource_uris
|
||||
|
|
@ -443,12 +442,12 @@ class TestPrefixConflictResolution:
|
|||
resource_uris.count("shared://api/data") == 1
|
||||
) # Should only appear once
|
||||
|
||||
# Test that reading the resource uses the later server's implementation
|
||||
# Test that reading the resource uses the first server's implementation
|
||||
result = await client.read_resource("shared://api/data")
|
||||
assert result[0].text == "Second app data" # type: ignore[attr-defined]
|
||||
assert result[0].text == "First app data" # type: ignore[attr-defined]
|
||||
|
||||
async def test_later_server_wins_resource_templates_no_prefix(self):
|
||||
"""Test that later mounted server wins for resource templates when no prefix is used."""
|
||||
async def test_first_server_wins_resource_templates_no_prefix(self):
|
||||
"""Test that first mounted server wins for resource templates when no prefix is used."""
|
||||
main_app = FastMCP("MainApp")
|
||||
first_app = FastMCP("FirstApp")
|
||||
second_app = FastMCP("SecondApp")
|
||||
|
|
@ -466,7 +465,7 @@ class TestPrefixConflictResolution:
|
|||
main_app.mount(second_app)
|
||||
|
||||
async with Client(main_app) as client:
|
||||
# Test that list_resource_templates shows the template from later server
|
||||
# Test that list_resource_templates shows the template
|
||||
templates = await client.list_resource_templates()
|
||||
template_uris = [t.uriTemplate for t in templates]
|
||||
assert "users://{user_id}/profile" in template_uris
|
||||
|
|
@ -474,12 +473,12 @@ class TestPrefixConflictResolution:
|
|||
template_uris.count("users://{user_id}/profile") == 1
|
||||
) # Should only appear once
|
||||
|
||||
# Test that reading the resource uses the later server's implementation
|
||||
# Test that reading the resource uses the first server's implementation
|
||||
result = await client.read_resource("users://123/profile")
|
||||
assert result[0].text == "Second app user 123" # type: ignore[attr-defined]
|
||||
assert result[0].text == "First app user 123" # type: ignore[attr-defined]
|
||||
|
||||
async def test_later_server_wins_resource_templates_same_prefix(self):
|
||||
"""Test that later mounted server wins for resource templates when same prefix is used."""
|
||||
async def test_first_server_wins_resource_templates_same_prefix(self):
|
||||
"""Test that first mounted server wins for resource templates when same prefix is used."""
|
||||
main_app = FastMCP("MainApp")
|
||||
first_app = FastMCP("FirstApp")
|
||||
second_app = FastMCP("SecondApp")
|
||||
|
|
@ -497,7 +496,7 @@ class TestPrefixConflictResolution:
|
|||
main_app.mount(second_app, "api")
|
||||
|
||||
async with Client(main_app) as client:
|
||||
# Test that list_resource_templates shows the template from later server
|
||||
# Test that list_resource_templates shows the template
|
||||
templates = await client.list_resource_templates()
|
||||
template_uris = [t.uriTemplate for t in templates]
|
||||
assert "users://api/{user_id}/profile" in template_uris
|
||||
|
|
@ -505,12 +504,12 @@ class TestPrefixConflictResolution:
|
|||
template_uris.count("users://api/{user_id}/profile") == 1
|
||||
) # Should only appear once
|
||||
|
||||
# Test that reading the resource uses the later server's implementation
|
||||
# Test that reading the resource uses the first server's implementation
|
||||
result = await client.read_resource("users://api/123/profile")
|
||||
assert result[0].text == "Second app user 123" # type: ignore[attr-defined]
|
||||
assert result[0].text == "First app user 123" # type: ignore[attr-defined]
|
||||
|
||||
async def test_later_server_wins_prompts_no_prefix(self):
|
||||
"""Test that later mounted server wins for prompts when no prefix is used."""
|
||||
async def test_first_server_wins_prompts_no_prefix(self):
|
||||
"""Test that first mounted server wins for prompts when no prefix is used."""
|
||||
main_app = FastMCP("MainApp")
|
||||
first_app = FastMCP("FirstApp")
|
||||
second_app = FastMCP("SecondApp")
|
||||
|
|
@ -528,19 +527,19 @@ class TestPrefixConflictResolution:
|
|||
main_app.mount(second_app)
|
||||
|
||||
async with Client(main_app) as client:
|
||||
# Test that list_prompts shows the prompt from later server
|
||||
# Test that list_prompts shows the prompt
|
||||
prompts = await client.list_prompts()
|
||||
prompt_names = [p.name for p in prompts]
|
||||
assert "shared_prompt" in prompt_names
|
||||
assert prompt_names.count("shared_prompt") == 1 # Should only appear once
|
||||
|
||||
# Test that getting the prompt uses the later server's implementation
|
||||
# Test that getting the prompt uses the first server's implementation
|
||||
result = await client.get_prompt("shared_prompt", {})
|
||||
assert result.messages is not None
|
||||
assert result.messages[0].content.text == "Second app prompt" # type: ignore[attr-defined]
|
||||
assert result.messages[0].content.text == "First app prompt" # type: ignore[attr-defined]
|
||||
|
||||
async def test_later_server_wins_prompts_same_prefix(self):
|
||||
"""Test that later mounted server wins for prompts when same prefix is used."""
|
||||
async def test_first_server_wins_prompts_same_prefix(self):
|
||||
"""Test that first mounted server wins for prompts when same prefix is used."""
|
||||
main_app = FastMCP("MainApp")
|
||||
first_app = FastMCP("FirstApp")
|
||||
second_app = FastMCP("SecondApp")
|
||||
|
|
@ -558,7 +557,7 @@ class TestPrefixConflictResolution:
|
|||
main_app.mount(second_app, "api")
|
||||
|
||||
async with Client(main_app) as client:
|
||||
# Test that list_prompts shows the prompt from later server
|
||||
# Test that list_prompts shows the prompt
|
||||
prompts = await client.list_prompts()
|
||||
prompt_names = [p.name for p in prompts]
|
||||
assert "api_shared_prompt" in prompt_names
|
||||
|
|
@ -566,10 +565,10 @@ class TestPrefixConflictResolution:
|
|||
prompt_names.count("api_shared_prompt") == 1
|
||||
) # Should only appear once
|
||||
|
||||
# Test that getting the prompt uses the later server's implementation
|
||||
# Test that getting the prompt uses the first server's implementation
|
||||
result = await client.get_prompt("api_shared_prompt", {})
|
||||
assert result.messages is not None
|
||||
assert result.messages[0].content.text == "Second app prompt" # type: ignore[attr-defined]
|
||||
assert result.messages[0].content.text == "First app prompt" # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class TestDynamicChanges:
|
||||
|
|
@ -852,7 +851,9 @@ class TestAsProxyKwarg:
|
|||
sub = FastMCP("Sub")
|
||||
|
||||
mcp.mount(sub, "sub")
|
||||
assert mcp._mounted_servers[0].server is sub
|
||||
provider = mcp._providers[0]
|
||||
assert isinstance(provider, MountedProvider)
|
||||
assert provider.server is sub
|
||||
|
||||
async def test_as_proxy_false(self):
|
||||
mcp = FastMCP("Main")
|
||||
|
|
@ -860,7 +861,9 @@ class TestAsProxyKwarg:
|
|||
|
||||
mcp.mount(sub, "sub", as_proxy=False)
|
||||
|
||||
assert mcp._mounted_servers[0].server is sub
|
||||
provider = mcp._providers[0]
|
||||
assert isinstance(provider, MountedProvider)
|
||||
assert provider.server is sub
|
||||
|
||||
async def test_as_proxy_true(self):
|
||||
mcp = FastMCP("Main")
|
||||
|
|
@ -868,11 +871,17 @@ class TestAsProxyKwarg:
|
|||
|
||||
mcp.mount(sub, "sub", as_proxy=True)
|
||||
|
||||
assert mcp._mounted_servers[0].server is not sub
|
||||
assert isinstance(mcp._mounted_servers[0].server, FastMCPProxy)
|
||||
provider = mcp._providers[0]
|
||||
assert isinstance(provider, MountedProvider)
|
||||
assert provider.server is not sub
|
||||
assert isinstance(provider.server, FastMCPProxy)
|
||||
|
||||
async def test_as_proxy_defaults_true_if_lifespan(self):
|
||||
"""Test that as_proxy defaults to True when server_lifespan is provided."""
|
||||
async def test_lifespan_server_mounted_directly(self):
|
||||
"""Test that servers with lifespan are mounted directly (not auto-proxied).
|
||||
|
||||
Since MountedProvider now handles lifespan via the provider lifespan interface,
|
||||
there's no need to auto-convert to a proxy. The server is mounted directly.
|
||||
"""
|
||||
|
||||
@asynccontextmanager
|
||||
async def server_lifespan(mcp: FastMCP):
|
||||
|
|
@ -883,9 +892,10 @@ class TestAsProxyKwarg:
|
|||
|
||||
mcp.mount(sub, "sub")
|
||||
|
||||
# Should auto-proxy because lifespan is set
|
||||
assert mcp._mounted_servers[0].server is not sub
|
||||
assert isinstance(mcp._mounted_servers[0].server, FastMCPProxy)
|
||||
# Server should be mounted directly without auto-proxying
|
||||
provider = mcp._providers[0]
|
||||
assert isinstance(provider, MountedProvider)
|
||||
assert provider.server is sub
|
||||
|
||||
async def test_as_proxy_ignored_for_proxy_mounts_default(self):
|
||||
mcp = FastMCP("Main")
|
||||
|
|
@ -894,7 +904,9 @@ class TestAsProxyKwarg:
|
|||
|
||||
mcp.mount(sub_proxy, "sub")
|
||||
|
||||
assert mcp._mounted_servers[0].server is sub_proxy
|
||||
provider = mcp._providers[0]
|
||||
assert isinstance(provider, MountedProvider)
|
||||
assert provider.server is sub_proxy
|
||||
|
||||
async def test_as_proxy_ignored_for_proxy_mounts_false(self):
|
||||
mcp = FastMCP("Main")
|
||||
|
|
@ -903,7 +915,9 @@ class TestAsProxyKwarg:
|
|||
|
||||
mcp.mount(sub_proxy, "sub", as_proxy=False)
|
||||
|
||||
assert mcp._mounted_servers[0].server is sub_proxy
|
||||
provider = mcp._providers[0]
|
||||
assert isinstance(provider, MountedProvider)
|
||||
assert provider.server is sub_proxy
|
||||
|
||||
async def test_as_proxy_ignored_for_proxy_mounts_true(self):
|
||||
mcp = FastMCP("Main")
|
||||
|
|
@ -912,7 +926,9 @@ class TestAsProxyKwarg:
|
|||
|
||||
mcp.mount(sub_proxy, "sub", as_proxy=True)
|
||||
|
||||
assert mcp._mounted_servers[0].server is sub_proxy
|
||||
provider = mcp._providers[0]
|
||||
assert isinstance(provider, MountedProvider)
|
||||
assert provider.server is sub_proxy
|
||||
|
||||
async def test_as_proxy_mounts_still_have_live_link(self):
|
||||
mcp = FastMCP("Main")
|
||||
|
|
@ -1124,81 +1140,30 @@ class TestCustomRouteForwarding:
|
|||
assert len(routes) == 1
|
||||
assert routes[0].path == "/test" # type: ignore[attr-defined]
|
||||
|
||||
async def test_get_additional_http_routes_with_mounted_server(self):
|
||||
"""Test _get_additional_http_routes includes routes from mounted servers."""
|
||||
main_server = FastMCP("MainServer")
|
||||
sub_server = FastMCP("SubServer")
|
||||
|
||||
@sub_server.custom_route("/sub-route", methods=["GET"])
|
||||
async def sub_route(request):
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
return JSONResponse({"message": "from sub"})
|
||||
|
||||
# Mount the sub server
|
||||
main_server.mount(sub_server, "sub")
|
||||
|
||||
routes = main_server._get_additional_http_routes()
|
||||
assert len(routes) == 1
|
||||
assert routes[0].path == "/sub-route" # type: ignore[attr-defined]
|
||||
|
||||
async def test_get_additional_http_routes_recursive(self):
|
||||
"""Test _get_additional_http_routes works recursively with nested mounts."""
|
||||
main_server = FastMCP("MainServer")
|
||||
sub_server = FastMCP("SubServer")
|
||||
nested_server = FastMCP("NestedServer")
|
||||
|
||||
@main_server.custom_route("/main-route", methods=["GET"])
|
||||
async def main_route(request):
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
return JSONResponse({"message": "from main"})
|
||||
|
||||
@sub_server.custom_route("/sub-route", methods=["GET"])
|
||||
async def sub_route(request):
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
return JSONResponse({"message": "from sub"})
|
||||
|
||||
@nested_server.custom_route("/nested-route", methods=["GET"])
|
||||
async def nested_route(request):
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
return JSONResponse({"message": "from nested"})
|
||||
|
||||
# Create nested mounting: main -> sub -> nested
|
||||
sub_server.mount(nested_server, "nested")
|
||||
main_server.mount(sub_server, "sub")
|
||||
|
||||
routes = main_server._get_additional_http_routes()
|
||||
|
||||
# Should include all routes
|
||||
assert len(routes) == 3
|
||||
route_paths = [route.path for route in routes] # type: ignore[attr-defined]
|
||||
assert "/main-route" in route_paths
|
||||
assert "/sub-route" in route_paths
|
||||
assert "/nested-route" in route_paths
|
||||
|
||||
async def test_mounted_servers_tracking(self):
|
||||
"""Test that _mounted_servers list tracks mounted servers correctly."""
|
||||
"""Test that _providers list tracks mounted servers correctly."""
|
||||
main_server = FastMCP("MainServer")
|
||||
sub_server1 = FastMCP("SubServer1")
|
||||
sub_server2 = FastMCP("SubServer2")
|
||||
|
||||
# Initially no mounted servers
|
||||
assert len(main_server._mounted_servers) == 0
|
||||
# Initially no providers
|
||||
assert len(main_server._providers) == 0
|
||||
|
||||
# Mount first server
|
||||
main_server.mount(sub_server1, "sub1")
|
||||
assert len(main_server._mounted_servers) == 1
|
||||
assert main_server._mounted_servers[0].server == sub_server1
|
||||
assert main_server._mounted_servers[0].prefix == "sub1"
|
||||
assert len(main_server._providers) == 1
|
||||
provider1 = main_server._providers[0]
|
||||
assert isinstance(provider1, MountedProvider)
|
||||
assert provider1.server == sub_server1
|
||||
assert provider1.prefix == "sub1"
|
||||
|
||||
# Mount second server
|
||||
main_server.mount(sub_server2, "sub2")
|
||||
assert len(main_server._mounted_servers) == 2
|
||||
assert main_server._mounted_servers[1].server == sub_server2
|
||||
assert main_server._mounted_servers[1].prefix == "sub2"
|
||||
assert len(main_server._providers) == 2
|
||||
provider2 = main_server._providers[1]
|
||||
assert isinstance(provider2, MountedProvider)
|
||||
assert provider2.server == sub_server2
|
||||
assert provider2.prefix == "sub2"
|
||||
|
||||
async def test_multiple_routes_same_server(self):
|
||||
"""Test that multiple custom routes from same server are all included."""
|
||||
|
|
@ -1424,3 +1389,117 @@ class TestToolNameOverrides:
|
|||
async with Client(main) as client:
|
||||
result = await client.call_tool("renamed", {})
|
||||
assert result.data == "success"
|
||||
|
||||
|
||||
class TestMountedServerDocketBehavior:
|
||||
"""Regression tests for mounted server lifecycle behavior.
|
||||
|
||||
These tests guard against architectural changes that could accidentally
|
||||
start Docket instances for mounted servers. Mounted servers should only
|
||||
run their user-defined lifespan, not the full _lifespan_manager which
|
||||
includes Docket creation.
|
||||
"""
|
||||
|
||||
async def test_mounted_server_does_not_have_docket(self):
|
||||
"""Test that a mounted server doesn't create its own Docket.
|
||||
|
||||
MountedProvider.lifespan() should call only the server's _lifespan
|
||||
(user-defined lifespan), not _lifespan_manager (which includes Docket).
|
||||
"""
|
||||
main_app = FastMCP("MainApp")
|
||||
sub_app = FastMCP("SubApp")
|
||||
|
||||
@sub_app.tool
|
||||
def my_tool() -> str:
|
||||
return "test"
|
||||
|
||||
main_app.mount(sub_app, "sub")
|
||||
|
||||
# After running the main app's lifespan, the sub app should not have
|
||||
# its own Docket instance
|
||||
async with Client(main_app) as client:
|
||||
# The main app should have a docket (created by _lifespan_manager)
|
||||
assert main_app.docket is not None
|
||||
|
||||
# The mounted sub app should NOT have its own docket
|
||||
# It uses the parent's docket for background tasks
|
||||
assert sub_app.docket is None
|
||||
|
||||
# But the tool should still work (prefixed as sub_my_tool)
|
||||
result = await client.call_tool("sub_my_tool", {})
|
||||
assert result.data == "test"
|
||||
|
||||
|
||||
class TestComponentServicePrefixLess:
|
||||
"""Test that ComponentService works with prefix-less mounted servers."""
|
||||
|
||||
async def test_enable_tool_prefixless_mount(self):
|
||||
"""Test enabling a tool on a prefix-less mounted server."""
|
||||
from fastmcp.contrib.component_manager.component_service import ComponentService
|
||||
|
||||
main_app = FastMCP("MainApp")
|
||||
sub_app = FastMCP("SubApp")
|
||||
|
||||
@sub_app.tool
|
||||
def my_tool() -> str:
|
||||
return "test"
|
||||
|
||||
# Mount without prefix
|
||||
main_app.mount(sub_app)
|
||||
|
||||
# Initially the tool is enabled
|
||||
tools = await main_app.get_tools()
|
||||
assert "my_tool" in tools
|
||||
assert tools["my_tool"].enabled
|
||||
|
||||
# Disable and re-enable via ComponentService
|
||||
service = ComponentService(main_app)
|
||||
tool = await service._disable_tool("my_tool")
|
||||
assert not tool.enabled
|
||||
|
||||
tool = await service._enable_tool("my_tool")
|
||||
assert tool.enabled
|
||||
|
||||
async def test_enable_resource_prefixless_mount(self):
|
||||
"""Test enabling a resource on a prefix-less mounted server."""
|
||||
from fastmcp.contrib.component_manager.component_service import ComponentService
|
||||
|
||||
main_app = FastMCP("MainApp")
|
||||
sub_app = FastMCP("SubApp")
|
||||
|
||||
@sub_app.resource(uri="data://test")
|
||||
def my_resource() -> str:
|
||||
return "test data"
|
||||
|
||||
# Mount without prefix
|
||||
main_app.mount(sub_app)
|
||||
|
||||
# Disable and re-enable via ComponentService
|
||||
service = ComponentService(main_app)
|
||||
resource = await service._disable_resource("data://test")
|
||||
assert not resource.enabled
|
||||
|
||||
resource = await service._enable_resource("data://test")
|
||||
assert resource.enabled
|
||||
|
||||
async def test_enable_prompt_prefixless_mount(self):
|
||||
"""Test enabling a prompt on a prefix-less mounted server."""
|
||||
from fastmcp.contrib.component_manager.component_service import ComponentService
|
||||
|
||||
main_app = FastMCP("MainApp")
|
||||
sub_app = FastMCP("SubApp")
|
||||
|
||||
@sub_app.prompt
|
||||
def my_prompt() -> str:
|
||||
return "test prompt"
|
||||
|
||||
# Mount without prefix
|
||||
main_app.mount(sub_app)
|
||||
|
||||
# Disable and re-enable via ComponentService
|
||||
service = ComponentService(main_app)
|
||||
prompt = await service._disable_prompt("my_prompt")
|
||||
assert not prompt.enabled
|
||||
|
||||
prompt = await service._enable_prompt("my_prompt")
|
||||
assert prompt.enabled
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ import pytest
|
|||
from mcp.types import AnyUrl, PromptMessage, TextContent
|
||||
from mcp.types import Tool as MCPTool
|
||||
|
||||
from fastmcp import FastMCP, Provider
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.client.client import CallToolResult
|
||||
from fastmcp.prompts.prompt import FunctionPrompt, Prompt, PromptResult
|
||||
from fastmcp.resources.resource import FunctionResource, Resource, ResourceContent
|
||||
from fastmcp.resources.template import FunctionResourceTemplate, ResourceTemplate
|
||||
from fastmcp.server.context import Context
|
||||
from fastmcp.server.providers import Provider
|
||||
from fastmcp.tools.tool import Tool, ToolResult
|
||||
|
||||
|
||||
|
|
@ -43,15 +43,16 @@ class SimpleToolProvider(Provider):
|
|||
"""A simple provider that returns a configurable list of tools."""
|
||||
|
||||
def __init__(self, tools: list[Tool] | None = None):
|
||||
super().__init__()
|
||||
self._tools = tools or []
|
||||
self.list_tools_call_count = 0
|
||||
self.get_tool_call_count = 0
|
||||
|
||||
async def list_tools(self, context: Context) -> list[Tool]:
|
||||
async def list_tools(self) -> list[Tool]:
|
||||
self.list_tools_call_count += 1
|
||||
return self._tools
|
||||
|
||||
async def get_tool(self, context: Context, name: str) -> Tool | None:
|
||||
async def get_tool(self, name: str) -> Tool | None:
|
||||
self.get_tool_call_count += 1
|
||||
return next((t for t in self._tools if t.name == name), None)
|
||||
|
||||
|
|
@ -60,10 +61,11 @@ class ListOnlyProvider(Provider):
|
|||
"""A provider that only implements list_tools (uses default get_tool)."""
|
||||
|
||||
def __init__(self, tools: list[Tool]):
|
||||
super().__init__()
|
||||
self._tools = tools
|
||||
self.list_tools_call_count = 0
|
||||
|
||||
async def list_tools(self, context: Context) -> list[Tool]:
|
||||
async def list_tools(self) -> list[Tool]:
|
||||
self.list_tools_call_count += 1
|
||||
return self._tools
|
||||
|
||||
|
|
@ -150,8 +152,9 @@ class TestProvider:
|
|||
await client.list_tools()
|
||||
await client.list_tools()
|
||||
|
||||
# Provider should have been called 3 times
|
||||
assert provider.list_tools_call_count == 3
|
||||
# Provider should have been called 4 times
|
||||
# (1 from get_tasks() during docket registration + 3 from client)
|
||||
assert provider.list_tools_call_count == 4
|
||||
|
||||
async def test_call_dynamic_tool(
|
||||
self, base_server: FastMCP, dynamic_tools: list[Tool]
|
||||
|
|
@ -210,11 +213,12 @@ class TestProvider:
|
|||
async with Client(base_server) as client:
|
||||
await client.call_tool(name="dynamic_multiply", arguments={"a": 2, "b": 3})
|
||||
|
||||
# get_tool is called twice:
|
||||
# 1. Server calls get_tool() to check _should_enable_component filter
|
||||
# 2. Default call_tool() implementation calls get_tool() internally
|
||||
# get_tool is called three times:
|
||||
# 1. Server.get_tool() for task config check calls provider.get_tool()
|
||||
# 2. _call_tool() calls provider.get_tool() to check _should_enable_component
|
||||
# 3. Default call_tool() implementation calls get_tool() internally
|
||||
# Key point: list_tools is NOT called during tool execution (efficient lookup)
|
||||
assert provider.get_tool_call_count == 2
|
||||
assert provider.get_tool_call_count == 3
|
||||
|
||||
async def test_default_get_tool_falls_back_to_list(self, base_server: FastMCP):
|
||||
"""Test that BaseToolProvider's default get_tool calls list_tools."""
|
||||
|
|
@ -298,17 +302,13 @@ class TestProviderClass:
|
|||
)
|
||||
provider = ListOnlyProvider(tools=[tool])
|
||||
|
||||
# Create a context for direct testing
|
||||
mcp = FastMCP("TestServer")
|
||||
ctx = Context(mcp)
|
||||
|
||||
# Default get_tool should find by name
|
||||
found = await provider.get_tool(ctx, "test")
|
||||
found = await provider.get_tool("test")
|
||||
assert found is not None
|
||||
assert found.name == "test"
|
||||
|
||||
# Should return None for unknown names
|
||||
not_found = await provider.get_tool(ctx, "unknown")
|
||||
not_found = await provider.get_tool("unknown")
|
||||
assert not_found is None
|
||||
|
||||
|
||||
|
|
@ -389,6 +389,7 @@ class TestProviderExecutionMethods:
|
|||
"""Provider that wraps tool execution with custom logic."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.call_count = 0
|
||||
self._tool = SimpleTool(
|
||||
name="custom_tool",
|
||||
|
|
@ -397,20 +398,20 @@ class TestProviderExecutionMethods:
|
|||
operation="add",
|
||||
)
|
||||
|
||||
async def list_tools(self, context: Context) -> Sequence[Tool]:
|
||||
async def list_tools(self) -> Sequence[Tool]:
|
||||
return [self._tool]
|
||||
|
||||
async def get_tool(self, context: Context, name: str) -> Tool | None:
|
||||
async def get_tool(self, name: str) -> Tool | None:
|
||||
if name == "custom_tool":
|
||||
return self._tool
|
||||
return None
|
||||
|
||||
async def call_tool(
|
||||
self, context: Context, name: str, arguments: dict[str, Any]
|
||||
self, name: str, arguments: dict[str, Any]
|
||||
) -> ToolResult | None:
|
||||
# Custom behavior: track calls and modify result
|
||||
self.call_count += 1
|
||||
tool = await self.get_tool(context, name)
|
||||
tool = await self.get_tool(name)
|
||||
if tool is None:
|
||||
return None
|
||||
result = await tool.run(arguments)
|
||||
|
|
@ -434,7 +435,7 @@ class TestProviderExecutionMethods:
|
|||
"""Test that default read_resource uses get_resource and reads it."""
|
||||
|
||||
class ResourceProvider(Provider):
|
||||
async def list_resources(self, context: Context) -> Sequence[Resource]:
|
||||
async def list_resources(self) -> Sequence[Resource]:
|
||||
return [
|
||||
FunctionResource(
|
||||
uri=AnyUrl("test://data"),
|
||||
|
|
@ -459,7 +460,7 @@ class TestProviderExecutionMethods:
|
|||
class CustomReadProvider(Provider):
|
||||
"""Provider that transforms resource content."""
|
||||
|
||||
async def list_resources(self, context: Context) -> Sequence[Resource]:
|
||||
async def list_resources(self) -> Sequence[Resource]:
|
||||
return [
|
||||
FunctionResource(
|
||||
uri=AnyUrl("test://data"),
|
||||
|
|
@ -468,9 +469,7 @@ class TestProviderExecutionMethods:
|
|||
)
|
||||
]
|
||||
|
||||
async def read_resource(
|
||||
self, context: Context, uri: str
|
||||
) -> ResourceContent | None:
|
||||
async def read_resource(self, uri: str) -> ResourceContent | None:
|
||||
if uri == "test://data":
|
||||
# Custom behavior: return transformed content
|
||||
return ResourceContent(content="TRANSFORMED")
|
||||
|
|
@ -490,9 +489,7 @@ class TestProviderExecutionMethods:
|
|||
"""Test that read_resource_template handles template-based resources."""
|
||||
|
||||
class TemplateProvider(Provider):
|
||||
async def list_resource_templates(
|
||||
self, context: Context
|
||||
) -> Sequence[ResourceTemplate]:
|
||||
async def list_resource_templates(self) -> Sequence[ResourceTemplate]:
|
||||
return [
|
||||
FunctionResourceTemplate.from_function(
|
||||
fn=lambda name: f"content of {name}",
|
||||
|
|
@ -515,7 +512,7 @@ class TestProviderExecutionMethods:
|
|||
"""Test that default render_prompt uses get_prompt and renders it."""
|
||||
|
||||
class PromptProvider(Provider):
|
||||
async def list_prompts(self, context: Context) -> Sequence[Prompt]:
|
||||
async def list_prompts(self) -> Sequence[Prompt]:
|
||||
return [
|
||||
FunctionPrompt.from_function(
|
||||
fn=lambda name: f"Hello, {name}!",
|
||||
|
|
@ -540,7 +537,7 @@ class TestProviderExecutionMethods:
|
|||
class CustomRenderProvider(Provider):
|
||||
"""Provider that adds prefix to all prompts."""
|
||||
|
||||
async def list_prompts(self, context: Context) -> Sequence[Prompt]:
|
||||
async def list_prompts(self) -> Sequence[Prompt]:
|
||||
return [
|
||||
FunctionPrompt.from_function(
|
||||
fn=lambda: "original message",
|
||||
|
|
@ -550,7 +547,7 @@ class TestProviderExecutionMethods:
|
|||
]
|
||||
|
||||
async def render_prompt(
|
||||
self, context: Context, name: str, arguments: dict[str, Any] | None
|
||||
self, name: str, arguments: dict[str, Any] | None
|
||||
) -> PromptResult | None:
|
||||
if name == "test_prompt":
|
||||
# Custom behavior: add prefix
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from fastmcp import Client, FastMCP
|
|||
from fastmcp.exceptions import NotFoundError
|
||||
from fastmcp.prompts.prompt import FunctionPrompt, Prompt
|
||||
from fastmcp.resources import Resource, ResourceContent, ResourceTemplate
|
||||
from fastmcp.server.server import (
|
||||
from fastmcp.server.providers.mounted import (
|
||||
add_resource_prefix,
|
||||
has_resource_prefix,
|
||||
remove_resource_prefix,
|
||||
|
|
@ -1329,8 +1329,6 @@ class TestResourcePrefixMounting:
|
|||
self, uri, prefix, expected_match, expected_strip
|
||||
):
|
||||
"""Test that resource prefix utility functions correctly match and strip resource prefixes."""
|
||||
from fastmcp.server.server import has_resource_prefix, remove_resource_prefix
|
||||
|
||||
# Test matching
|
||||
assert has_resource_prefix(uri, prefix) == expected_match
|
||||
|
||||
|
|
|
|||
|
|
@ -1057,8 +1057,8 @@ class TestMountedComponentsRaiseOnLoadError:
|
|||
|
||||
# Create a failing mounted server by corrupting it
|
||||
parent_mcp.mount(child_mcp, prefix="child")
|
||||
# Corrupt the parent's mounted servers to make it fail during loading
|
||||
parent_mcp._mounted_servers.append("invalid") # type: ignore
|
||||
# Corrupt the parent's providers to make it fail during loading
|
||||
parent_mcp._providers.append("invalid") # type: ignore
|
||||
|
||||
# Should not raise, just warn; use server middleware path now
|
||||
tools = await parent_mcp._list_tools_middleware()
|
||||
|
|
@ -1071,13 +1071,13 @@ class TestMountedComponentsRaiseOnLoadError:
|
|||
|
||||
# Create a failing mounted server
|
||||
parent_mcp.mount(child_mcp, prefix="child")
|
||||
# Corrupt the parent's mounted servers to make it fail during loading
|
||||
parent_mcp._mounted_servers.append("invalid") # type: ignore
|
||||
# Corrupt the parent's providers to make it fail during loading
|
||||
parent_mcp._providers.append("invalid") # type: ignore
|
||||
|
||||
# Use temporary settings context manager
|
||||
with temporary_settings(mounted_components_raise_on_load_error=True):
|
||||
# Should raise the exception
|
||||
with pytest.raises(
|
||||
AttributeError, match="'str' object has no attribute 'server'"
|
||||
AttributeError, match="'str' object has no attribute 'list_tools'"
|
||||
):
|
||||
await parent_mcp._list_tools_middleware()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue