fastmcp/examples/providers/sqlite/server.py
Jeremiah Lowin ede8ff6703
Convert mounted servers to MountedProvider (#2635)
* Simplify Provider interface and consolidate docket registration

- Remove get_http_routes from Provider (unused)
- Remove ProviderLifespanConfig, _base_lifespan, _register_tasks
- Remove supports_tasks flag from Provider.__init__
- Consolidate all docket registration in server._docket_lifespan()
- Simplify lifespan() to take no parameters
- Move MountedProvider to separate module

* Fix control flow in ComponentService resource methods

* Move providers to server/providers

* Ensure MountedProvider get_* methods go through middleware

* Fix get_resource to only return concrete resources

Reverts template-checking in get_resource that broke task execution.
Tasks need access to the original template, not instantiated resources.

* Move prefix utilities into mounted.py, deprecate import_server

- Add resource prefix functions (add/remove/has_resource_prefix) to mounted.py
- Deprecate import_server with warning to use mount() instead
- Add tool_names uniqueness validation in MountedProvider

* Fix provider iteration order and remove dead _is_mounted flag

- Remove unused _is_mounted flag (MountedProvider.lifespan() calls _lifespan
  not _lifespan_manager, so the flag was never checked)
- Fix provider iteration: change reversed() to forward order in execution
  methods (_call_tool, _read_resource_middleware, _get_prompt_content_middleware)
  to match documented "first non-None wins" semantics
- Fix ComponentService to handle prefix-less mounted servers using
  _strip_tool_prefix()/_strip_resource_prefix() methods
- Update conflict resolution tests to expect first-registered provider wins
- Add regression tests for Docket behavior and prefix-less ComponentService

* Add TaskComponents type and exception handling for provider task registration

- Create TaskComponents dataclass with FunctionTool/FunctionResource/etc. types
  for proper typing of get_tasks() return value
- Add try/except wrapper around provider.get_tasks() in _docket_lifespan for
  consistent error handling (warn + continue or raise based on settings)
- Remove type: ignore comments from server.py task registration loop
2025-12-17 22:21:51 -05:00

138 lines
4.2 KiB
Python

# /// 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
from fastmcp.server.context import Context
from fastmcp.server.providers import Provider
from fastmcp.tools.tool import Tool, ToolResult
DB_PATH = Path(__file__).parent / "tools.db"
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())