mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 14:04:18 +02:00
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:
parent
8abd84bf1e
commit
0e5a8fed6c
12 changed files with 1398 additions and 63 deletions
|
|
@ -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
|
||||
|
|
|
|||
59
examples/providers/sqlite/README.md
Normal file
59
examples/providers/sqlite/README.md
Normal 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.
|
||||
137
examples/providers/sqlite/server.py
Normal file
137
examples/providers/sqlite/server.py
Normal 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())
|
||||
102
examples/providers/sqlite/setup_db.py
Normal file
102
examples/providers/sqlite/setup_db.py
Normal 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())
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
222
src/fastmcp/providers.py
Normal 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)
|
||||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from .middleware import (
|
||||
CallNext,
|
||||
Middleware,
|
||||
MiddlewareContext,
|
||||
CallNext,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
578
tests/server/test_providers.py
Normal file
578
tests/server/test_providers.py
Normal 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]
|
||||
Loading…
Add table
Add a link
Reference in a new issue