feat: Provider abstraction for dynamic MCP components (#2622)

* feat: add Provider abstraction for dynamic components

Introduces a `Provider` base class that allows dynamic provision of
tools, resources, and prompts at runtime. Providers are queried after
static components, enabling database-backed tools, external integrations,
and other dynamic component sources.

```python
from fastmcp import FastMCP, Provider

class DatabaseProvider(Provider):
    async def list_tools(self, context):
        return await db.fetch_tools()

mcp = FastMCP("Server", providers=[DatabaseProvider()])
```

* Add execution methods to Provider interface

Enable providers to customize how components are executed, not just listed.
This unlocks MountedProvider (future) invoking wrapped server middleware.

- call_tool, read_resource, read_resource_template, render_prompt
- Server uses provider execution methods after filter checks
- Default implementations delegate to component methods

* Apply unified error handling to provider execution

- Document error semantics: list_* gracefully degrades, execution propagates
- Wrap provider call_tool/read_resource/render_prompt with masking logic
- ToolError/ResourceError/PromptError pass through, others wrapped
This commit is contained in:
Jeremiah Lowin 2025-12-16 22:05:43 -05:00 committed by GitHub
commit 0e5a8fed6c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1398 additions and 63 deletions

View file

@ -133,6 +133,14 @@ async with Client(transport=StreamableHttpTransport(server_url)) as client:
- Use `# type: ignore[attr-defined]` in tests for MCP results instead of type assertions
- Each feature needs corresponding tests
### Module Exports
- **Be intentional about re-exports** - don't blindly re-export everything to parent namespaces
- Core types that define a module's purpose should be exported (e.g., `Middleware` from `fastmcp.server.middleware`)
- Specialized features can live in submodules (e.g., `fastmcp.server.middleware.dynamic`)
- Only re-export to `fastmcp.*` for the most fundamental types (e.g., `FastMCP`, `Client`)
- When in doubt, prefer users importing from the specific submodule over re-exporting
### Documentation
- Uses Mintlify framework

View file

@ -0,0 +1,59 @@
# Dynamic Tools from SQLite
This example demonstrates serving MCP tools from a database. Tools can be added, modified, or disabled by updating the database - no server restart required.
## Structure
- `tools.db` - SQLite database with tool configurations (committed for convenience)
- `setup_db.py` - Script to create/reset the database
- `server.py` - MCP server that loads tools from the database
## Usage
```bash
# Reset the database (optional - tools.db is pre-seeded)
uv run examples/dynamic_tools_sqlite/setup_db.py
# Run the server
uv run fastmcp run examples/dynamic_tools_sqlite/server.py
```
## How It Works
The `SQLiteToolProvider` queries the database on every `list_tools` and `call_tool` request:
```python
class SQLiteToolProvider(BaseToolProvider):
async def list_tools(self) -> list[Tool]:
# Query database for enabled tools
...
async def get_tool(self, name: str) -> Tool | None:
# Efficient single-tool lookup
...
```
Tools are defined as `ConfigurableTool` subclasses that combine schema and execution:
```python
class ConfigurableTool(Tool):
operation: str # "add", "multiply", etc.
async def run(self, arguments: dict[str, Any]) -> ToolResult:
# Execute based on configured operation
...
```
## Modifying Tools at Runtime
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)"
# Disable a tool
sqlite3 examples/dynamic_tools_sqlite/tools.db "UPDATE tools SET enabled = 0 WHERE name = 'divide_numbers'"
```
The next `list_tools` or `call_tool` request will reflect these changes.

View file

@ -0,0 +1,137 @@
# /// script
# dependencies = ["aiosqlite", "fastmcp"]
# ///
"""
MCP server with database-configured tools.
Tools are loaded from tools.db on each request, so you can add/modify/disable
tools in the database without restarting the server.
Run with: uv run fastmcp run examples/providers/sqlite/server.py
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from typing import Any
import aiosqlite
from rich import print
from fastmcp import Client, FastMCP, Provider
from fastmcp.server.context import Context
from fastmcp.tools.tool import Tool, ToolResult
DB_PATH = Path(__file__).parent / "tools.db"
class ConfigurableTool(Tool):
"""A tool that performs a configured arithmetic operation.
This demonstrates the pattern: Tool subclass = schema + execution in one place.
"""
operation: str # "add", "multiply", "subtract", "divide"
default_value: float = 0
async def run(self, arguments: dict[str, Any]) -> ToolResult:
a = arguments.get("a", self.default_value)
b = arguments.get("b", self.default_value)
if self.operation == "add":
result = a + b
elif self.operation == "multiply":
result = a * b
elif self.operation == "subtract":
result = a - b
elif self.operation == "divide":
if b == 0:
return ToolResult(
structured_content={
"error": "Division by zero",
"operation": self.operation,
}
)
result = a / b
else:
result = a + b
return ToolResult(
structured_content={"result": result, "operation": self.operation}
)
class SQLiteToolProvider(Provider):
"""Queries SQLite for tool configurations.
Called on every list_tools/get_tool request, so database changes
are reflected immediately without server restart.
"""
def __init__(self, db_path: str):
self.db_path = db_path
async def list_tools(self, context: Context) -> list[Tool]:
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute("SELECT * FROM tools WHERE enabled = 1") as cursor:
rows = await cursor.fetchall()
return [self._make_tool(row) for row in rows]
async def get_tool(self, context: Context, name: str) -> Tool | None:
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM tools WHERE name = ? AND enabled = 1", (name,)
) as cursor:
row = await cursor.fetchone()
return self._make_tool(row) if row else None
def _make_tool(self, row: aiosqlite.Row) -> ConfigurableTool:
return ConfigurableTool(
name=row["name"],
description=row["description"],
parameters=json.loads(row["parameters_schema"]),
operation=row["operation"],
default_value=row["default_value"] or 0,
)
mcp = FastMCP("DynamicToolsServer")
provider = SQLiteToolProvider(db_path=str(DB_PATH))
mcp.add_provider(provider)
@mcp.tool
def server_info() -> dict[str, str]:
"""Get information about this server (static tool)."""
return {
"name": "DynamicToolsServer",
"description": "A server with database-configured tools",
"database": str(DB_PATH),
}
async def main():
async with Client(mcp) as client:
tools = await client.list_tools()
print(f"[bold]Available tools ({len(tools)}):[/bold]")
for tool in tools:
print(f"{tool.name}: {tool.description}")
print()
print("[bold]Calling add_numbers(10, 5):[/bold]")
result = await client.call_tool("add_numbers", {"a": 10, "b": 5})
print(f" Result: {result.structured_content}")
print()
print("[bold]Calling multiply_numbers(7, 6):[/bold]")
result = await client.call_tool("multiply_numbers", {"a": 7, "b": 6})
print(f" Result: {result.structured_content}")
if __name__ == "__main__":
asyncio.run(main())

View file

@ -0,0 +1,102 @@
# /// script
# dependencies = ["aiosqlite"]
# ///
"""
Creates and seeds the tools database.
Run with: uv run examples/dynamic_tools_sqlite/setup_db.py
"""
import asyncio
import json
from pathlib import Path
import aiosqlite
DB_PATH = Path(__file__).parent / "tools.db"
async def setup_database() -> None:
"""Create the tools table and seed with example tools."""
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("""
CREATE TABLE IF NOT EXISTS tools (
name TEXT PRIMARY KEY,
description TEXT NOT NULL,
parameters_schema TEXT NOT NULL,
operation TEXT NOT NULL,
default_value REAL,
enabled INTEGER DEFAULT 1
)
""")
tools_data = [
(
"add_numbers",
"Add two numbers together",
json.dumps(
{
"type": "object",
"properties": {
"a": {"type": "number", "description": "First number"},
"b": {"type": "number", "description": "Second number"},
},
"required": ["a", "b"],
}
),
"add",
0,
1,
),
(
"multiply_numbers",
"Multiply two numbers",
json.dumps(
{
"type": "object",
"properties": {
"a": {"type": "number", "description": "First number"},
"b": {"type": "number", "description": "Second number"},
},
"required": ["a", "b"],
}
),
"multiply",
1,
1,
),
(
"divide_numbers",
"Divide two numbers",
json.dumps(
{
"type": "object",
"properties": {
"a": {"type": "number", "description": "Dividend"},
"b": {"type": "number", "description": "Divisor"},
},
"required": ["a", "b"],
}
),
"divide",
0,
1,
),
]
await db.executemany(
"""
INSERT OR REPLACE INTO tools
(name, description, parameters_schema, operation, default_value, enabled)
VALUES (?, ?, ?, ?, ?, ?)
""",
tools_data,
)
await db.commit()
print(f"Database created at: {DB_PATH}")
print("Seeded 3 tools: add_numbers, multiply_numbers, divide_numbers")
if __name__ == "__main__":
asyncio.run(setup_database())

View file

@ -14,6 +14,7 @@ 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
@ -31,5 +32,6 @@ __all__ = [
"Client",
"Context",
"FastMCP",
"Provider",
"settings",
]

View file

@ -107,18 +107,17 @@ class PromptManager:
arguments: dict[str, Any] | None = None,
) -> PromptResult:
"""
Internal API for servers: Finds and renders a prompt, respecting the
filtered protocol path.
Internal API for servers: Finds and renders a prompt.
Note: Full error handling (logging, masking) is done at the FastMCP
server level. This method provides basic error wrapping for direct usage.
"""
prompt = await self.get_prompt(name)
try:
return await prompt._render(arguments)
except PromptError:
logger.exception(f"Error rendering prompt {name!r}")
raise
except Exception as e:
logger.exception(f"Error rendering prompt {name!r}")
if self.mask_error_details:
raise PromptError(f"Error rendering prompt {name!r}") from e
else:
raise PromptError(f"Error rendering prompt {name!r}: {e}") from e
raise PromptError(f"Error rendering prompt {name!r}: {e}") from e

222
src/fastmcp/providers.py Normal file
View file

@ -0,0 +1,222 @@
"""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.server.context import Context
from fastmcp.tools import Tool
class DatabaseProvider(Provider):
def __init__(self, db_url: str):
self.db = Database(db_url)
async def list_tools(self, context: Context) -> 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:
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 __future__ import annotations
from collections.abc import Sequence
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.server.context import Context
__all__ = [
"Provider",
]
class Provider:
"""Base class for dynamic component providers.
Subclass and override whichever methods you need. Default implementations
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
- Providers are queried in registration order; first non-None wins
Error handling:
- `list_*` methods: Errors are logged and the provider returns empty (graceful degradation).
This allows other providers to still contribute their components.
- Execution methods (`call_tool`, `read_resource`, `render_prompt`): Errors propagate
with unified handling. ToolError/ResourceError/PromptError pass through; other
exceptions are wrapped with optional detail masking.
"""
async def list_tools(self, context: Context) -> Sequence[Tool]:
"""Return all available tools.
Override to provide tools dynamically.
"""
return []
async def get_tool(self, context: Context, name: str) -> Tool | None:
"""Get a specific tool by name.
Default implementation lists all tools and finds by name.
Override for more efficient single-tool lookup.
Returns:
The Tool if found, or None to continue searching other providers.
"""
tools = await self.list_tools(context)
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]
) -> ToolResult | None:
"""Execute a tool by name.
Default implementation gets the tool and runs it.
Override for custom execution logic (e.g., middleware, error handling).
Returns:
The ToolResult if found and executed, or None if tool not found.
"""
tool = await self.get_tool(context, name)
if tool is None:
return None
return await tool.run(arguments)
async def list_resources(self, context: Context) -> Sequence[Resource]:
"""Return all available resources.
Override to provide resources dynamically.
"""
return []
async def get_resource(self, context: Context, uri: str) -> Resource | None:
"""Get a specific resource by URI.
Default implementation lists all resources and finds by URI.
Override for more efficient single-resource lookup.
Returns:
The Resource if found, or None to continue searching other providers.
"""
resources = await self.list_resources(context)
return next((r for r in resources if str(r.uri) == uri), None)
async def list_resource_templates(
self, context: Context
) -> 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:
"""Get a resource template that matches the given URI.
Default implementation lists all templates and finds one whose pattern
matches the URI.
Override for more efficient lookup.
Returns:
The ResourceTemplate if a matching one is found, or None to continue searching.
"""
templates = await self.list_resource_templates(context)
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:
"""Read a concrete resource by URI.
Default implementation gets the resource and reads it.
Override for custom read logic (e.g., middleware, caching).
Note: This only handles concrete resources. For template-based resources,
use read_resource_template().
Returns:
The ResourceContent if found and read, or None if not found.
"""
resource = await self.get_resource(context, uri)
if resource is None:
return None
return await resource._read()
async def read_resource_template(
self, context: Context, uri: str
) -> ResourceContent | None:
"""Read a resource via a matching template.
Default implementation finds a matching template, creates a resource
from it, and reads the content.
Override for custom read logic (e.g., middleware, caching).
Returns:
The ResourceContent if a matching template is found and read,
or None if no template matches.
"""
template = await self.get_resource_template(context, uri)
if template is None:
return None
params = template.matches(uri)
if params is None:
return None
resource = await template.create_resource(uri, params)
return await resource._read()
async def list_prompts(self, context: Context) -> Sequence[Prompt]:
"""Return all available prompts.
Override to provide prompts dynamically.
"""
return []
async def get_prompt(self, context: Context, name: str) -> Prompt | None:
"""Get a specific prompt by name.
Default implementation lists all prompts and finds by name.
Override for more efficient single-prompt lookup.
Returns:
The Prompt if found, or None to continue searching other providers.
"""
prompts = await self.list_prompts(context)
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
) -> PromptResult | None:
"""Render a prompt by name.
Default implementation gets the prompt and renders it.
Override for custom render logic (e.g., middleware, templating).
Returns:
The PromptResult if found and rendered, or None if not found.
"""
prompt = await self.get_prompt(context, name)
if prompt is None:
return None
return await prompt._render(arguments)

View file

@ -288,60 +288,43 @@ class ResourceManager:
async def read_resource(self, uri: AnyUrl | str) -> ResourceContent:
"""
Internal API for servers: Finds and reads a resource, respecting the
filtered protocol path.
Internal API for servers: Finds and reads a resource.
Note: Full error handling (logging, masking) is done at the FastMCP
server level. This method provides basic error wrapping for direct usage.
Returns:
ResourceContent: The canonical content wrapper. All Resource.read()
implementations now return ResourceContent.
ResourceContent: The canonical content wrapper.
"""
uri_str = str(uri)
# 1. Check local resources first. The server will have already applied its filter.
# Check local resources first
if uri_str in self._resources:
resource = await self.get_resource(uri_str)
try:
return await resource._read()
# raise ResourceErrors as-is
except ResourceError as e:
logger.exception(f"Error reading resource {uri_str!r}")
raise e
# Handle other exceptions
except ResourceError:
raise
except Exception as e:
logger.exception(f"Error reading resource {uri_str!r}")
if self.mask_error_details:
# Mask internal details
raise ResourceError(f"Error reading resource {uri_str!r}") from e
else:
# Include original error details
raise ResourceError(
f"Error reading resource {uri_str!r}: {e}"
) from e
raise ResourceError(f"Error reading resource {uri_str!r}: {e}") from e
# 1b. Check local templates if not found in concrete resources
# Check local templates if not found in concrete resources
for key, template in self._templates.items():
if (params := match_uri_template(uri_str, key)) is not None:
try:
resource = await template.create_resource(uri_str, params=params)
return await resource._read()
except ResourceError as e:
logger.exception(
f"Error reading resource from template {uri_str!r}"
)
raise e
except ResourceError:
raise
except Exception as e:
logger.exception(
f"Error reading resource from template {uri_str!r}"
)
if self.mask_error_details:
raise ResourceError(
f"Error reading resource from template {uri_str!r}"
) from e
else:
raise ResourceError(
f"Error reading resource from template {uri_str!r}: {e}"
) from e
raise ResourceError(
f"Error reading resource from template {uri_str!r}: {e}"
) from e
raise NotFoundError(f"Resource {uri_str!r} not found.")

View file

@ -1,7 +1,7 @@
from .middleware import (
CallNext,
Middleware,
MiddlewareContext,
CallNext,
)
__all__ = [

View file

@ -50,6 +50,7 @@ from mcp.types import Resource as SDKResource
from mcp.types import ResourceTemplate as SDKResourceTemplate
from mcp.types import Tool as SDKTool
from pydantic import AnyUrl
from pydantic import ValidationError as PydanticValidationError
from starlette.middleware import Middleware as ASGIMiddleware
from starlette.requests import Request
from starlette.responses import Response
@ -57,11 +58,19 @@ from starlette.routing import BaseRoute, Route
import fastmcp
import fastmcp.server
from fastmcp.exceptions import DisabledError, NotFoundError
from fastmcp.exceptions import (
DisabledError,
NotFoundError,
PromptError,
ResourceError,
ToolError,
ValidationError,
)
from fastmcp.mcp_config import MCPConfig
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
@ -180,6 +189,7 @@ class FastMCP(Generic[LifespanResultT]):
icons: list[mcp.types.Icon] | None = None,
auth: AuthProvider | NotSetT | None = NotSet,
middleware: Sequence[Middleware] | None = None,
providers: Sequence[Provider] | None = None,
lifespan: LifespanCallable | None = None,
mask_error_details: bool | None = None,
tools: Sequence[Tool | Callable[..., Any]] | None = None,
@ -218,6 +228,7 @@ class FastMCP(Generic[LifespanResultT]):
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
)
@ -234,6 +245,12 @@ class FastMCP(Generic[LifespanResultT]):
duplicate_behavior=on_duplicate_prompts,
mask_error_details=mask_error_details,
)
# Store mask_error_details for execution error handling
self._mask_error_details: bool = (
mask_error_details
if mask_error_details is not None
else fastmcp.settings.mask_error_details
)
self._tool_serializer: Callable[[Any], str] | None = tool_serializer
self._lifespan: LifespanCallable[LifespanResultT] = lifespan or default_lifespan
@ -898,6 +915,18 @@ class FastMCP(Generic[LifespanResultT]):
def add_middleware(self, middleware: Middleware) -> None:
self.middleware.append(middleware)
def add_provider(self, provider: Provider) -> None:
"""Add a provider for dynamic tools, resources, and prompts.
Providers are queried in registration order. The first provider to return
a non-None result wins. Static components (registered via decorators)
always take precedence over providers.
Args:
provider: A Provider instance that will provide components dynamically.
"""
self._providers.append(provider)
async def get_tools(self) -> dict[str, Tool]:
"""Get all tools (unfiltered), including mounted servers, indexed by key."""
all_tools = dict(await self._tool_manager.get_tools())
@ -1215,7 +1244,24 @@ class FastMCP(Generic[LifespanResultT]):
raise
continue
return list(all_tools.values())
# 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")
# Provider tools come first in the list (for visibility),
# but static tools take precedence for execution
return provider_tools_list + list(all_tools.values())
async def _list_resources_mcp(self) -> list[SDKResource]:
"""
@ -1304,7 +1350,24 @@ class FastMCP(Generic[LifespanResultT]):
raise
continue
return list(all_resources.values())
# 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")
# 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())
async def _list_resource_templates_mcp(self) -> list[SDKResourceTemplate]:
"""
@ -1398,7 +1461,21 @@ class FastMCP(Generic[LifespanResultT]):
raise
continue
return list(all_templates.values())
# 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")
return provider_templates_list + list(all_templates.values())
async def _list_prompts_mcp(self) -> list[SDKPrompt]:
"""
@ -1491,7 +1568,24 @@ class FastMCP(Generic[LifespanResultT]):
raise
continue
return list(all_prompts.values())
# 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")
# 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())
async def _call_tool_mcp(
self, key: str, arguments: dict[str, Any]
@ -1649,18 +1743,62 @@ class FastMCP(Generic[LifespanResultT]):
except NotFoundError:
continue
# Try local tools last (mounted servers override local)
# Try local tools (static tools take precedence)
try:
tool = await self._tool_manager.get_tool(tool_name)
if self._should_enable_component(tool):
return await self._tool_manager.call_tool(
key=tool_name, arguments=context.message.arguments or {}
return await self._execute_tool(
tool, tool_name, context.message.arguments or {}
)
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
raise NotFoundError(f"Unknown tool: {tool_name!r}")
async def _execute_tool(
self, tool: Tool, tool_name: str, arguments: dict[str, Any]
) -> ToolResult:
"""Run a tool with unified error handling."""
try:
return await tool.run(arguments)
except (ValidationError, PydanticValidationError):
# Validation errors are never masked - they indicate client input issues
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}")
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
async def _read_resource_mcp(self, uri: AnyUrl | str) -> list[ResourceContent]:
"""
Handle MCP 'readResource' requests.
@ -1740,8 +1878,7 @@ class FastMCP(Generic[LifespanResultT]):
try:
resource = await self._resource_manager.get_resource(uri_str)
if self._should_enable_component(resource):
content = await self._resource_manager.read_resource(uri_str)
# read_resource() always returns ResourceContent now
content = await self._execute_resource(resource, uri_str)
# Use mime_type from ResourceContent if set, otherwise from resource
if content.mime_type is None:
content.mime_type = resource.mime_type
@ -1749,8 +1886,82 @@ class FastMCP(Generic[LifespanResultT]):
except NotFoundError:
pass
# Try local templates
templates = await self._resource_manager.get_resource_templates()
for template in templates.values():
params = template.matches(uri_str)
if params is not None:
if self._should_enable_component(template):
resource = await template.create_resource(uri_str, params)
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
raise NotFoundError(f"Unknown resource: {uri_str!r}")
async def _execute_resource(
self, resource: Resource, uri_str: str
) -> ResourceContent:
"""Read a resource with unified error handling."""
try:
return await resource._read()
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}")
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
async def _get_prompt_mcp(
self, name: str, arguments: dict[str, Any] | None = None
) -> GetPromptResult:
@ -1832,18 +2043,54 @@ class FastMCP(Generic[LifespanResultT]):
except NotFoundError:
continue
# Try local prompts last (mounted servers override local)
# Try local prompts (static prompts take precedence)
try:
prompt = await self._prompt_manager.get_prompt(name)
if self._should_enable_component(prompt):
return await self._prompt_manager.render_prompt(
name=name, arguments=context.message.arguments
return await self._execute_prompt(
prompt, name, context.message.arguments
)
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
raise NotFoundError(f"Unknown prompt: {name!r}")
async def _execute_prompt(
self, prompt: Prompt, name: str, arguments: dict[str, Any] | None
) -> PromptResult:
"""Render a prompt with unified error handling."""
try:
return await prompt._render(arguments)
except PromptError:
logger.exception(f"Error rendering prompt {name!r}")
raise
except Exception as e:
logger.exception(f"Error rendering prompt {name!r}")
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
def add_tool(self, tool: Tool) -> Tool:
"""Add a tool to the server.

View file

@ -152,21 +152,19 @@ class ToolManager:
async def call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult:
"""
Internal API for servers: Finds and calls a tool, respecting the
filtered protocol path.
Internal API for servers: Finds and calls a tool.
Note: Full error handling (logging, masking) is done at the FastMCP
server level. This method provides basic error wrapping for direct usage.
"""
tool = await self.get_tool(key)
try:
return await tool.run(arguments)
except ValidationError as e:
logger.exception(f"Error validating tool {key!r}: {e}")
raise e
except ToolError as e:
logger.exception(f"Error calling tool {key!r}")
raise e
except ValidationError:
raise
except ToolError:
raise
except Exception as e:
logger.exception(f"Error calling tool {key!r}")
if self.mask_error_details:
raise ToolError(f"Error calling tool {key!r}") from e
else:
raise ToolError(f"Error calling tool {key!r}: {e}") from e
raise ToolError(f"Error calling tool {key!r}: {e}") from e

View file

@ -0,0 +1,578 @@
"""Tests for providers."""
from collections.abc import Sequence
from typing import Any
import pytest
from mcp.types import AnyUrl, PromptMessage, TextContent
from mcp.types import Tool as MCPTool
from fastmcp import FastMCP, Provider
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.tools.tool import Tool, ToolResult
class SimpleTool(Tool):
"""A simple tool for testing that performs a configured operation."""
operation: str
value: int = 0
async def run(self, arguments: dict[str, Any]) -> ToolResult:
a = arguments.get("a", 0)
b = arguments.get("b", 0)
if self.operation == "add":
result = a + b + self.value
elif self.operation == "multiply":
result = a * b + self.value
else:
result = a + b
return ToolResult(
structured_content={"result": result, "operation": self.operation}
)
class SimpleToolProvider(Provider):
"""A simple provider that returns a configurable list of tools."""
def __init__(self, tools: list[Tool] | None = None):
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]:
self.list_tools_call_count += 1
return self._tools
async def get_tool(self, context: Context, name: str) -> Tool | None:
self.get_tool_call_count += 1
return next((t for t in self._tools if t.name == name), None)
class ListOnlyProvider(Provider):
"""A provider that only implements list_tools (uses default get_tool)."""
def __init__(self, tools: list[Tool]):
self._tools = tools
self.list_tools_call_count = 0
async def list_tools(self, context: Context) -> list[Tool]:
self.list_tools_call_count += 1
return self._tools
class TestProvider:
"""Tests for Provider."""
@pytest.fixture
def base_server(self):
"""Create a base FastMCP server with static tools."""
mcp = FastMCP("BaseServer")
@mcp.tool
def static_add(a: int, b: int) -> int:
"""Add two numbers (static tool)."""
return a + b
@mcp.tool
def static_subtract(a: int, b: int) -> int:
"""Subtract two numbers (static tool)."""
return a - b
return mcp
@pytest.fixture
def dynamic_tools(self) -> list[Tool]:
"""Create dynamic tools for testing."""
return [
SimpleTool(
name="dynamic_multiply",
description="Multiply two numbers",
parameters={
"type": "object",
"properties": {
"a": {"type": "integer"},
"b": {"type": "integer"},
},
},
operation="multiply",
),
SimpleTool(
name="dynamic_add",
description="Add two numbers with offset",
parameters={
"type": "object",
"properties": {
"a": {"type": "integer"},
"b": {"type": "integer"},
},
},
operation="add",
value=100,
),
]
async def test_list_tools_includes_dynamic_tools(
self, base_server: FastMCP, dynamic_tools: list[Tool]
):
"""Test that list_tools returns both static and dynamic tools."""
provider = SimpleToolProvider(tools=dynamic_tools)
base_server.add_provider(provider)
async with Client(base_server) as client:
tools: list[MCPTool] = await client.list_tools()
# Should have all tools: 2 static + 2 dynamic
assert len(tools) == 4
tool_names = [tool.name for tool in tools]
assert "static_add" in tool_names
assert "static_subtract" in tool_names
assert "dynamic_multiply" in tool_names
assert "dynamic_add" in tool_names
async def test_list_tools_calls_provider_each_time(
self, base_server: FastMCP, dynamic_tools: list[Tool]
):
"""Test that provider.list_tools() is called on every list_tools request."""
provider = SimpleToolProvider(tools=dynamic_tools)
base_server.add_provider(provider)
async with Client(base_server) as client:
# Call list_tools multiple times
await client.list_tools()
await client.list_tools()
await client.list_tools()
# Provider should have been called 3 times
assert provider.list_tools_call_count == 3
async def test_call_dynamic_tool(
self, base_server: FastMCP, dynamic_tools: list[Tool]
):
"""Test that dynamic tools can be called successfully."""
provider = SimpleToolProvider(tools=dynamic_tools)
base_server.add_provider(provider)
async with Client(base_server) as client:
result: CallToolResult = await client.call_tool(
name="dynamic_multiply", arguments={"a": 7, "b": 6}
)
assert result.structured_content is not None
assert result.structured_content["result"] == 42 # type: ignore[attr-defined]
assert result.structured_content["operation"] == "multiply" # type: ignore[attr-defined]
async def test_call_dynamic_tool_with_config(
self, base_server: FastMCP, dynamic_tools: list[Tool]
):
"""Test that dynamic tool config (like value offset) is used."""
provider = SimpleToolProvider(tools=dynamic_tools)
base_server.add_provider(provider)
async with Client(base_server) as client:
result: CallToolResult = await client.call_tool(
name="dynamic_add", arguments={"a": 5, "b": 3}
)
assert result.structured_content is not None
# 5 + 3 + 100 (value offset) = 108
assert result.structured_content["result"] == 108 # type: ignore[attr-defined]
async def test_call_static_tool_still_works(
self, base_server: FastMCP, dynamic_tools: list[Tool]
):
"""Test that static tools still work after adding dynamic tools."""
provider = SimpleToolProvider(tools=dynamic_tools)
base_server.add_provider(provider)
async with Client(base_server) as client:
result: CallToolResult = await client.call_tool(
name="static_add", arguments={"a": 10, "b": 5}
)
assert result.structured_content is not None
assert result.structured_content["result"] == 15 # type: ignore[attr-defined]
async def test_call_tool_uses_get_tool_for_efficient_lookup(
self, base_server: FastMCP, dynamic_tools: list[Tool]
):
"""Test that call_tool uses get_tool() for efficient single-tool lookup."""
provider = SimpleToolProvider(tools=dynamic_tools)
base_server.add_provider(provider)
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
# Key point: list_tools is NOT called during tool execution (efficient lookup)
assert provider.get_tool_call_count == 2
async def test_default_get_tool_falls_back_to_list(self, base_server: FastMCP):
"""Test that BaseToolProvider's default get_tool calls list_tools."""
tools = [
SimpleTool(
name="test_tool",
description="A test tool",
parameters={"type": "object", "properties": {}},
operation="add",
),
]
provider = ListOnlyProvider(tools=tools)
base_server.add_provider(provider)
async with Client(base_server) as client:
result = await client.call_tool(
name="test_tool", arguments={"a": 1, "b": 2}
)
assert result.structured_content is not None
# Default get_tool should have called list_tools
assert provider.list_tools_call_count >= 1
async def test_dynamic_tools_come_first(
self, base_server: FastMCP, dynamic_tools: list[Tool]
):
"""Test that dynamic tools appear before static tools in list."""
provider = SimpleToolProvider(tools=dynamic_tools)
base_server.add_provider(provider)
async with Client(base_server) as client:
tools: list[MCPTool] = await client.list_tools()
tool_names = [tool.name for tool in tools]
# Dynamic tools should come first
assert tool_names[:2] == ["dynamic_multiply", "dynamic_add"]
async def test_empty_provider(self, base_server: FastMCP):
"""Test that empty provider doesn't affect behavior."""
provider = SimpleToolProvider(tools=[])
base_server.add_provider(provider)
async with Client(base_server) as client:
tools: list[MCPTool] = await client.list_tools()
# Should only have static tools
assert len(tools) == 2
async def test_tool_not_found_falls_through_to_static(
self, base_server: FastMCP, dynamic_tools: list[Tool]
):
"""Test that unknown tool name falls through to static tools."""
provider = SimpleToolProvider(tools=dynamic_tools)
base_server.add_provider(provider)
async with Client(base_server) as client:
# This tool is static, not in the dynamic provider
result: CallToolResult = await client.call_tool(
name="static_subtract", arguments={"a": 10, "b": 3}
)
assert result.structured_content is not None
assert result.structured_content["result"] == 7 # type: ignore[attr-defined]
class TestProviderClass:
"""Tests for the Provider class."""
async def test_subclass_is_instance(self):
"""Test that subclasses are instances of Provider."""
provider = SimpleToolProvider(tools=[])
assert isinstance(provider, Provider)
async def test_default_get_tool_works(self):
"""Test that the default get_tool implementation works."""
tool = SimpleTool(
name="test",
description="Test",
parameters={"type": "object", "properties": {}},
operation="add",
)
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")
assert found is not None
assert found.name == "test"
# Should return None for unknown names
not_found = await provider.get_tool(ctx, "unknown")
assert not_found is None
class TestDynamicToolUpdates:
"""Tests demonstrating dynamic tool updates without restart."""
async def test_tools_update_without_restart(self):
"""Test that tools can be updated dynamically."""
mcp = FastMCP("DynamicServer")
# Start with one tool
initial_tools = [
SimpleTool(
name="tool_v1",
description="Version 1",
parameters={"type": "object", "properties": {}},
operation="add",
),
]
provider = SimpleToolProvider(tools=initial_tools)
mcp.add_provider(provider)
async with Client(mcp) as client:
tools = await client.list_tools()
assert len(tools) == 1
assert tools[0].name == "tool_v1"
# Update the provider's tools (simulating DB update)
provider._tools = [
SimpleTool(
name="tool_v2",
description="Version 2",
parameters={"type": "object", "properties": {}},
operation="multiply",
),
SimpleTool(
name="tool_v3",
description="Version 3",
parameters={"type": "object", "properties": {}},
operation="add",
),
]
# List tools again - should see new tools
tools = await client.list_tools()
assert len(tools) == 2
tool_names = [t.name for t in tools]
assert "tool_v1" not in tool_names
assert "tool_v2" in tool_names
assert "tool_v3" in tool_names
class TestProviderExecutionMethods:
"""Tests for Provider execution methods (call_tool, read_resource, render_prompt)."""
async def test_call_tool_default_implementation(self):
"""Test that default call_tool uses get_tool and runs the tool."""
tool = SimpleTool(
name="test_tool",
description="Test",
parameters={"type": "object", "properties": {"a": {}, "b": {}}},
operation="add",
)
provider = SimpleToolProvider(tools=[tool])
mcp = FastMCP("TestServer")
mcp.add_provider(provider)
async with Client(mcp) as client:
result = await client.call_tool("test_tool", {"a": 1, "b": 2})
assert result.structured_content is not None
assert result.structured_content["result"] == 3 # type: ignore[attr-defined]
async def test_call_tool_custom_implementation(self):
"""Test that providers can override call_tool for custom behavior."""
class CustomCallProvider(Provider):
"""Provider that wraps tool execution with custom logic."""
def __init__(self):
self.call_count = 0
self._tool = SimpleTool(
name="custom_tool",
description="Test",
parameters={"type": "object", "properties": {"a": {}, "b": {}}},
operation="add",
)
async def list_tools(self, context: Context) -> Sequence[Tool]:
return [self._tool]
async def get_tool(self, context: Context, 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]
) -> ToolResult | None:
# Custom behavior: track calls and modify result
self.call_count += 1
tool = await self.get_tool(context, name)
if tool is None:
return None
result = await tool.run(arguments)
# Add custom metadata to result
result.structured_content["custom_wrapper"] = True # type: ignore[index]
return result
provider = CustomCallProvider()
mcp = FastMCP("TestServer")
mcp.add_provider(provider)
async with Client(mcp) as client:
result = await client.call_tool("custom_tool", {"a": 5, "b": 3})
assert provider.call_count == 1
assert result.structured_content is not None
assert result.structured_content["result"] == 8 # type: ignore[attr-defined]
assert result.structured_content["custom_wrapper"] is True # type: ignore[attr-defined]
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]:
return [
FunctionResource(
uri=AnyUrl("test://data"),
name="Test Data",
fn=lambda: "hello world",
)
]
provider = ResourceProvider()
mcp = FastMCP("TestServer")
mcp.add_provider(provider)
async with Client(mcp) as client:
result = await client.read_resource("test://data")
assert len(result) == 1
assert result[0].text == "hello world"
async def test_read_resource_custom_implementation(self):
"""Test that providers can override read_resource for custom behavior."""
class CustomReadProvider(Provider):
"""Provider that transforms resource content."""
async def list_resources(self, context: Context) -> Sequence[Resource]:
return [
FunctionResource(
uri=AnyUrl("test://data"),
name="Test Data",
fn=lambda: "original",
)
]
async def read_resource(
self, context: Context, uri: str
) -> ResourceContent | None:
if uri == "test://data":
# Custom behavior: return transformed content
return ResourceContent(content="TRANSFORMED")
return None
provider = CustomReadProvider()
mcp = FastMCP("TestServer")
mcp.add_provider(provider)
async with Client(mcp) as client:
result = await client.read_resource("test://data")
assert len(result) == 1
assert result[0].text == "TRANSFORMED"
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]:
return [
FunctionResourceTemplate.from_function(
fn=lambda name: f"content of {name}",
uri_template="data://files/{name}",
name="Data Template",
)
]
provider = TemplateProvider()
mcp = FastMCP("TestServer")
mcp.add_provider(provider)
async with Client(mcp) as client:
result = await client.read_resource("data://files/test.txt")
assert len(result) == 1
assert result[0].text == "content of test.txt"
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]:
return [
FunctionPrompt.from_function(
fn=lambda name: f"Hello, {name}!",
name="greeting",
description="Greet someone",
)
]
provider = PromptProvider()
mcp = FastMCP("TestServer")
mcp.add_provider(provider)
async with Client(mcp) as client:
result = await client.get_prompt("greeting", {"name": "World"})
assert len(result.messages) == 1
assert result.messages[0].content.text == "Hello, World!" # type: ignore[attr-defined]
async def test_render_prompt_custom_implementation(self):
"""Test that providers can override render_prompt for custom behavior."""
class CustomRenderProvider(Provider):
"""Provider that adds prefix to all prompts."""
async def list_prompts(self, context: Context) -> Sequence[Prompt]:
return [
FunctionPrompt.from_function(
fn=lambda: "original message",
name="test_prompt",
description="Test",
)
]
async def render_prompt(
self, context: Context, name: str, arguments: dict[str, Any] | None
) -> PromptResult | None:
if name == "test_prompt":
# Custom behavior: add prefix
return PromptResult(
messages=[
PromptMessage(
role="user",
content=TextContent(
type="text",
text="[CUSTOM PREFIX] original message",
),
)
]
)
return None
provider = CustomRenderProvider()
mcp = FastMCP("TestServer")
mcp.add_provider(provider)
async with Client(mcp) as client:
result = await client.get_prompt("test_prompt", {})
assert len(result.messages) == 1
assert "[CUSTOM PREFIX]" in result.messages[0].content.text # type: ignore[attr-defined]