Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions examples/providers/sqlite/README.md
Original file line number Diff line number Diff line change
@@ -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
```
Comment thread
jlowin marked this conversation as resolved.

## 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'"
```
Comment thread
jlowin marked this conversation as resolved.

The next `list_tools` or `call_tool` request will reflect these changes.
137 changes: 137 additions & 0 deletions examples/providers/sqlite/server.py
Original file line number Diff line number Diff line change
@@ -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 changes: 102 additions & 0 deletions examples/providers/sqlite/setup_db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# /// script
# dependencies = ["aiosqlite"]
# ///
"""
Creates and seeds the tools database.

Run with: uv run examples/dynamic_tools_sqlite/setup_db.py
"""
Comment thread
jlowin marked this conversation as resolved.

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())
2 changes: 2 additions & 0 deletions src/fastmcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,5 +32,6 @@
"Client",
"Context",
"FastMCP",
"Provider",
"settings",
]
11 changes: 5 additions & 6 deletions src/fastmcp/prompts/prompt_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading