From 3fcb01f8db9e71720dc01dad3a59d8d9f37e23bc Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:46:21 -0500 Subject: [PATCH 1/3] 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()]) ``` --- AGENTS.md | 8 + examples/providers/sqlite/README.md | 59 ++++ examples/providers/sqlite/server.py | 137 +++++++++ examples/providers/sqlite/setup_db.py | 102 +++++++ src/fastmcp/__init__.py | 2 + src/fastmcp/prompts/prompt_manager.py | 11 +- src/fastmcp/providers.py | 117 +++++++ src/fastmcp/resources/resource_manager.py | 47 +-- src/fastmcp/server/middleware/__init__.py | 2 +- src/fastmcp/server/server.py | 205 ++++++++++++- src/fastmcp/tools/tool_manager.py | 20 +- tests/server/test_providers.py | 353 ++++++++++++++++++++++ 12 files changed, 1001 insertions(+), 62 deletions(-) create mode 100644 examples/providers/sqlite/README.md create mode 100644 examples/providers/sqlite/server.py create mode 100644 examples/providers/sqlite/setup_db.py create mode 100644 src/fastmcp/providers.py create mode 100644 tests/server/test_providers.py diff --git a/AGENTS.md b/AGENTS.md index 93fc9b4b83..ac67314a8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/examples/providers/sqlite/README.md b/examples/providers/sqlite/README.md new file mode 100644 index 0000000000..86daad327f --- /dev/null +++ b/examples/providers/sqlite/README.md @@ -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. diff --git a/examples/providers/sqlite/server.py b/examples/providers/sqlite/server.py new file mode 100644 index 0000000000..d8d7f99b29 --- /dev/null +++ b/examples/providers/sqlite/server.py @@ -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()) diff --git a/examples/providers/sqlite/setup_db.py b/examples/providers/sqlite/setup_db.py new file mode 100644 index 0000000000..ac73fc257f --- /dev/null +++ b/examples/providers/sqlite/setup_db.py @@ -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()) diff --git a/src/fastmcp/__init__.py b/src/fastmcp/__init__.py index 14c0abc5b4..50dac02e09 100644 --- a/src/fastmcp/__init__.py +++ b/src/fastmcp/__init__.py @@ -14,6 +14,7 @@ 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 @@ "Client", "Context", "FastMCP", + "Provider", "settings", ] diff --git a/src/fastmcp/prompts/prompt_manager.py b/src/fastmcp/prompts/prompt_manager.py index 971eb52641..6dd57a8a92 100644 --- a/src/fastmcp/prompts/prompt_manager.py +++ b/src/fastmcp/prompts/prompt_manager.py @@ -107,18 +107,17 @@ async def render_prompt( 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 diff --git a/src/fastmcp/providers.py b/src/fastmcp/providers.py new file mode 100644 index 0000000000..04d9d3f8a1 --- /dev/null +++ b/src/fastmcp/providers.py @@ -0,0 +1,117 @@ +"""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 + +from fastmcp.prompts.prompt import Prompt +from fastmcp.resources.resource import Resource +from fastmcp.tools.tool import Tool + +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) + - Raise an exception for actual errors (propagates to caller) + - Static components (registered via decorators) always take precedence over providers + - Providers are queried in registration order; first non-None wins + """ + + 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 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_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) diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py index ebc44ca8e9..290fe8484f 100644 --- a/src/fastmcp/resources/resource_manager.py +++ b/src/fastmcp/resources/resource_manager.py @@ -288,60 +288,43 @@ async def get_resource(self, uri: AnyUrl | str) -> Resource: 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.") diff --git a/src/fastmcp/server/middleware/__init__.py b/src/fastmcp/server/middleware/__init__.py index 1e2035b21d..d53d5f05d5 100644 --- a/src/fastmcp/server/middleware/__init__.py +++ b/src/fastmcp/server/middleware/__init__.py @@ -1,7 +1,7 @@ from .middleware import ( + CallNext, Middleware, MiddlewareContext, - CallNext, ) __all__ = [ diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index bc8eee40dc..0d258898b4 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -50,6 +50,7 @@ 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 @@ 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 @@ def __init__( 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 @@ def __init__( 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 @@ def __init__( 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 @@ async def _apply_middleware( 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 @@ async def _list_tools( 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 @@ async def _list_resources( 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]: """ @@ -1491,7 +1554,24 @@ async def _list_prompts( 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 +1729,81 @@ async def _call_tool( 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._run_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): + return await self._run_tool( + tool, tool_name, context.message.arguments or {} + ) + except Exception: + logger.exception(f"Error getting tool '{tool_name}' from provider") + raise + raise NotFoundError(f"Unknown tool: {tool_name!r}") + async def _run_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_content( + 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 _render_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 + async def _read_resource_mcp(self, uri: AnyUrl | str) -> list[ResourceContent]: """ Handle MCP 'readResource' requests. @@ -1740,8 +1883,7 @@ async def _read_resource( 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._read_resource_content(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,6 +1891,31 @@ async def _read_resource( 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._read_resource_content(resource, uri_str) + return [content] + + # Try component providers + 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 self._read_resource_content(resource, uri_str) + return [content] + except Exception: + logger.exception( + f"Error getting resource '{uri_str}' from provider" + ) + raise + raise NotFoundError(f"Unknown resource: {uri_str!r}") async def _get_prompt_mcp( @@ -1832,16 +1999,30 @@ async def _get_prompt( 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._render_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): + return await self._render_prompt( + prompt, name, context.message.arguments + ) + except Exception: + logger.exception(f"Error getting prompt '{name}' from provider") + raise + raise NotFoundError(f"Unknown prompt: {name!r}") def add_tool(self, tool: Tool) -> Tool: diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py index 9c6ee88604..18bb227226 100644 --- a/src/fastmcp/tools/tool_manager.py +++ b/src/fastmcp/tools/tool_manager.py @@ -152,21 +152,19 @@ def remove_tool(self, key: str) -> None: 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 diff --git a/tests/server/test_providers.py b/tests/server/test_providers.py new file mode 100644 index 0000000000..384a3ff95e --- /dev/null +++ b/tests/server/test_providers.py @@ -0,0 +1,353 @@ +"""Tests for providers.""" + +from typing import Any + +import pytest +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.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 should have been called (not list_tools) + assert provider.get_tool_call_count == 1 + + 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 From 88c5450adbfc8c87e4349b30969b806ab42ac6eb Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 15 Dec 2025 19:42:37 -0500 Subject: [PATCH 2/3] 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 --- src/fastmcp/providers.py | 107 ++++++++++++++- src/fastmcp/server/server.py | 126 +++++++++++------- tests/server/test_providers.py | 229 ++++++++++++++++++++++++++++++++- 3 files changed, 410 insertions(+), 52 deletions(-) diff --git a/src/fastmcp/providers.py b/src/fastmcp/providers.py index 04d9d3f8a1..99d9d9ef83 100644 --- a/src/fastmcp/providers.py +++ b/src/fastmcp/providers.py @@ -28,11 +28,12 @@ async def get_tool(self, context: Context, name: str) -> Tool | None: from __future__ import annotations from collections.abc import Sequence -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any -from fastmcp.prompts.prompt import Prompt -from fastmcp.resources.resource import Resource -from fastmcp.tools.tool import Tool +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 @@ -78,6 +79,22 @@ async def get_tool(self, context: Context, name: str) -> Tool | None: 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. @@ -97,6 +114,72 @@ async def get_resource(self, context: Context, uri: str) -> Resource | None: 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. @@ -115,3 +198,19 @@ async def get_prompt(self, context: Context, name: str) -> Prompt | None: """ 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) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 0d258898b4..73261668f3 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1461,7 +1461,21 @@ async def _list_resource_templates( 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]: """ @@ -1733,7 +1747,7 @@ async def _call_tool( try: tool = await self._tool_manager.get_tool(tool_name) if self._should_enable_component(tool): - return await self._run_tool( + return await self._execute_tool( tool, tool_name, context.message.arguments or {} ) except NotFoundError: @@ -1746,16 +1760,18 @@ async def _call_tool( try: tool = await provider.get_tool(ctx, tool_name) if tool is not None and self._should_enable_component(tool): - return await self._run_tool( - tool, tool_name, context.message.arguments or {} + result = await provider.call_tool( + ctx, tool_name, context.message.arguments or {} ) + if result is not None: + return result except Exception: - logger.exception(f"Error getting tool '{tool_name}' from provider") + logger.exception(f"Error calling tool '{tool_name}' from provider") raise raise NotFoundError(f"Unknown tool: {tool_name!r}") - async def _run_tool( + async def _execute_tool( self, tool: Tool, tool_name: str, arguments: dict[str, Any] ) -> ToolResult: """Run a tool with unified error handling.""" @@ -1774,36 +1790,6 @@ async def _run_tool( 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_content( - 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 _render_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 - async def _read_resource_mcp(self, uri: AnyUrl | str) -> list[ResourceContent]: """ Handle MCP 'readResource' requests. @@ -1883,7 +1869,7 @@ async def _read_resource( try: resource = await self._resource_manager.get_resource(uri_str) if self._should_enable_component(resource): - content = await self._read_resource_content(resource, uri_str) + 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 @@ -1898,26 +1884,57 @@ async def _read_resource( if params is not None: if self._should_enable_component(template): resource = await template.create_resource(uri_str, params) - content = await self._read_resource_content(resource, uri_str) + content = await self._execute_resource(resource, uri_str) return [content] - # Try component providers + # 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 self._read_resource_content(resource, uri_str) - return [content] + content = await provider.read_resource(ctx, uri_str) + if content is not None: + return [content] except Exception: logger.exception( - f"Error getting resource '{uri_str}' from provider" + f"Error reading resource '{uri_str}' from provider" + ) + raise + + # 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 Exception: + logger.exception( + f"Error reading resource '{uri_str}' from provider template" ) raise 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: @@ -2003,7 +2020,7 @@ async def _get_prompt( try: prompt = await self._prompt_manager.get_prompt(name) if self._should_enable_component(prompt): - return await self._render_prompt( + return await self._execute_prompt( prompt, name, context.message.arguments ) except NotFoundError: @@ -2016,15 +2033,32 @@ async def _get_prompt( try: prompt = await provider.get_prompt(ctx, name) if prompt is not None and self._should_enable_component(prompt): - return await self._render_prompt( - prompt, name, context.message.arguments + result = await provider.render_prompt( + ctx, name, context.message.arguments ) + if result is not None: + return result except Exception: - logger.exception(f"Error getting prompt '{name}' from provider") + logger.exception(f"Error rendering prompt '{name}' from provider") raise 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. diff --git a/tests/server/test_providers.py b/tests/server/test_providers.py index 384a3ff95e..e5489cbb5d 100644 --- a/tests/server/test_providers.py +++ b/tests/server/test_providers.py @@ -1,13 +1,18 @@ """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 @@ -205,8 +210,11 @@ async def test_call_tool_uses_get_tool_for_efficient_lookup( async with Client(base_server) as client: await client.call_tool(name="dynamic_multiply", arguments={"a": 2, "b": 3}) - # get_tool should have been called (not list_tools) - assert provider.get_tool_call_count == 1 + # 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.""" @@ -351,3 +359,220 @@ async def test_tools_update_without_restart(self): 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] From 28a43076e2a148b295b02d716fe5f894ba27ad29 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:49:09 -0500 Subject: [PATCH 3/3] 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 --- src/fastmcp/providers.py | 8 +++++- src/fastmcp/server/server.py | 52 +++++++++++++++++++++++++++++------- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/src/fastmcp/providers.py b/src/fastmcp/providers.py index 99d9d9ef83..e7246b444f 100644 --- a/src/fastmcp/providers.py +++ b/src/fastmcp/providers.py @@ -55,9 +55,15 @@ class Provider: Provider semantics: - Return `None` from `get_*` methods to indicate "I don't have it" (search continues) - - Raise an exception for actual errors (propagates to caller) - 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]: diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 73261668f3..4a252e7dbf 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1765,9 +1765,18 @@ async def _call_tool( ) if result is not None: return result - except Exception: - logger.exception(f"Error calling tool '{tool_name}' from provider") + 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}") @@ -1897,11 +1906,20 @@ async def _read_resource( content = await provider.read_resource(ctx, uri_str) if content is not None: return [content] - except Exception: + except ResourceError: + logger.exception(f"Error reading resource {uri_str!r}") + raise + except Exception as e: logger.exception( - f"Error reading resource '{uri_str}' from provider" + f"Error reading resource {uri_str!r} from provider" ) - raise + 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: @@ -1912,11 +1930,20 @@ async def _read_resource( content = await provider.read_resource_template(ctx, uri_str) if content is not None: return [content] - except Exception: + except ResourceError: + logger.exception(f"Error reading resource {uri_str!r}") + raise + except Exception as e: logger.exception( - f"Error reading resource '{uri_str}' from provider template" + f"Error reading resource {uri_str!r} from provider template" ) - raise + 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}") @@ -2038,9 +2065,14 @@ async def _get_prompt( ) if result is not None: return result - except Exception: - logger.exception(f"Error rendering prompt '{name}' from provider") + 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}")