├── .gitattributes
├── .gitignore
├── README.md
├── fastmcp-documentation.txt
├── mcp-documentation.txt
├── requirements.txt
└── sqlite_explorer.py
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | __pycache__/
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://mseep.ai/app/hannesrudolph-sqlite-explorer-fastmcp-mcp-server)
2 |
3 | # SQLite Explorer MCP Server
4 |
5 | An MCP server that provides safe, read-only access to SQLite databases through Model Context Protocol (MCP). This server is built with the FastMCP framework, which enables LLMs to explore and query SQLite databases with built-in safety features and query validation.
6 |
7 | ## 📋 System Requirements
8 |
9 | - Python 3.6+
10 | - SQLite database file (path specified via environment variable)
11 |
12 | ## 📦 Dependencies
13 |
14 | Install all required dependencies:
15 |
16 | ```bash
17 | # Using pip
18 | pip install -r requirements.txt
19 | ```
20 |
21 | ### Required Packages
22 | - **fastmcp**: Framework for building Model Context Protocol servers
23 |
24 | All dependencies are specified in `requirements.txt` for easy installation.
25 |
26 | ## 📑 Table of Contents
27 | - [System Requirements](#-system-requirements)
28 | - [Dependencies](#-dependencies)
29 | - [MCP Tools](#%EF%B8%8F-mcp-tools)
30 | - [Getting Started](#-getting-started)
31 | - [Installation Options](#-installation-options)
32 | - [Claude Desktop](#option-1-install-for-claude-desktop)
33 | - [Cline VSCode Plugin](#option-2-install-for-cline-vscode-plugin)
34 | - [Safety Features](#-safety-features)
35 | - [Development Documentation](#-development-documentation)
36 | - [Environment Variables](#%EF%B8%8F-environment-variables)
37 |
38 | ## 🛠️ MCP Tools
39 |
40 | The server exposes the following tools to LLMs:
41 |
42 | ### read_query
43 | Execute a SELECT query on the database with built-in safety validations. Features:
44 | - Query validation and sanitization
45 | - Parameter binding support
46 | - Row limit enforcement
47 | - Results formatted as dictionaries
48 |
49 | ### list_tables
50 | List all available tables in the database with their names.
51 |
52 | ### describe_table
53 | Get detailed schema information for a specific table, including:
54 | - Column names and types
55 | - NULL constraints
56 | - Default values
57 | - Primary key information
58 |
59 | ## 🚀 Getting Started
60 |
61 | Clone the repository:
62 |
63 | ```bash
64 | git clone https://github.com/hannesrudolph/sqlite-explorer-fastmcp-mcp-server.git
65 | cd sqlite-explorer-fastmcp-mcp-server
66 | ```
67 |
68 | ## 📦 Installation Options
69 |
70 | You can install this MCP server in either Claude Desktop or the Cline VSCode plugin. Choose the option that best suits your needs.
71 |
72 | ### Option 1: Install for Claude Desktop
73 |
74 | Install using FastMCP:
75 |
76 | ```bash
77 | fastmcp install sqlite_explorer.py --name "SQLite Explorer" -e SQLITE_DB_PATH=/path/to/db
78 | ```
79 |
80 | Replace `/path/to/db` with the path to your SQLite database file.
81 |
82 | ### Option 2: Install for Cline VSCode Plugin
83 |
84 | To use this server with the [Cline VSCode plugin](http://cline.bot):
85 |
86 | 1. In VSCode, click the server icon (☰) in the Cline plugin sidebar
87 | 2. Click the "Edit MCP Settings" button (✎)
88 | 3. Add the following configuration to the settings file:
89 |
90 | ```json
91 | {
92 | "sqlite-explorer": {
93 | "command": "uv",
94 | "args": [
95 | "run",
96 | "--with",
97 | "fastmcp",
98 | "--with",
99 | "uvicorn",
100 | "fastmcp",
101 | "run",
102 | "/path/to/repo/sqlite_explorer.py"
103 | ],
104 | "env": {
105 | "SQLITE_DB_PATH": "/path/to/your/database.db"
106 | }
107 | }
108 | }
109 | ```
110 |
111 | Replace:
112 | - `/path/to/repo` with the full path to where you cloned this repository (e.g., `/Users/username/Projects/sqlite-explorer-fastmcp-mcp-server`)
113 | - `/path/to/your/database.db` with the full path to your SQLite database file
114 |
115 | ## 🔒 Safety Features
116 |
117 | - Read-only access to SQLite databases
118 | - Query validation and sanitization
119 | - Parameter binding for safe query execution
120 | - Row limit enforcement
121 | - Progress output suppression for clean JSON responses
122 |
123 | ## 📚 Development Documentation
124 |
125 | The repository includes documentation files for development:
126 |
127 | - `mcp-documentation.txt`: Contains comprehensive documentation about the MCP server implementation and FastMCP framework usage.
128 |
129 | This documentation serves as context when developing features and can be used with LLMs to assist in development.
130 |
131 | ## ⚙️ Environment Variables
132 |
133 | The following environment variables must be set:
134 |
135 | - `SQLITE_DB_PATH`: Full path to the SQLite database file you want to explore
136 |
--------------------------------------------------------------------------------
/fastmcp-documentation.txt:
--------------------------------------------------------------------------------
1 | Directory structure:
2 | └── jlowin-fastmcp
3 | ├── pyproject.toml
4 | ├── docs
5 | │ └── assets
6 | ├── README.md
7 | ├── examples
8 | │ ├── simple_echo.py
9 | │ ├── memory.py
10 | │ ├── readme-quickstart.py
11 | │ ├── text_me.py
12 | │ ├── screenshot.py
13 | │ ├── echo.py
14 | │ ├── desktop.py
15 | │ └── complex_inputs.py
16 | ├── Windows_Notes.md
17 | └── src
18 | └── fastmcp
19 | ├── server.py
20 | ├── tools
21 | │ ├── tool_manager.py
22 | │ ├── __init__.py
23 | │ └── base.py
24 | ├── resources
25 | │ ├── resource_manager.py
26 | │ ├── __init__.py
27 | │ ├── types.py
28 | │ ├── templates.py
29 | │ └── base.py
30 | ├── __init__.py
31 | ├── cli
32 | │ ├── claude.py
33 | │ ├── __init__.py
34 | │ └── cli.py
35 | ├── utilities
36 | │ ├── logging.py
37 | │ ├── func_metadata.py
38 | │ ├── __init__.py
39 | │ └── types.py
40 | ├── prompts
41 | │ ├── prompt_manager.py
42 | │ ├── __init__.py
43 | │ ├── manager.py
44 | │ └── base.py
45 | ├── py.typed
46 | └── exceptions.py
47 |
48 | ================================================
49 | File: /pyproject.toml
50 | ================================================
51 | [project]
52 | name = "fastmcp"
53 | dynamic = ["version"]
54 | description = "A more ergonomic interface for MCP servers"
55 | authors = [{ name = "Jeremiah Lowin" }]
56 | dependencies = [
57 | "httpx>=0.26.0",
58 | "mcp>=1.0.0,<2.0.0",
59 | "pydantic-settings>=2.6.1",
60 | "pydantic>=2.5.3,<3.0.0",
61 | "typer>=0.9.0",
62 | "python-dotenv>=1.0.1",
63 | ]
64 | requires-python = ">=3.10"
65 | readme = "README.md"
66 | license = { text = "MIT" }
67 |
68 | [project.scripts]
69 | fastmcp = "fastmcp.cli:app"
70 |
71 | [build-system]
72 | requires = ["hatchling>=1.21.0", "hatch-vcs>=0.4.0"]
73 | build-backend = "hatchling.build"
74 |
75 | [project.optional-dependencies]
76 | tests = [
77 | "pre-commit",
78 | "pyright>=1.1.389",
79 | "pytest>=8.3.3",
80 | "pytest-asyncio>=0.23.5",
81 | "pytest-flakefinder",
82 | "pytest-xdist>=3.6.1",
83 | "ruff",
84 | ]
85 | dev = ["fastmcp[tests]", "copychat>=0.5.2", "ipython>=8.12.3", "pdbpp>=0.10.3"]
86 |
87 | [tool.pytest.ini_options]
88 | asyncio_mode = "auto"
89 | asyncio_default_fixture_loop_scope = "session"
90 |
91 | [tool.hatch.version]
92 | source = "vcs"
93 |
94 | [tool.pyright]
95 | include = ["src", "tests"]
96 | exclude = ["**/node_modules", "**/__pycache__", ".venv", ".git", "dist"]
97 | pythonVersion = "3.10"
98 | pythonPlatform = "Darwin"
99 | typeCheckingMode = "basic"
100 | reportMissingImports = true
101 | reportMissingTypeStubs = false
102 | useLibraryCodeForTypes = true
103 | venvPath = "."
104 | venv = ".venv"
105 |
106 |
107 | ================================================
108 | File: /README.md
109 | ================================================
110 |
111 | # FastMCP 🚀
112 |
113 |
114 |
115 | The fast, Pythonic way to build MCP servers.
116 |
117 | [](https://pypi.org/project/fastmcp)
118 | [](https://github.com/jlowin/fastmcp/actions/workflows/run-tests.yml)
119 | [](https://github.com/jlowin/fastmcp/blob/main/LICENSE)
120 |
121 |
122 |
123 |
124 | [Model Context Protocol (MCP)](https://modelcontextprotocol.io) servers are a new, standardized way to provide context and tools to your LLMs, and FastMCP makes building MCP servers simple and intuitive. Create tools, expose resources, and define prompts with clean, Pythonic code:
125 |
126 | ```python
127 | # demo.py
128 |
129 | from fastmcp import FastMCP
130 |
131 |
132 | mcp = FastMCP("Demo 🚀")
133 |
134 |
135 | @mcp.tool()
136 | def add(a: int, b: int) -> int:
137 | """Add two numbers"""
138 | return a + b
139 | ```
140 |
141 | That's it! Give Claude access to the server by running:
142 |
143 | ```bash
144 | fastmcp install demo.py
145 | ```
146 |
147 | FastMCP handles all the complex protocol details and server management, so you can focus on building great tools. It's designed to be high-level and Pythonic - in most cases, decorating a function is all you need.
148 |
149 |
150 | ### Key features:
151 | * **Fast**: High-level interface means less code and faster development
152 | * **Simple**: Build MCP servers with minimal boilerplate
153 | * **Pythonic**: Feels natural to Python developers
154 | * **Complete***: FastMCP aims to provide a full implementation of the core MCP specification
155 |
156 | (\*emphasis on *aims*)
157 |
158 | 🚨 🚧 🏗️ *FastMCP is under active development, as is the MCP specification itself. Core features are working but some advanced capabilities are still in progress.*
159 |
160 |
161 |
162 | ## Table of Contents
163 |
164 | - [Installation](#installation)
165 | - [Quickstart](#quickstart)
166 | - [What is MCP?](#what-is-mcp)
167 | - [Core Concepts](#core-concepts)
168 | - [Server](#server)
169 | - [Resources](#resources)
170 | - [Tools](#tools)
171 | - [Prompts](#prompts)
172 | - [Images](#images)
173 | - [Context](#context)
174 | - [Running Your Server](#running-your-server)
175 | - [Development Mode (Recommended for Building \& Testing)](#development-mode-recommended-for-building--testing)
176 | - [Claude Desktop Integration (For Regular Use)](#claude-desktop-integration-for-regular-use)
177 | - [Direct Execution (For Advanced Use Cases)](#direct-execution-for-advanced-use-cases)
178 | - [Server Object Names](#server-object-names)
179 | - [Examples](#examples)
180 | - [Echo Server](#echo-server)
181 | - [SQLite Explorer](#sqlite-explorer)
182 | - [Contributing](#contributing)
183 | - [Prerequisites](#prerequisites)
184 | - [Installation](#installation-1)
185 | - [Testing](#testing)
186 | - [Formatting](#formatting)
187 | - [Opening a Pull Request](#opening-a-pull-request)
188 |
189 | ## Installation
190 |
191 | We strongly recommend installing FastMCP with [uv](https://docs.astral.sh/uv/), as it is required for deploying servers:
192 |
193 | ```bash
194 | uv pip install fastmcp
195 | ```
196 |
197 | Note: on macOS, uv may need to be installed with Homebrew (`brew install uv`) in order to make it available to the Claude Desktop app.
198 |
199 | Alternatively, to use the SDK without deploying, you may use pip:
200 |
201 | ```bash
202 | pip install fastmcp
203 | ```
204 |
205 | ## Quickstart
206 |
207 | Let's create a simple MCP server that exposes a calculator tool and some data:
208 |
209 | ```python
210 | # server.py
211 |
212 | from fastmcp import FastMCP
213 |
214 |
215 | # Create an MCP server
216 | mcp = FastMCP("Demo")
217 |
218 |
219 | # Add an addition tool
220 | @mcp.tool()
221 | def add(a: int, b: int) -> int:
222 | """Add two numbers"""
223 | return a + b
224 |
225 |
226 | # Add a dynamic greeting resource
227 | @mcp.resource("greeting://{name}")
228 | def get_greeting(name: str) -> str:
229 | """Get a personalized greeting"""
230 | return f"Hello, {name}!"
231 | ```
232 |
233 | You can install this server in [Claude Desktop](https://claude.ai/download) and interact with it right away by running:
234 | ```bash
235 | fastmcp install server.py
236 | ```
237 |
238 | Alternatively, you can test it with the MCP Inspector:
239 | ```bash
240 | fastmcp dev server.py
241 | ```
242 |
243 | 
244 |
245 | ## What is MCP?
246 |
247 | The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but specifically designed for LLM interactions. MCP servers can:
248 |
249 | - Expose data through **Resources** (think of these sort of like GET endpoints; they are used to load information into the LLM's context)
250 | - Provide functionality through **Tools** (sort of like POST endpoints; they are used to execute code or otherwise produce a side effect)
251 | - Define interaction patterns through **Prompts** (reusable templates for LLM interactions)
252 | - And more!
253 |
254 | There is a low-level [Python SDK](https://github.com/modelcontextprotocol/python-sdk) available for implementing the protocol directly, but FastMCP aims to make that easier by providing a high-level, Pythonic interface.
255 |
256 | ## Core Concepts
257 |
258 |
259 | ### Server
260 |
261 | The FastMCP server is your core interface to the MCP protocol. It handles connection management, protocol compliance, and message routing:
262 |
263 | ```python
264 | from fastmcp import FastMCP
265 |
266 | # Create a named server
267 | mcp = FastMCP("My App")
268 |
269 | # Specify dependencies for deployment and development
270 | mcp = FastMCP("My App", dependencies=["pandas", "numpy"])
271 | ```
272 |
273 | ### Resources
274 |
275 | Resources are how you expose data to LLMs. They're similar to GET endpoints in a REST API - they provide data but shouldn't perform significant computation or have side effects. Some examples:
276 |
277 | - File contents
278 | - Database schemas
279 | - API responses
280 | - System information
281 |
282 | Resources can be static:
283 | ```python
284 | @mcp.resource("config://app")
285 | def get_config() -> str:
286 | """Static configuration data"""
287 | return "App configuration here"
288 | ```
289 |
290 | Or dynamic with parameters (FastMCP automatically handles these as MCP templates):
291 | ```python
292 | @mcp.resource("users://{user_id}/profile")
293 | def get_user_profile(user_id: str) -> str:
294 | """Dynamic user data"""
295 | return f"Profile data for user {user_id}"
296 | ```
297 |
298 | ### Tools
299 |
300 | Tools let LLMs take actions through your server. Unlike resources, tools are expected to perform computation and have side effects. They're similar to POST endpoints in a REST API.
301 |
302 | Simple calculation example:
303 | ```python
304 | @mcp.tool()
305 | def calculate_bmi(weight_kg: float, height_m: float) -> float:
306 | """Calculate BMI given weight in kg and height in meters"""
307 | return weight_kg / (height_m ** 2)
308 | ```
309 |
310 | HTTP request example:
311 | ```python
312 | import httpx
313 |
314 | @mcp.tool()
315 | async def fetch_weather(city: str) -> str:
316 | """Fetch current weather for a city"""
317 | async with httpx.AsyncClient() as client:
318 | response = await client.get(
319 | f"https://api.weather.com/{city}"
320 | )
321 | return response.text
322 | ```
323 |
324 | Complex input handling example:
325 | ```python
326 | from pydantic import BaseModel, Field
327 | from typing import Annotated
328 |
329 | class ShrimpTank(BaseModel):
330 | class Shrimp(BaseModel):
331 | name: Annotated[str, Field(max_length=10)]
332 |
333 | shrimp: list[Shrimp]
334 |
335 | @mcp.tool()
336 | def name_shrimp(
337 | tank: ShrimpTank,
338 | # You can use pydantic Field in function signatures for validation.
339 | extra_names: Annotated[list[str], Field(max_length=10)],
340 | ) -> list[str]:
341 | """List all shrimp names in the tank"""
342 | return [shrimp.name for shrimp in tank.shrimp] + extra_names
343 | ```
344 |
345 | ### Prompts
346 |
347 | Prompts are reusable templates that help LLMs interact with your server effectively. They're like "best practices" encoded into your server. A prompt can be as simple as a string:
348 |
349 | ```python
350 | @mcp.prompt()
351 | def review_code(code: str) -> str:
352 | return f"Please review this code:\n\n{code}"
353 | ```
354 |
355 | Or a more structured sequence of messages:
356 | ```python
357 | from fastmcp.prompts.base import UserMessage, AssistantMessage
358 |
359 | @mcp.prompt()
360 | def debug_error(error: str) -> list[Message]:
361 | return [
362 | UserMessage("I'm seeing this error:"),
363 | UserMessage(error),
364 | AssistantMessage("I'll help debug that. What have you tried so far?")
365 | ]
366 | ```
367 |
368 |
369 | ### Images
370 |
371 | FastMCP provides an `Image` class that automatically handles image data in your server:
372 |
373 | ```python
374 | from fastmcp import FastMCP, Image
375 | from PIL import Image as PILImage
376 |
377 | @mcp.tool()
378 | def create_thumbnail(image_path: str) -> Image:
379 | """Create a thumbnail from an image"""
380 | img = PILImage.open(image_path)
381 | img.thumbnail((100, 100))
382 |
383 | # FastMCP automatically handles conversion and MIME types
384 | return Image(data=img.tobytes(), format="png")
385 |
386 | @mcp.tool()
387 | def load_image(path: str) -> Image:
388 | """Load an image from disk"""
389 | # FastMCP handles reading and format detection
390 | return Image(path=path)
391 | ```
392 |
393 | Images can be used as the result of both tools and resources.
394 |
395 | ### Context
396 |
397 | The Context object gives your tools and resources access to MCP capabilities. To use it, add a parameter annotated with `fastmcp.Context`:
398 |
399 | ```python
400 | from fastmcp import FastMCP, Context
401 |
402 | @mcp.tool()
403 | async def long_task(files: list[str], ctx: Context) -> str:
404 | """Process multiple files with progress tracking"""
405 | for i, file in enumerate(files):
406 | ctx.info(f"Processing {file}")
407 | await ctx.report_progress(i, len(files))
408 |
409 | # Read another resource if needed
410 | data = await ctx.read_resource(f"file://{file}")
411 |
412 | return "Processing complete"
413 | ```
414 |
415 | The Context object provides:
416 | - Progress reporting through `report_progress()`
417 | - Logging via `debug()`, `info()`, `warning()`, and `error()`
418 | - Resource access through `read_resource()`
419 | - Request metadata via `request_id` and `client_id`
420 |
421 | ## Running Your Server
422 |
423 | There are three main ways to use your FastMCP server, each suited for different stages of development:
424 |
425 | ### Development Mode (Recommended for Building & Testing)
426 |
427 | The fastest way to test and debug your server is with the MCP Inspector:
428 |
429 | ```bash
430 | fastmcp dev server.py
431 | ```
432 |
433 | This launches a web interface where you can:
434 | - Test your tools and resources interactively
435 | - See detailed logs and error messages
436 | - Monitor server performance
437 | - Set environment variables for testing
438 |
439 | During development, you can:
440 | - Add dependencies with `--with`:
441 | ```bash
442 | fastmcp dev server.py --with pandas --with numpy
443 | ```
444 | - Mount your local code for live updates:
445 | ```bash
446 | fastmcp dev server.py --with-editable .
447 | ```
448 |
449 | ### Claude Desktop Integration (For Regular Use)
450 |
451 | Once your server is ready, install it in Claude Desktop to use it with Claude:
452 |
453 | ```bash
454 | fastmcp install server.py
455 | ```
456 |
457 | Your server will run in an isolated environment with:
458 | - Automatic installation of dependencies specified in your FastMCP instance:
459 | ```python
460 | mcp = FastMCP("My App", dependencies=["pandas", "numpy"])
461 | ```
462 | - Custom naming via `--name`:
463 | ```bash
464 | fastmcp install server.py --name "My Analytics Server"
465 | ```
466 | - Environment variable management:
467 | ```bash
468 | # Set variables individually
469 | fastmcp install server.py -e API_KEY=abc123 -e DB_URL=postgres://...
470 |
471 | # Or load from a .env file
472 | fastmcp install server.py -f .env
473 | ```
474 |
475 | ### Direct Execution (For Advanced Use Cases)
476 |
477 | For advanced scenarios like custom deployments or running without Claude, you can execute your server directly:
478 |
479 | ```python
480 | from fastmcp import FastMCP
481 |
482 | mcp = FastMCP("My App")
483 |
484 | if __name__ == "__main__":
485 | mcp.run()
486 | ```
487 |
488 | Run it with:
489 | ```bash
490 | # Using the FastMCP CLI
491 | fastmcp run server.py
492 |
493 | # Or with Python/uv directly
494 | python server.py
495 | uv run python server.py
496 | ```
497 |
498 |
499 | Note: When running directly, you are responsible for ensuring all dependencies are available in your environment. Any dependencies specified on the FastMCP instance are ignored.
500 |
501 | Choose this method when you need:
502 | - Custom deployment configurations
503 | - Integration with other services
504 | - Direct control over the server lifecycle
505 |
506 | ### Server Object Names
507 |
508 | All FastMCP commands will look for a server object called `mcp`, `app`, or `server` in your file. If you have a different object name or multiple servers in one file, use the syntax `server.py:my_server`:
509 |
510 | ```bash
511 | # Using a standard name
512 | fastmcp run server.py
513 |
514 | # Using a custom name
515 | fastmcp run server.py:my_custom_server
516 | ```
517 |
518 | ## Examples
519 |
520 | Here are a few examples of FastMCP servers. For more, see the `examples/` directory.
521 |
522 | ### Echo Server
523 | A simple server demonstrating resources, tools, and prompts:
524 |
525 | ```python
526 | from fastmcp import FastMCP
527 |
528 | mcp = FastMCP("Echo")
529 |
530 | @mcp.resource("echo://{message}")
531 | def echo_resource(message: str) -> str:
532 | """Echo a message as a resource"""
533 | return f"Resource echo: {message}"
534 |
535 | @mcp.tool()
536 | def echo_tool(message: str) -> str:
537 | """Echo a message as a tool"""
538 | return f"Tool echo: {message}"
539 |
540 | @mcp.prompt()
541 | def echo_prompt(message: str) -> str:
542 | """Create an echo prompt"""
543 | return f"Please process this message: {message}"
544 | ```
545 |
546 | ### SQLite Explorer
547 | A more complex example showing database integration:
548 |
549 | ```python
550 | from fastmcp import FastMCP
551 | import sqlite3
552 |
553 | mcp = FastMCP("SQLite Explorer")
554 |
555 | @mcp.resource("schema://main")
556 | def get_schema() -> str:
557 | """Provide the database schema as a resource"""
558 | conn = sqlite3.connect("database.db")
559 | schema = conn.execute(
560 | "SELECT sql FROM sqlite_master WHERE type='table'"
561 | ).fetchall()
562 | return "\n".join(sql[0] for sql in schema if sql[0])
563 |
564 | @mcp.tool()
565 | def query_data(sql: str) -> str:
566 | """Execute SQL queries safely"""
567 | conn = sqlite3.connect("database.db")
568 | try:
569 | result = conn.execute(sql).fetchall()
570 | return "\n".join(str(row) for row in result)
571 | except Exception as e:
572 | return f"Error: {str(e)}"
573 |
574 | @mcp.prompt()
575 | def analyze_table(table: str) -> str:
576 | """Create a prompt template for analyzing tables"""
577 | return f"""Please analyze this database table:
578 | Table: {table}
579 | Schema:
580 | {get_schema()}
581 |
582 | What insights can you provide about the structure and relationships?"""
583 | ```
584 |
585 | ## Contributing
586 |
587 |
588 |
589 | Open Developer Guide
590 |
591 | ### Prerequisites
592 |
593 | FastMCP requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
594 |
595 | ### Installation
596 |
597 | For development, we recommend installing FastMCP with development dependencies, which includes various utilities the maintainers find useful.
598 |
599 | ```bash
600 | git clone https://github.com/jlowin/fastmcp.git
601 | cd fastmcp
602 | uv sync --frozen --extra dev
603 | ```
604 |
605 | For running tests only (e.g., in CI), you only need the testing dependencies:
606 |
607 | ```bash
608 | uv sync --frozen --extra tests
609 | ```
610 |
611 | ### Testing
612 |
613 | Please make sure to test any new functionality. Your tests should be simple and atomic and anticipate change rather than cement complex patterns.
614 |
615 | Run tests from the root directory:
616 |
617 |
618 | ```bash
619 | pytest -vv
620 | ```
621 |
622 | ### Formatting
623 |
624 | FastMCP enforces a variety of required formats, which you can automatically enforce with pre-commit.
625 |
626 | Install the pre-commit hooks:
627 |
628 | ```bash
629 | pre-commit install
630 | ```
631 |
632 | The hooks will now run on every commit (as well as on every PR). To run them manually:
633 |
634 | ```bash
635 | pre-commit run --all-files
636 | ```
637 |
638 | ### Opening a Pull Request
639 |
640 | Fork the repository and create a new branch:
641 |
642 | ```bash
643 | git checkout -b my-branch
644 | ```
645 |
646 | Make your changes and commit them:
647 |
648 |
649 | ```bash
650 | git add . && git commit -m "My changes"
651 | ```
652 |
653 | Push your changes to your fork:
654 |
655 |
656 | ```bash
657 | git push origin my-branch
658 | ```
659 |
660 | Feel free to reach out in a GitHub issue or discussion if you have any questions!
661 |
662 |
663 |
664 |
665 | ================================================
666 | File: /examples/simple_echo.py
667 | ================================================
668 | """
669 | FastMCP Echo Server
670 | """
671 |
672 | from fastmcp import FastMCP
673 |
674 |
675 | # Create server
676 | mcp = FastMCP("Echo Server")
677 |
678 |
679 | @mcp.tool()
680 | def echo(text: str) -> str:
681 | """Echo the input text"""
682 | return text
683 |
684 |
685 | ================================================
686 | File: /examples/memory.py
687 | ================================================
688 | # /// script
689 | # dependencies = ["pydantic-ai-slim[openai]", "asyncpg", "numpy", "pgvector", "fastmcp"]
690 | # ///
691 |
692 | # uv pip install 'pydantic-ai-slim[openai]' asyncpg numpy pgvector fastmcp
693 |
694 | """
695 | Recursive memory system inspired by the human brain's clustering of memories.
696 | Uses OpenAI's 'text-embedding-3-small' model and pgvector for efficient similarity search.
697 | """
698 |
699 | import asyncio
700 | import math
701 | import os
702 | from dataclasses import dataclass
703 | from datetime import datetime, timezone
704 | from pathlib import Path
705 | from typing import Annotated, Self
706 |
707 | import asyncpg
708 | import numpy as np
709 | from openai import AsyncOpenAI
710 | from pgvector.asyncpg import register_vector # Import register_vector
711 | from pydantic import BaseModel, Field
712 | from pydantic_ai import Agent
713 |
714 | from fastmcp import FastMCP
715 |
716 | MAX_DEPTH = 5
717 | SIMILARITY_THRESHOLD = 0.7
718 | DECAY_FACTOR = 0.99
719 | REINFORCEMENT_FACTOR = 1.1
720 |
721 | DEFAULT_LLM_MODEL = "openai:gpt-4o"
722 | DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"
723 |
724 | mcp = FastMCP(
725 | "memory",
726 | dependencies=[
727 | "pydantic-ai-slim[openai]",
728 | "asyncpg",
729 | "numpy",
730 | "pgvector",
731 | ],
732 | )
733 |
734 | DB_DSN = "postgresql://postgres:postgres@localhost:54320/memory_db"
735 | # reset memory with rm ~/.fastmcp/{USER}/memory/*
736 | PROFILE_DIR = (
737 | Path.home() / ".fastmcp" / os.environ.get("USER", "anon") / "memory"
738 | ).resolve()
739 | PROFILE_DIR.mkdir(parents=True, exist_ok=True)
740 |
741 |
742 | def cosine_similarity(a: list[float], b: list[float]) -> float:
743 | a_array = np.array(a, dtype=np.float64)
744 | b_array = np.array(b, dtype=np.float64)
745 | return np.dot(a_array, b_array) / (
746 | np.linalg.norm(a_array) * np.linalg.norm(b_array)
747 | )
748 |
749 |
750 | async def do_ai[T](
751 | user_prompt: str,
752 | system_prompt: str,
753 | result_type: type[T] | Annotated,
754 | deps=None,
755 | ) -> T:
756 | agent = Agent(
757 | DEFAULT_LLM_MODEL,
758 | system_prompt=system_prompt,
759 | result_type=result_type,
760 | )
761 | result = await agent.run(user_prompt, deps=deps)
762 | return result.data
763 |
764 |
765 | @dataclass
766 | class Deps:
767 | openai: AsyncOpenAI
768 | pool: asyncpg.Pool
769 |
770 |
771 | async def get_db_pool() -> asyncpg.Pool:
772 | async def init(conn):
773 | await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;")
774 | await register_vector(conn)
775 |
776 | pool = await asyncpg.create_pool(DB_DSN, init=init)
777 | return pool
778 |
779 |
780 | class MemoryNode(BaseModel):
781 | id: int | None = None
782 | content: str
783 | summary: str = ""
784 | importance: float = 1.0
785 | access_count: int = 0
786 | timestamp: float = Field(
787 | default_factory=lambda: datetime.now(timezone.utc).timestamp()
788 | )
789 | embedding: list[float]
790 |
791 | @classmethod
792 | async def from_content(cls, content: str, deps: Deps):
793 | embedding = await get_embedding(content, deps)
794 | return cls(content=content, embedding=embedding)
795 |
796 | async def save(self, deps: Deps):
797 | async with deps.pool.acquire() as conn:
798 | if self.id is None:
799 | result = await conn.fetchrow(
800 | """
801 | INSERT INTO memories (content, summary, importance, access_count, timestamp, embedding)
802 | VALUES ($1, $2, $3, $4, $5, $6)
803 | RETURNING id
804 | """,
805 | self.content,
806 | self.summary,
807 | self.importance,
808 | self.access_count,
809 | self.timestamp,
810 | self.embedding,
811 | )
812 | self.id = result["id"]
813 | else:
814 | await conn.execute(
815 | """
816 | UPDATE memories
817 | SET content = $1, summary = $2, importance = $3,
818 | access_count = $4, timestamp = $5, embedding = $6
819 | WHERE id = $7
820 | """,
821 | self.content,
822 | self.summary,
823 | self.importance,
824 | self.access_count,
825 | self.timestamp,
826 | self.embedding,
827 | self.id,
828 | )
829 |
830 | async def merge_with(self, other: Self, deps: Deps):
831 | self.content = await do_ai(
832 | f"{self.content}\n\n{other.content}",
833 | "Combine the following two texts into a single, coherent text.",
834 | str,
835 | deps,
836 | )
837 | self.importance += other.importance
838 | self.access_count += other.access_count
839 | self.embedding = [(a + b) / 2 for a, b in zip(self.embedding, other.embedding)]
840 | self.summary = await do_ai(
841 | self.content, "Summarize the following text concisely.", str, deps
842 | )
843 | await self.save(deps)
844 | # Delete the merged node from the database
845 | if other.id is not None:
846 | await delete_memory(other.id, deps)
847 |
848 | def get_effective_importance(self):
849 | return self.importance * (1 + math.log(self.access_count + 1))
850 |
851 |
852 | async def get_embedding(text: str, deps: Deps) -> list[float]:
853 | embedding_response = await deps.openai.embeddings.create(
854 | input=text,
855 | model=DEFAULT_EMBEDDING_MODEL,
856 | )
857 | return embedding_response.data[0].embedding
858 |
859 |
860 | async def delete_memory(memory_id: int, deps: Deps):
861 | async with deps.pool.acquire() as conn:
862 | await conn.execute("DELETE FROM memories WHERE id = $1", memory_id)
863 |
864 |
865 | async def add_memory(content: str, deps: Deps):
866 | new_memory = await MemoryNode.from_content(content, deps)
867 | await new_memory.save(deps)
868 |
869 | similar_memories = await find_similar_memories(new_memory.embedding, deps)
870 | for memory in similar_memories:
871 | if memory.id != new_memory.id:
872 | await new_memory.merge_with(memory, deps)
873 |
874 | await update_importance(new_memory.embedding, deps)
875 |
876 | await prune_memories(deps)
877 |
878 | return f"Remembered: {content}"
879 |
880 |
881 | async def find_similar_memories(embedding: list[float], deps: Deps) -> list[MemoryNode]:
882 | async with deps.pool.acquire() as conn:
883 | rows = await conn.fetch(
884 | """
885 | SELECT id, content, summary, importance, access_count, timestamp, embedding
886 | FROM memories
887 | ORDER BY embedding <-> $1
888 | LIMIT 5
889 | """,
890 | embedding,
891 | )
892 | memories = [
893 | MemoryNode(
894 | id=row["id"],
895 | content=row["content"],
896 | summary=row["summary"],
897 | importance=row["importance"],
898 | access_count=row["access_count"],
899 | timestamp=row["timestamp"],
900 | embedding=row["embedding"],
901 | )
902 | for row in rows
903 | ]
904 | return memories
905 |
906 |
907 | async def update_importance(user_embedding: list[float], deps: Deps):
908 | async with deps.pool.acquire() as conn:
909 | rows = await conn.fetch(
910 | "SELECT id, importance, access_count, embedding FROM memories"
911 | )
912 | for row in rows:
913 | memory_embedding = row["embedding"]
914 | similarity = cosine_similarity(user_embedding, memory_embedding)
915 | if similarity > SIMILARITY_THRESHOLD:
916 | new_importance = row["importance"] * REINFORCEMENT_FACTOR
917 | new_access_count = row["access_count"] + 1
918 | else:
919 | new_importance = row["importance"] * DECAY_FACTOR
920 | new_access_count = row["access_count"]
921 | await conn.execute(
922 | """
923 | UPDATE memories
924 | SET importance = $1, access_count = $2
925 | WHERE id = $3
926 | """,
927 | new_importance,
928 | new_access_count,
929 | row["id"],
930 | )
931 |
932 |
933 | async def prune_memories(deps: Deps):
934 | async with deps.pool.acquire() as conn:
935 | rows = await conn.fetch(
936 | """
937 | SELECT id, importance, access_count
938 | FROM memories
939 | ORDER BY importance DESC
940 | OFFSET $1
941 | """,
942 | MAX_DEPTH,
943 | )
944 | for row in rows:
945 | await conn.execute("DELETE FROM memories WHERE id = $1", row["id"])
946 |
947 |
948 | async def display_memory_tree(deps: Deps) -> str:
949 | async with deps.pool.acquire() as conn:
950 | rows = await conn.fetch(
951 | """
952 | SELECT content, summary, importance, access_count
953 | FROM memories
954 | ORDER BY importance DESC
955 | LIMIT $1
956 | """,
957 | MAX_DEPTH,
958 | )
959 | result = ""
960 | for row in rows:
961 | effective_importance = row["importance"] * (
962 | 1 + math.log(row["access_count"] + 1)
963 | )
964 | summary = row["summary"] or row["content"]
965 | result += f"- {summary} (Importance: {effective_importance:.2f})\n"
966 | return result
967 |
968 |
969 | @mcp.tool()
970 | async def remember(
971 | contents: list[str] = Field(
972 | description="List of observations or memories to store"
973 | ),
974 | ):
975 | deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool())
976 | try:
977 | return "\n".join(
978 | await asyncio.gather(*[add_memory(content, deps) for content in contents])
979 | )
980 | finally:
981 | await deps.pool.close()
982 |
983 |
984 | @mcp.tool()
985 | async def read_profile() -> str:
986 | deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool())
987 | profile = await display_memory_tree(deps)
988 | await deps.pool.close()
989 | return profile
990 |
991 |
992 | async def initialize_database():
993 | pool = await asyncpg.create_pool(
994 | "postgresql://postgres:postgres@localhost:54320/postgres"
995 | )
996 | try:
997 | async with pool.acquire() as conn:
998 | await conn.execute("""
999 | SELECT pg_terminate_backend(pg_stat_activity.pid)
1000 | FROM pg_stat_activity
1001 | WHERE pg_stat_activity.datname = 'memory_db'
1002 | AND pid <> pg_backend_pid();
1003 | """)
1004 | await conn.execute("DROP DATABASE IF EXISTS memory_db;")
1005 | await conn.execute("CREATE DATABASE memory_db;")
1006 | finally:
1007 | await pool.close()
1008 |
1009 | pool = await asyncpg.create_pool(DB_DSN)
1010 | try:
1011 | async with pool.acquire() as conn:
1012 | await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;")
1013 |
1014 | await register_vector(conn)
1015 |
1016 | await conn.execute("""
1017 | CREATE TABLE IF NOT EXISTS memories (
1018 | id SERIAL PRIMARY KEY,
1019 | content TEXT NOT NULL,
1020 | summary TEXT,
1021 | importance REAL NOT NULL,
1022 | access_count INT NOT NULL,
1023 | timestamp DOUBLE PRECISION NOT NULL,
1024 | embedding vector(1536) NOT NULL
1025 | );
1026 | CREATE INDEX IF NOT EXISTS idx_memories_embedding ON memories USING hnsw (embedding vector_l2_ops);
1027 | """)
1028 | finally:
1029 | await pool.close()
1030 |
1031 |
1032 | if __name__ == "__main__":
1033 | asyncio.run(initialize_database())
1034 |
1035 |
1036 | ================================================
1037 | File: /examples/readme-quickstart.py
1038 | ================================================
1039 | from fastmcp import FastMCP
1040 |
1041 |
1042 | # Create an MCP server
1043 | mcp = FastMCP("Demo")
1044 |
1045 |
1046 | # Add an addition tool
1047 | @mcp.tool()
1048 | def add(a: int, b: int) -> int:
1049 | """Add two numbers"""
1050 | return a + b
1051 |
1052 |
1053 | # Add a dynamic greeting resource
1054 | @mcp.resource("greeting://{name}")
1055 | def get_greeting(name: str) -> str:
1056 | """Get a personalized greeting"""
1057 | return f"Hello, {name}!"
1058 |
1059 |
1060 | ================================================
1061 | File: /examples/text_me.py
1062 | ================================================
1063 | # /// script
1064 | # dependencies = ["fastmcp"]
1065 | # ///
1066 |
1067 | """
1068 | FastMCP Text Me Server
1069 | --------------------------------
1070 | This defines a simple FastMCP server that sends a text message to a phone number via https://surgemsg.com/.
1071 |
1072 | To run this example, create a `.env` file with the following values:
1073 |
1074 | SURGE_API_KEY=...
1075 | SURGE_ACCOUNT_ID=...
1076 | SURGE_MY_PHONE_NUMBER=...
1077 | SURGE_MY_FIRST_NAME=...
1078 | SURGE_MY_LAST_NAME=...
1079 |
1080 | Visit https://surgemsg.com/ and click "Get Started" to obtain these values.
1081 | """
1082 |
1083 | from typing import Annotated
1084 | import httpx
1085 | from pydantic import BeforeValidator
1086 | from pydantic_settings import BaseSettings, SettingsConfigDict
1087 |
1088 | from fastmcp import FastMCP
1089 |
1090 |
1091 | class SurgeSettings(BaseSettings):
1092 | model_config: SettingsConfigDict = SettingsConfigDict(
1093 | env_prefix="SURGE_", env_file=".env"
1094 | )
1095 |
1096 | api_key: str
1097 | account_id: str
1098 | my_phone_number: Annotated[
1099 | str, BeforeValidator(lambda v: "+" + v if not v.startswith("+") else v)
1100 | ]
1101 | my_first_name: str
1102 | my_last_name: str
1103 |
1104 |
1105 | # Create server
1106 | mcp = FastMCP("Text me")
1107 | surge_settings = SurgeSettings() # type: ignore
1108 |
1109 |
1110 | @mcp.tool(name="textme", description="Send a text message to me")
1111 | def text_me(text_content: str) -> str:
1112 | """Send a text message to a phone number via https://surgemsg.com/"""
1113 | with httpx.Client() as client:
1114 | response = client.post(
1115 | "https://api.surgemsg.com/messages",
1116 | headers={
1117 | "Authorization": f"Bearer {surge_settings.api_key}",
1118 | "Surge-Account": surge_settings.account_id,
1119 | "Content-Type": "application/json",
1120 | },
1121 | json={
1122 | "body": text_content,
1123 | "conversation": {
1124 | "contact": {
1125 | "first_name": surge_settings.my_first_name,
1126 | "last_name": surge_settings.my_last_name,
1127 | "phone_number": surge_settings.my_phone_number,
1128 | }
1129 | },
1130 | },
1131 | )
1132 | response.raise_for_status()
1133 | return f"Message sent: {text_content}"
1134 |
1135 |
1136 | ================================================
1137 | File: /examples/screenshot.py
1138 | ================================================
1139 | """
1140 | FastMCP Screenshot Example
1141 |
1142 | Give Claude a tool to capture and view screenshots.
1143 | """
1144 |
1145 | import io
1146 | from fastmcp import FastMCP, Image
1147 |
1148 |
1149 | # Create server
1150 | mcp = FastMCP("Screenshot Demo", dependencies=["pyautogui", "Pillow"])
1151 |
1152 |
1153 | @mcp.tool()
1154 | def take_screenshot() -> Image:
1155 | """
1156 | Take a screenshot of the user's screen and return it as an image. Use
1157 | this tool anytime the user wants you to look at something they're doing.
1158 | """
1159 | import pyautogui
1160 |
1161 | buffer = io.BytesIO()
1162 |
1163 | # if the file exceeds ~1MB, it will be rejected by Claude
1164 | screenshot = pyautogui.screenshot()
1165 | screenshot.convert("RGB").save(buffer, format="JPEG", quality=60, optimize=True)
1166 | return Image(data=buffer.getvalue(), format="jpeg")
1167 |
1168 |
1169 | ================================================
1170 | File: /examples/echo.py
1171 | ================================================
1172 | """
1173 | FastMCP Echo Server
1174 | """
1175 |
1176 | from fastmcp import FastMCP
1177 |
1178 | # Create server
1179 | mcp = FastMCP("Echo Server")
1180 |
1181 |
1182 | @mcp.tool()
1183 | def echo_tool(text: str) -> str:
1184 | """Echo the input text"""
1185 | return text
1186 |
1187 |
1188 | @mcp.resource("echo://static")
1189 | def echo_resource() -> str:
1190 | return "Echo!"
1191 |
1192 |
1193 | @mcp.resource("echo://{text}")
1194 | def echo_template(text: str) -> str:
1195 | """Echo the input text"""
1196 | return f"Echo: {text}"
1197 |
1198 |
1199 | @mcp.prompt("echo")
1200 | def echo_prompt(text: str) -> str:
1201 | return text
1202 |
1203 |
1204 | ================================================
1205 | File: /examples/desktop.py
1206 | ================================================
1207 | """
1208 | FastMCP Desktop Example
1209 |
1210 | A simple example that exposes the desktop directory as a resource.
1211 | """
1212 |
1213 | from pathlib import Path
1214 |
1215 | from fastmcp.server import FastMCP
1216 |
1217 | # Create server
1218 | mcp = FastMCP("Demo")
1219 |
1220 |
1221 | @mcp.resource("dir://desktop")
1222 | def desktop() -> list[str]:
1223 | """List the files in the user's desktop"""
1224 | desktop = Path.home() / "Desktop"
1225 | return [str(f) for f in desktop.iterdir()]
1226 |
1227 |
1228 | @mcp.tool()
1229 | def add(a: int, b: int) -> int:
1230 | """Add two numbers"""
1231 | return a + b
1232 |
1233 |
1234 | ================================================
1235 | File: /examples/complex_inputs.py
1236 | ================================================
1237 | """
1238 | FastMCP Complex inputs Example
1239 |
1240 | Demonstrates validation via pydantic with complex models.
1241 | """
1242 |
1243 | from pydantic import BaseModel, Field
1244 | from typing import Annotated
1245 | from fastmcp.server import FastMCP
1246 |
1247 | mcp = FastMCP("Shrimp Tank")
1248 |
1249 |
1250 | class ShrimpTank(BaseModel):
1251 | class Shrimp(BaseModel):
1252 | name: Annotated[str, Field(max_length=10)]
1253 |
1254 | shrimp: list[Shrimp]
1255 |
1256 |
1257 | @mcp.tool()
1258 | def name_shrimp(
1259 | tank: ShrimpTank,
1260 | # You can use pydantic Field in function signatures for validation.
1261 | extra_names: Annotated[list[str], Field(max_length=10)],
1262 | ) -> list[str]:
1263 | """List all shrimp names in the tank"""
1264 | return [shrimp.name for shrimp in tank.shrimp] + extra_names
1265 |
1266 |
1267 | ================================================
1268 | File: /Windows_Notes.md
1269 | ================================================
1270 | # Getting your development environment set up properly
1271 | To get your environment up and running properly, you'll need a slightly different set of commands that are windows specific:
1272 | ```bash
1273 | uv venv
1274 | .venv\Scripts\activate
1275 | uv pip install -e ".[dev]"
1276 | ```
1277 |
1278 | This will install the package in editable mode, and install the development dependencies.
1279 |
1280 |
1281 | # Fixing `AttributeError: module 'collections' has no attribute 'Callable'`
1282 | - open `.venv\Lib\site-packages\pyreadline\py3k_compat.py`
1283 | - change `return isinstance(x, collections.Callable)` to
1284 | ```
1285 | from collections.abc import Callable
1286 | return isinstance(x, Callable)
1287 | ```
1288 |
1289 | # Helpful notes
1290 | For developing FastMCP
1291 | ## Install local development version of FastMCP into a local FastMCP project server
1292 | - ensure
1293 | - change directories to your FastMCP Server location so you can install it in your .venv
1294 | - run `.venv\Scripts\activate` to activate your virtual environment
1295 | - Then run a series of commands to uninstall the old version and install the new
1296 | ```bash
1297 | # First uninstall
1298 | uv pip uninstall fastmcp
1299 |
1300 | # Clean any build artifacts in your fastmcp directory
1301 | cd C:\path\to\fastmcp
1302 | del /s /q *.egg-info
1303 |
1304 | # Then reinstall in your weather project
1305 | cd C:\path\to\new\fastmcp_server
1306 | uv pip install --no-cache-dir -e C:\Users\justj\PycharmProjects\fastmcp
1307 |
1308 | # Check that it installed properly and has the correct git hash
1309 | pip show fastmcp
1310 | ```
1311 |
1312 | ## Running the FastMCP server with Inspector
1313 | MCP comes with a node.js application called Inspector that can be used to inspect the FastMCP server. To run the inspector, you'll need to install node.js and npm. Then you can run the following commands:
1314 | ```bash
1315 | fastmcp dev server.py
1316 | ```
1317 | This will launch a web app on http://localhost:5173/ that you can use to inspect the FastMCP server.
1318 |
1319 | ## If you start development before creating a fork - your get out of jail free card
1320 | - Add your fork as a new remote to your local repository `git remote add fork git@github.com:YOUR-USERNAME/REPOSITORY-NAME.git`
1321 | - This will add your repo, short named 'fork', as a remote to your local repository
1322 | - Verify that it was added correctly by running `git remote -v`
1323 | - Commit your changes
1324 | - Push your changes to your fork `git push fork `
1325 | - Create your pull request on GitHub
1326 |
1327 |
1328 |
1329 |
1330 | ================================================
1331 | File: /src/fastmcp/server.py
1332 | ================================================
1333 | """FastMCP - A more ergonomic interface for MCP servers."""
1334 |
1335 | import asyncio
1336 | import functools
1337 | import inspect
1338 | import json
1339 | import re
1340 | from itertools import chain
1341 | from typing import Any, Callable, Dict, Literal, Sequence, TypeVar, ParamSpec
1342 |
1343 | import pydantic_core
1344 | from pydantic import Field
1345 | import uvicorn
1346 | from mcp.server import Server as MCPServer
1347 | from mcp.server.sse import SseServerTransport
1348 | from mcp.server.stdio import stdio_server
1349 | from mcp.shared.context import RequestContext
1350 | from mcp.types import (
1351 | EmbeddedResource,
1352 | GetPromptResult,
1353 | ImageContent,
1354 | TextContent,
1355 | )
1356 | from mcp.types import (
1357 | Prompt as MCPPrompt,
1358 | PromptArgument as MCPPromptArgument,
1359 | )
1360 | from mcp.types import (
1361 | Resource as MCPResource,
1362 | )
1363 | from mcp.types import (
1364 | ResourceTemplate as MCPResourceTemplate,
1365 | )
1366 | from mcp.types import (
1367 | Tool as MCPTool,
1368 | )
1369 | from pydantic import BaseModel
1370 | from pydantic.networks import AnyUrl
1371 | from pydantic_settings import BaseSettings, SettingsConfigDict
1372 |
1373 | from fastmcp.exceptions import ResourceError
1374 | from fastmcp.prompts import Prompt, PromptManager
1375 | from fastmcp.prompts.base import PromptResult
1376 | from fastmcp.resources import FunctionResource, Resource, ResourceManager
1377 | from fastmcp.tools import ToolManager
1378 | from fastmcp.utilities.logging import configure_logging, get_logger
1379 | from fastmcp.utilities.types import Image
1380 |
1381 | logger = get_logger(__name__)
1382 |
1383 | P = ParamSpec("P")
1384 | R = TypeVar("R")
1385 | R_PromptResult = TypeVar("R_PromptResult", bound=PromptResult)
1386 |
1387 |
1388 | class Settings(BaseSettings):
1389 | """FastMCP server settings.
1390 |
1391 | All settings can be configured via environment variables with the prefix FASTMCP_.
1392 | For example, FASTMCP_DEBUG=true will set debug=True.
1393 | """
1394 |
1395 | model_config: SettingsConfigDict = SettingsConfigDict(
1396 | env_prefix="FASTMCP_",
1397 | env_file=".env",
1398 | extra="ignore",
1399 | )
1400 |
1401 | # Server settings
1402 | debug: bool = False
1403 | log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
1404 |
1405 | # HTTP settings
1406 | host: str = "0.0.0.0"
1407 | port: int = 8000
1408 |
1409 | # resource settings
1410 | warn_on_duplicate_resources: bool = True
1411 |
1412 | # tool settings
1413 | warn_on_duplicate_tools: bool = True
1414 |
1415 | # prompt settings
1416 | warn_on_duplicate_prompts: bool = True
1417 |
1418 | dependencies: list[str] = Field(
1419 | default_factory=list,
1420 | description="List of dependencies to install in the server environment",
1421 | )
1422 |
1423 |
1424 | class FastMCP:
1425 | def __init__(self, name: str | None = None, **settings: Any):
1426 | self.settings = Settings(**settings)
1427 | self._mcp_server = MCPServer(name=name or "FastMCP")
1428 | self._tool_manager = ToolManager(
1429 | warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools
1430 | )
1431 | self._resource_manager = ResourceManager(
1432 | warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources
1433 | )
1434 | self._prompt_manager = PromptManager(
1435 | warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts
1436 | )
1437 | self.dependencies = self.settings.dependencies
1438 |
1439 | # Set up MCP protocol handlers
1440 | self._setup_handlers()
1441 |
1442 | # Configure logging
1443 | configure_logging(self.settings.log_level)
1444 |
1445 | @property
1446 | def name(self) -> str:
1447 | return self._mcp_server.name
1448 |
1449 | def run(self, transport: Literal["stdio", "sse"] = "stdio") -> None:
1450 | """Run the FastMCP server. Note this is a synchronous function.
1451 |
1452 | Args:
1453 | transport: Transport protocol to use ("stdio" or "sse")
1454 | """
1455 | TRANSPORTS = Literal["stdio", "sse"]
1456 | if transport not in TRANSPORTS.__args__: # type: ignore
1457 | raise ValueError(f"Unknown transport: {transport}")
1458 |
1459 | if transport == "stdio":
1460 | asyncio.run(self.run_stdio_async())
1461 | else: # transport == "sse"
1462 | asyncio.run(self.run_sse_async())
1463 |
1464 | def _setup_handlers(self) -> None:
1465 | """Set up core MCP protocol handlers."""
1466 | self._mcp_server.list_tools()(self.list_tools)
1467 | self._mcp_server.call_tool()(self.call_tool)
1468 | self._mcp_server.list_resources()(self.list_resources)
1469 | self._mcp_server.read_resource()(self.read_resource)
1470 | self._mcp_server.list_prompts()(self.list_prompts)
1471 | self._mcp_server.get_prompt()(self.get_prompt)
1472 | # TODO: This has not been added to MCP yet, see https://github.com/jlowin/fastmcp/issues/10
1473 | # self._mcp_server.list_resource_templates()(self.list_resource_templates)
1474 |
1475 | async def list_tools(self) -> list[MCPTool]:
1476 | """List all available tools."""
1477 | tools = self._tool_manager.list_tools()
1478 | return [
1479 | MCPTool(
1480 | name=info.name,
1481 | description=info.description,
1482 | inputSchema=info.parameters,
1483 | )
1484 | for info in tools
1485 | ]
1486 |
1487 | def get_context(self) -> "Context":
1488 | """
1489 | Returns a Context object. Note that the context will only be valid
1490 | during a request; outside a request, most methods will error.
1491 | """
1492 | try:
1493 | request_context = self._mcp_server.request_context
1494 | except LookupError:
1495 | request_context = None
1496 | return Context(request_context=request_context, fastmcp=self)
1497 |
1498 | async def call_tool(
1499 | self, name: str, arguments: dict
1500 | ) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
1501 | """Call a tool by name with arguments."""
1502 | context = self.get_context()
1503 | result = await self._tool_manager.call_tool(name, arguments, context=context)
1504 | converted_result = _convert_to_content(result)
1505 | return converted_result
1506 |
1507 | async def list_resources(self) -> list[MCPResource]:
1508 | """List all available resources."""
1509 |
1510 | resources = self._resource_manager.list_resources()
1511 | return [
1512 | MCPResource(
1513 | uri=resource.uri,
1514 | name=resource.name or "",
1515 | description=resource.description,
1516 | mimeType=resource.mime_type,
1517 | )
1518 | for resource in resources
1519 | ]
1520 |
1521 | async def list_resource_templates(self) -> list[MCPResourceTemplate]:
1522 | templates = self._resource_manager.list_templates()
1523 | return [
1524 | MCPResourceTemplate(
1525 | uriTemplate=template.uri_template,
1526 | name=template.name,
1527 | description=template.description,
1528 | )
1529 | for template in templates
1530 | ]
1531 |
1532 | async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
1533 | """Read a resource by URI."""
1534 | resource = await self._resource_manager.get_resource(uri)
1535 | if not resource:
1536 | raise ResourceError(f"Unknown resource: {uri}")
1537 |
1538 | try:
1539 | return await resource.read()
1540 | except Exception as e:
1541 | logger.error(f"Error reading resource {uri}: {e}")
1542 | raise ResourceError(str(e))
1543 |
1544 | def add_tool(
1545 | self,
1546 | fn: Callable,
1547 | name: str | None = None,
1548 | description: str | None = None,
1549 | ) -> None:
1550 | """Add a tool to the server.
1551 |
1552 | The tool function can optionally request a Context object by adding a parameter
1553 | with the Context type annotation. See the @tool decorator for examples.
1554 |
1555 | Args:
1556 | fn: The function to register as a tool
1557 | name: Optional name for the tool (defaults to function name)
1558 | description: Optional description of what the tool does
1559 | """
1560 | self._tool_manager.add_tool(fn, name=name, description=description)
1561 |
1562 | def tool(
1563 | self, name: str | None = None, description: str | None = None
1564 | ) -> Callable[[Callable[P, R]], Callable[P, R]]:
1565 | """Decorator to register a tool.
1566 |
1567 | Tools can optionally request a Context object by adding a parameter with the Context type annotation.
1568 | The context provides access to MCP capabilities like logging, progress reporting, and resource access.
1569 |
1570 | Args:
1571 | name: Optional name for the tool (defaults to function name)
1572 | description: Optional description of what the tool does
1573 |
1574 | Example:
1575 | @server.tool()
1576 | def my_tool(x: int) -> str:
1577 | return str(x)
1578 |
1579 | @server.tool()
1580 | def tool_with_context(x: int, ctx: Context) -> str:
1581 | ctx.info(f"Processing {x}")
1582 | return str(x)
1583 |
1584 | @server.tool()
1585 | async def async_tool(x: int, context: Context) -> str:
1586 | await context.report_progress(50, 100)
1587 | return str(x)
1588 | """
1589 | # Check if user passed function directly instead of calling decorator
1590 | if callable(name):
1591 | raise TypeError(
1592 | "The @tool decorator was used incorrectly. "
1593 | "Did you forget to call it? Use @tool() instead of @tool"
1594 | )
1595 |
1596 | def decorator(fn: Callable[P, R]) -> Callable[P, R]:
1597 | self.add_tool(fn, name=name, description=description)
1598 | return fn
1599 |
1600 | return decorator
1601 |
1602 | def add_resource(self, resource: Resource) -> None:
1603 | """Add a resource to the server.
1604 |
1605 | Args:
1606 | resource: A Resource instance to add
1607 | """
1608 | self._resource_manager.add_resource(resource)
1609 |
1610 | def resource(
1611 | self,
1612 | uri: str,
1613 | *,
1614 | name: str | None = None,
1615 | description: str | None = None,
1616 | mime_type: str | None = None,
1617 | ) -> Callable[[Callable[P, R]], Callable[P, R]]:
1618 | """Decorator to register a function as a resource.
1619 |
1620 | The function will be called when the resource is read to generate its content.
1621 | The function can return:
1622 | - str for text content
1623 | - bytes for binary content
1624 | - other types will be converted to JSON
1625 |
1626 | If the URI contains parameters (e.g. "resource://{param}") or the function
1627 | has parameters, it will be registered as a template resource.
1628 |
1629 | Args:
1630 | uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}")
1631 | name: Optional name for the resource
1632 | description: Optional description of the resource
1633 | mime_type: Optional MIME type for the resource
1634 |
1635 | Example:
1636 | @server.resource("resource://my-resource")
1637 | def get_data() -> str:
1638 | return "Hello, world!"
1639 |
1640 | @server.resource("resource://{city}/weather")
1641 | def get_weather(city: str) -> str:
1642 | return f"Weather for {city}"
1643 | """
1644 | # Check if user passed function directly instead of calling decorator
1645 | if callable(uri):
1646 | raise TypeError(
1647 | "The @resource decorator was used incorrectly. "
1648 | "Did you forget to call it? Use @resource('uri') instead of @resource"
1649 | )
1650 |
1651 | def decorator(fn: Callable[P, R]) -> Callable[P, R]:
1652 | @functools.wraps(fn)
1653 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
1654 | return fn(*args, **kwargs)
1655 |
1656 | # Check if this should be a template
1657 | has_uri_params = "{" in uri and "}" in uri
1658 | has_func_params = bool(inspect.signature(fn).parameters)
1659 |
1660 | if has_uri_params or has_func_params:
1661 | # Validate that URI params match function params
1662 | uri_params = set(re.findall(r"{(\w+)}", uri))
1663 | func_params = set(inspect.signature(fn).parameters.keys())
1664 |
1665 | if uri_params != func_params:
1666 | raise ValueError(
1667 | f"Mismatch between URI parameters {uri_params} "
1668 | f"and function parameters {func_params}"
1669 | )
1670 |
1671 | # Register as template
1672 | self._resource_manager.add_template(
1673 | wrapper,
1674 | uri_template=uri,
1675 | name=name,
1676 | description=description,
1677 | mime_type=mime_type or "text/plain",
1678 | )
1679 | else:
1680 | # Register as regular resource
1681 | resource = FunctionResource(
1682 | uri=AnyUrl(uri),
1683 | name=name,
1684 | description=description,
1685 | mime_type=mime_type or "text/plain",
1686 | fn=wrapper,
1687 | )
1688 | self.add_resource(resource)
1689 | return wrapper
1690 |
1691 | return decorator
1692 |
1693 | def add_prompt(self, prompt: Prompt) -> None:
1694 | """Add a prompt to the server.
1695 |
1696 | Args:
1697 | prompt: A Prompt instance to add
1698 | """
1699 | self._prompt_manager.add_prompt(prompt)
1700 |
1701 | def prompt(
1702 | self, name: str | None = None, description: str | None = None
1703 | ) -> Callable[[Callable[P, R_PromptResult]], Callable[P, R_PromptResult]]:
1704 | """Decorator to register a prompt.
1705 |
1706 | Args:
1707 | name: Optional name for the prompt (defaults to function name)
1708 | description: Optional description of what the prompt does
1709 |
1710 | Example:
1711 | @server.prompt()
1712 | def analyze_table(table_name: str) -> list[Message]:
1713 | schema = read_table_schema(table_name)
1714 | return [
1715 | {
1716 | "role": "user",
1717 | "content": f"Analyze this schema:\n{schema}"
1718 | }
1719 | ]
1720 |
1721 | @server.prompt()
1722 | async def analyze_file(path: str) -> list[Message]:
1723 | content = await read_file(path)
1724 | return [
1725 | {
1726 | "role": "user",
1727 | "content": {
1728 | "type": "resource",
1729 | "resource": {
1730 | "uri": f"file://{path}",
1731 | "text": content
1732 | }
1733 | }
1734 | }
1735 | ]
1736 | """
1737 | # Check if user passed function directly instead of calling decorator
1738 | if callable(name):
1739 | raise TypeError(
1740 | "The @prompt decorator was used incorrectly. "
1741 | "Did you forget to call it? Use @prompt() instead of @prompt"
1742 | )
1743 |
1744 | def decorator(func: Callable[P, R_PromptResult]) -> Callable[P, R_PromptResult]:
1745 | prompt = Prompt.from_function(func, name=name, description=description)
1746 | self.add_prompt(prompt)
1747 | return func
1748 |
1749 | return decorator
1750 |
1751 | async def run_stdio_async(self) -> None:
1752 | """Run the server using stdio transport."""
1753 | async with stdio_server() as (read_stream, write_stream):
1754 | await self._mcp_server.run(
1755 | read_stream,
1756 | write_stream,
1757 | self._mcp_server.create_initialization_options(),
1758 | )
1759 |
1760 | async def run_sse_async(self) -> None:
1761 | """Run the server using SSE transport."""
1762 | from starlette.applications import Starlette
1763 | from starlette.routing import Route
1764 |
1765 | sse = SseServerTransport("/messages")
1766 |
1767 | async def handle_sse(request):
1768 | async with sse.connect_sse(
1769 | request.scope, request.receive, request._send
1770 | ) as streams:
1771 | await self._mcp_server.run(
1772 | streams[0],
1773 | streams[1],
1774 | self._mcp_server.create_initialization_options(),
1775 | )
1776 |
1777 | async def handle_messages(request):
1778 | await sse.handle_post_message(request.scope, request.receive, request._send)
1779 |
1780 | starlette_app = Starlette(
1781 | debug=self.settings.debug,
1782 | routes=[
1783 | Route("/sse", endpoint=handle_sse),
1784 | Route("/messages", endpoint=handle_messages, methods=["POST"]),
1785 | ],
1786 | )
1787 |
1788 | config = uvicorn.Config(
1789 | starlette_app,
1790 | host=self.settings.host,
1791 | port=self.settings.port,
1792 | log_level=self.settings.log_level.lower(),
1793 | )
1794 | server = uvicorn.Server(config)
1795 | await server.serve()
1796 |
1797 | async def list_prompts(self) -> list[MCPPrompt]:
1798 | """List all available prompts."""
1799 | prompts = self._prompt_manager.list_prompts()
1800 | return [
1801 | MCPPrompt(
1802 | name=prompt.name,
1803 | description=prompt.description,
1804 | arguments=[
1805 | MCPPromptArgument(
1806 | name=arg.name,
1807 | description=arg.description,
1808 | required=arg.required,
1809 | )
1810 | for arg in (prompt.arguments or [])
1811 | ],
1812 | )
1813 | for prompt in prompts
1814 | ]
1815 |
1816 | async def get_prompt(
1817 | self, name: str, arguments: Dict[str, Any] | None = None
1818 | ) -> GetPromptResult:
1819 | """Get a prompt by name with arguments."""
1820 | try:
1821 | messages = await self._prompt_manager.render_prompt(name, arguments)
1822 |
1823 | return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages))
1824 | except Exception as e:
1825 | logger.error(f"Error getting prompt {name}: {e}")
1826 | raise ValueError(str(e))
1827 |
1828 |
1829 | def _convert_to_content(
1830 | result: Any,
1831 | ) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
1832 | """Convert a result to a sequence of content objects."""
1833 | if result is None:
1834 | return []
1835 |
1836 | if isinstance(result, (TextContent, ImageContent, EmbeddedResource)):
1837 | return [result]
1838 |
1839 | if isinstance(result, Image):
1840 | return [result.to_image_content()]
1841 |
1842 | if isinstance(result, (list, tuple)):
1843 | return list(chain.from_iterable(_convert_to_content(item) for item in result))
1844 |
1845 | if not isinstance(result, str):
1846 | try:
1847 | result = json.dumps(pydantic_core.to_jsonable_python(result))
1848 | except Exception:
1849 | result = str(result)
1850 |
1851 | return [TextContent(type="text", text=result)]
1852 |
1853 |
1854 | class Context(BaseModel):
1855 | """Context object providing access to MCP capabilities.
1856 |
1857 | This provides a cleaner interface to MCP's RequestContext functionality.
1858 | It gets injected into tool and resource functions that request it via type hints.
1859 |
1860 | To use context in a tool function, add a parameter with the Context type annotation:
1861 |
1862 | ```python
1863 | @server.tool()
1864 | def my_tool(x: int, ctx: Context) -> str:
1865 | # Log messages to the client
1866 | ctx.info(f"Processing {x}")
1867 | ctx.debug("Debug info")
1868 | ctx.warning("Warning message")
1869 | ctx.error("Error message")
1870 |
1871 | # Report progress
1872 | ctx.report_progress(50, 100)
1873 |
1874 | # Access resources
1875 | data = ctx.read_resource("resource://data")
1876 |
1877 | # Get request info
1878 | request_id = ctx.request_id
1879 | client_id = ctx.client_id
1880 |
1881 | return str(x)
1882 | ```
1883 |
1884 | The context parameter name can be anything as long as it's annotated with Context.
1885 | The context is optional - tools that don't need it can omit the parameter.
1886 | """
1887 |
1888 | _request_context: RequestContext | None
1889 | _fastmcp: FastMCP | None
1890 |
1891 | def __init__(
1892 | self,
1893 | *,
1894 | request_context: RequestContext | None = None,
1895 | fastmcp: FastMCP | None = None,
1896 | **kwargs: Any,
1897 | ):
1898 | super().__init__(**kwargs)
1899 | self._request_context = request_context
1900 | self._fastmcp = fastmcp
1901 |
1902 | @property
1903 | def fastmcp(self) -> FastMCP:
1904 | """Access to the FastMCP server."""
1905 | if self._fastmcp is None:
1906 | raise ValueError("Context is not available outside of a request")
1907 | return self._fastmcp
1908 |
1909 | @property
1910 | def request_context(self) -> RequestContext:
1911 | """Access to the underlying request context."""
1912 | if self._request_context is None:
1913 | raise ValueError("Context is not available outside of a request")
1914 | return self._request_context
1915 |
1916 | async def report_progress(
1917 | self, progress: float, total: float | None = None
1918 | ) -> None:
1919 | """Report progress for the current operation.
1920 |
1921 | Args:
1922 | progress: Current progress value e.g. 24
1923 | total: Optional total value e.g. 100
1924 | """
1925 |
1926 | progress_token = (
1927 | self.request_context.meta.progressToken
1928 | if self.request_context.meta
1929 | else None
1930 | )
1931 |
1932 | if not progress_token:
1933 | return
1934 |
1935 | await self.request_context.session.send_progress_notification(
1936 | progress_token=progress_token, progress=progress, total=total
1937 | )
1938 |
1939 | async def read_resource(self, uri: str | AnyUrl) -> str | bytes:
1940 | """Read a resource by URI.
1941 |
1942 | Args:
1943 | uri: Resource URI to read
1944 |
1945 | Returns:
1946 | The resource content as either text or bytes
1947 | """
1948 | assert (
1949 | self._fastmcp is not None
1950 | ), "Context is not available outside of a request"
1951 | return await self._fastmcp.read_resource(uri)
1952 |
1953 | def log(
1954 | self,
1955 | level: Literal["debug", "info", "warning", "error"],
1956 | message: str,
1957 | *,
1958 | logger_name: str | None = None,
1959 | ) -> None:
1960 | """Send a log message to the client.
1961 |
1962 | Args:
1963 | level: Log level (debug, info, warning, error)
1964 | message: Log message
1965 | logger_name: Optional logger name
1966 | **extra: Additional structured data to include
1967 | """
1968 | self.request_context.session.send_log_message(
1969 | level=level, data=message, logger=logger_name
1970 | )
1971 |
1972 | @property
1973 | def client_id(self) -> str | None:
1974 | """Get the client ID if available."""
1975 | return (
1976 | getattr(self.request_context.meta, "client_id", None)
1977 | if self.request_context.meta
1978 | else None
1979 | )
1980 |
1981 | @property
1982 | def request_id(self) -> str:
1983 | """Get the unique ID for this request."""
1984 | return str(self.request_context.request_id)
1985 |
1986 | @property
1987 | def session(self):
1988 | """Access to the underlying session for advanced usage."""
1989 | return self.request_context.session
1990 |
1991 | # Convenience methods for common log levels
1992 | def debug(self, message: str, **extra: Any) -> None:
1993 | """Send a debug log message."""
1994 | self.log("debug", message, **extra)
1995 |
1996 | def info(self, message: str, **extra: Any) -> None:
1997 | """Send an info log message."""
1998 | self.log("info", message, **extra)
1999 |
2000 | def warning(self, message: str, **extra: Any) -> None:
2001 | """Send a warning log message."""
2002 | self.log("warning", message, **extra)
2003 |
2004 | def error(self, message: str, **extra: Any) -> None:
2005 | """Send an error log message."""
2006 | self.log("error", message, **extra)
2007 |
2008 |
2009 | ================================================
2010 | File: /src/fastmcp/tools/tool_manager.py
2011 | ================================================
2012 | from fastmcp.exceptions import ToolError
2013 |
2014 | from fastmcp.tools.base import Tool
2015 |
2016 |
2017 | from typing import Any, Callable, Dict, Optional, TYPE_CHECKING
2018 |
2019 | from fastmcp.utilities.logging import get_logger
2020 |
2021 | if TYPE_CHECKING:
2022 | from fastmcp.server import Context
2023 |
2024 | logger = get_logger(__name__)
2025 |
2026 |
2027 | class ToolManager:
2028 | """Manages FastMCP tools."""
2029 |
2030 | def __init__(self, warn_on_duplicate_tools: bool = True):
2031 | self._tools: Dict[str, Tool] = {}
2032 | self.warn_on_duplicate_tools = warn_on_duplicate_tools
2033 |
2034 | def get_tool(self, name: str) -> Optional[Tool]:
2035 | """Get tool by name."""
2036 | return self._tools.get(name)
2037 |
2038 | def list_tools(self) -> list[Tool]:
2039 | """List all registered tools."""
2040 | return list(self._tools.values())
2041 |
2042 | def add_tool(
2043 | self,
2044 | fn: Callable,
2045 | name: Optional[str] = None,
2046 | description: Optional[str] = None,
2047 | ) -> Tool:
2048 | """Add a tool to the server."""
2049 | tool = Tool.from_function(fn, name=name, description=description)
2050 | existing = self._tools.get(tool.name)
2051 | if existing:
2052 | if self.warn_on_duplicate_tools:
2053 | logger.warning(f"Tool already exists: {tool.name}")
2054 | return existing
2055 | self._tools[tool.name] = tool
2056 | return tool
2057 |
2058 | async def call_tool(
2059 | self, name: str, arguments: dict, context: Optional["Context"] = None
2060 | ) -> Any:
2061 | """Call a tool by name with arguments."""
2062 | tool = self.get_tool(name)
2063 | if not tool:
2064 | raise ToolError(f"Unknown tool: {name}")
2065 |
2066 | return await tool.run(arguments, context=context)
2067 |
2068 |
2069 | ================================================
2070 | File: /src/fastmcp/tools/__init__.py
2071 | ================================================
2072 | from .base import Tool
2073 | from .tool_manager import ToolManager
2074 |
2075 | __all__ = ["Tool", "ToolManager"]
2076 |
2077 |
2078 | ================================================
2079 | File: /src/fastmcp/tools/base.py
2080 | ================================================
2081 | import fastmcp
2082 | from fastmcp.exceptions import ToolError
2083 |
2084 | from fastmcp.utilities.func_metadata import func_metadata, FuncMetadata
2085 | from pydantic import BaseModel, Field
2086 |
2087 |
2088 | import inspect
2089 | from typing import TYPE_CHECKING, Any, Callable, Optional
2090 |
2091 | if TYPE_CHECKING:
2092 | from fastmcp.server import Context
2093 |
2094 |
2095 | class Tool(BaseModel):
2096 | """Internal tool registration info."""
2097 |
2098 | fn: Callable = Field(exclude=True)
2099 | name: str = Field(description="Name of the tool")
2100 | description: str = Field(description="Description of what the tool does")
2101 | parameters: dict = Field(description="JSON schema for tool parameters")
2102 | fn_metadata: FuncMetadata = Field(
2103 | description="Metadata about the function including a pydantic model for tool arguments"
2104 | )
2105 | is_async: bool = Field(description="Whether the tool is async")
2106 | context_kwarg: Optional[str] = Field(
2107 | None, description="Name of the kwarg that should receive context"
2108 | )
2109 |
2110 | @classmethod
2111 | def from_function(
2112 | cls,
2113 | fn: Callable,
2114 | name: Optional[str] = None,
2115 | description: Optional[str] = None,
2116 | context_kwarg: Optional[str] = None,
2117 | ) -> "Tool":
2118 | """Create a Tool from a function."""
2119 | func_name = name or fn.__name__
2120 |
2121 | if func_name == "":
2122 | raise ValueError("You must provide a name for lambda functions")
2123 |
2124 | func_doc = description or fn.__doc__ or ""
2125 | is_async = inspect.iscoroutinefunction(fn)
2126 |
2127 | # Find context parameter if it exists
2128 | if context_kwarg is None:
2129 | sig = inspect.signature(fn)
2130 | for param_name, param in sig.parameters.items():
2131 | if param.annotation is fastmcp.Context:
2132 | context_kwarg = param_name
2133 | break
2134 |
2135 | func_arg_metadata = func_metadata(
2136 | fn,
2137 | skip_names=[context_kwarg] if context_kwarg is not None else [],
2138 | )
2139 | parameters = func_arg_metadata.arg_model.model_json_schema()
2140 |
2141 | return cls(
2142 | fn=fn,
2143 | name=func_name,
2144 | description=func_doc,
2145 | parameters=parameters,
2146 | fn_metadata=func_arg_metadata,
2147 | is_async=is_async,
2148 | context_kwarg=context_kwarg,
2149 | )
2150 |
2151 | async def run(self, arguments: dict, context: Optional["Context"] = None) -> Any:
2152 | """Run the tool with arguments."""
2153 | try:
2154 | return await self.fn_metadata.call_fn_with_arg_validation(
2155 | self.fn,
2156 | self.is_async,
2157 | arguments,
2158 | {self.context_kwarg: context}
2159 | if self.context_kwarg is not None
2160 | else None,
2161 | )
2162 | except Exception as e:
2163 | raise ToolError(f"Error executing tool {self.name}: {e}") from e
2164 |
2165 |
2166 | ================================================
2167 | File: /src/fastmcp/resources/resource_manager.py
2168 | ================================================
2169 | """Resource manager functionality."""
2170 |
2171 | from typing import Callable, Dict, Optional, Union
2172 |
2173 | from pydantic import AnyUrl
2174 |
2175 | from fastmcp.resources.base import Resource
2176 | from fastmcp.resources.templates import ResourceTemplate
2177 | from fastmcp.utilities.logging import get_logger
2178 |
2179 | logger = get_logger(__name__)
2180 |
2181 |
2182 | class ResourceManager:
2183 | """Manages FastMCP resources."""
2184 |
2185 | def __init__(self, warn_on_duplicate_resources: bool = True):
2186 | self._resources: Dict[str, Resource] = {}
2187 | self._templates: Dict[str, ResourceTemplate] = {}
2188 | self.warn_on_duplicate_resources = warn_on_duplicate_resources
2189 |
2190 | def add_resource(self, resource: Resource) -> Resource:
2191 | """Add a resource to the manager.
2192 |
2193 | Args:
2194 | resource: A Resource instance to add
2195 |
2196 | Returns:
2197 | The added resource. If a resource with the same URI already exists,
2198 | returns the existing resource.
2199 | """
2200 | logger.debug(
2201 | "Adding resource",
2202 | extra={
2203 | "uri": resource.uri,
2204 | "type": type(resource).__name__,
2205 | "name": resource.name,
2206 | },
2207 | )
2208 | existing = self._resources.get(str(resource.uri))
2209 | if existing:
2210 | if self.warn_on_duplicate_resources:
2211 | logger.warning(f"Resource already exists: {resource.uri}")
2212 | return existing
2213 | self._resources[str(resource.uri)] = resource
2214 | return resource
2215 |
2216 | def add_template(
2217 | self,
2218 | fn: Callable,
2219 | uri_template: str,
2220 | name: Optional[str] = None,
2221 | description: Optional[str] = None,
2222 | mime_type: Optional[str] = None,
2223 | ) -> ResourceTemplate:
2224 | """Add a template from a function."""
2225 | template = ResourceTemplate.from_function(
2226 | fn,
2227 | uri_template=uri_template,
2228 | name=name,
2229 | description=description,
2230 | mime_type=mime_type,
2231 | )
2232 | self._templates[template.uri_template] = template
2233 | return template
2234 |
2235 | async def get_resource(self, uri: Union[AnyUrl, str]) -> Optional[Resource]:
2236 | """Get resource by URI, checking concrete resources first, then templates."""
2237 | uri_str = str(uri)
2238 | logger.debug("Getting resource", extra={"uri": uri_str})
2239 |
2240 | # First check concrete resources
2241 | if resource := self._resources.get(uri_str):
2242 | return resource
2243 |
2244 | # Then check templates
2245 | for template in self._templates.values():
2246 | if params := template.matches(uri_str):
2247 | try:
2248 | return await template.create_resource(uri_str, params)
2249 | except Exception as e:
2250 | raise ValueError(f"Error creating resource from template: {e}")
2251 |
2252 | raise ValueError(f"Unknown resource: {uri}")
2253 |
2254 | def list_resources(self) -> list[Resource]:
2255 | """List all registered resources."""
2256 | logger.debug("Listing resources", extra={"count": len(self._resources)})
2257 | return list(self._resources.values())
2258 |
2259 | def list_templates(self) -> list[ResourceTemplate]:
2260 | """List all registered templates."""
2261 | logger.debug("Listing templates", extra={"count": len(self._templates)})
2262 | return list(self._templates.values())
2263 |
2264 |
2265 | ================================================
2266 | File: /src/fastmcp/resources/__init__.py
2267 | ================================================
2268 | from .base import Resource
2269 | from .types import (
2270 | TextResource,
2271 | BinaryResource,
2272 | FunctionResource,
2273 | FileResource,
2274 | HttpResource,
2275 | DirectoryResource,
2276 | )
2277 | from .templates import ResourceTemplate
2278 | from .resource_manager import ResourceManager
2279 |
2280 | __all__ = [
2281 | "Resource",
2282 | "TextResource",
2283 | "BinaryResource",
2284 | "FunctionResource",
2285 | "FileResource",
2286 | "HttpResource",
2287 | "DirectoryResource",
2288 | "ResourceTemplate",
2289 | "ResourceManager",
2290 | ]
2291 |
2292 |
2293 | ================================================
2294 | File: /src/fastmcp/resources/types.py
2295 | ================================================
2296 | """Concrete resource implementations."""
2297 |
2298 | import asyncio
2299 | import json
2300 | from pathlib import Path
2301 | from typing import Any, Callable, Union
2302 |
2303 | import httpx
2304 | import pydantic.json
2305 | import pydantic_core
2306 | from pydantic import Field, ValidationInfo
2307 |
2308 | from fastmcp.resources.base import Resource
2309 |
2310 |
2311 | class TextResource(Resource):
2312 | """A resource that reads from a string."""
2313 |
2314 | text: str = Field(description="Text content of the resource")
2315 |
2316 | async def read(self) -> str:
2317 | """Read the text content."""
2318 | return self.text
2319 |
2320 |
2321 | class BinaryResource(Resource):
2322 | """A resource that reads from bytes."""
2323 |
2324 | data: bytes = Field(description="Binary content of the resource")
2325 |
2326 | async def read(self) -> bytes:
2327 | """Read the binary content."""
2328 | return self.data
2329 |
2330 |
2331 | class FunctionResource(Resource):
2332 | """A resource that defers data loading by wrapping a function.
2333 |
2334 | The function is only called when the resource is read, allowing for lazy loading
2335 | of potentially expensive data. This is particularly useful when listing resources,
2336 | as the function won't be called until the resource is actually accessed.
2337 |
2338 | The function can return:
2339 | - str for text content (default)
2340 | - bytes for binary content
2341 | - other types will be converted to JSON
2342 | """
2343 |
2344 | fn: Callable[[], Any] = Field(exclude=True)
2345 |
2346 | async def read(self) -> Union[str, bytes]:
2347 | """Read the resource by calling the wrapped function."""
2348 | try:
2349 | result = self.fn()
2350 | if isinstance(result, Resource):
2351 | return await result.read()
2352 | if isinstance(result, bytes):
2353 | return result
2354 | if isinstance(result, str):
2355 | return result
2356 | try:
2357 | return json.dumps(pydantic_core.to_jsonable_python(result))
2358 | except (TypeError, pydantic_core.PydanticSerializationError):
2359 | # If JSON serialization fails, try str()
2360 | return str(result)
2361 | except Exception as e:
2362 | raise ValueError(f"Error reading resource {self.uri}: {e}")
2363 |
2364 |
2365 | class FileResource(Resource):
2366 | """A resource that reads from a file.
2367 |
2368 | Set is_binary=True to read file as binary data instead of text.
2369 | """
2370 |
2371 | path: Path = Field(description="Path to the file")
2372 | is_binary: bool = Field(
2373 | default=False,
2374 | description="Whether to read the file as binary data",
2375 | )
2376 | mime_type: str = Field(
2377 | default="text/plain",
2378 | description="MIME type of the resource content",
2379 | )
2380 |
2381 | @pydantic.field_validator("path")
2382 | @classmethod
2383 | def validate_absolute_path(cls, path: Path) -> Path:
2384 | """Ensure path is absolute."""
2385 | if not path.is_absolute():
2386 | raise ValueError("Path must be absolute")
2387 | return path
2388 |
2389 | @pydantic.field_validator("is_binary")
2390 | @classmethod
2391 | def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool:
2392 | """Set is_binary based on mime_type if not explicitly set."""
2393 | if is_binary:
2394 | return True
2395 | mime_type = info.data.get("mime_type", "text/plain")
2396 | return not mime_type.startswith("text/")
2397 |
2398 | async def read(self) -> Union[str, bytes]:
2399 | """Read the file content."""
2400 | try:
2401 | if self.is_binary:
2402 | return await asyncio.to_thread(self.path.read_bytes)
2403 | return await asyncio.to_thread(self.path.read_text)
2404 | except Exception as e:
2405 | raise ValueError(f"Error reading file {self.path}: {e}")
2406 |
2407 |
2408 | class HttpResource(Resource):
2409 | """A resource that reads from an HTTP endpoint."""
2410 |
2411 | url: str = Field(description="URL to fetch content from")
2412 | mime_type: str | None = Field(
2413 | default="application/json", description="MIME type of the resource content"
2414 | )
2415 |
2416 | async def read(self) -> Union[str, bytes]:
2417 | """Read the HTTP content."""
2418 | async with httpx.AsyncClient() as client:
2419 | response = await client.get(self.url)
2420 | response.raise_for_status()
2421 | return response.text
2422 |
2423 |
2424 | class DirectoryResource(Resource):
2425 | """A resource that lists files in a directory."""
2426 |
2427 | path: Path = Field(description="Path to the directory")
2428 | recursive: bool = Field(
2429 | default=False, description="Whether to list files recursively"
2430 | )
2431 | pattern: str | None = Field(
2432 | default=None, description="Optional glob pattern to filter files"
2433 | )
2434 | mime_type: str | None = Field(
2435 | default="application/json", description="MIME type of the resource content"
2436 | )
2437 |
2438 | @pydantic.field_validator("path")
2439 | @classmethod
2440 | def validate_absolute_path(cls, path: Path) -> Path:
2441 | """Ensure path is absolute."""
2442 | if not path.is_absolute():
2443 | raise ValueError("Path must be absolute")
2444 | return path
2445 |
2446 | def list_files(self) -> list[Path]:
2447 | """List files in the directory."""
2448 | if not self.path.exists():
2449 | raise FileNotFoundError(f"Directory not found: {self.path}")
2450 | if not self.path.is_dir():
2451 | raise NotADirectoryError(f"Not a directory: {self.path}")
2452 |
2453 | try:
2454 | if self.pattern:
2455 | return (
2456 | list(self.path.glob(self.pattern))
2457 | if not self.recursive
2458 | else list(self.path.rglob(self.pattern))
2459 | )
2460 | return (
2461 | list(self.path.glob("*"))
2462 | if not self.recursive
2463 | else list(self.path.rglob("*"))
2464 | )
2465 | except Exception as e:
2466 | raise ValueError(f"Error listing directory {self.path}: {e}")
2467 |
2468 | async def read(self) -> str: # Always returns JSON string
2469 | """Read the directory listing."""
2470 | try:
2471 | files = await asyncio.to_thread(self.list_files)
2472 | file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
2473 | return json.dumps({"files": file_list}, indent=2)
2474 | except Exception as e:
2475 | raise ValueError(f"Error reading directory {self.path}: {e}")
2476 |
2477 |
2478 | ================================================
2479 | File: /src/fastmcp/resources/templates.py
2480 | ================================================
2481 | """Resource template functionality."""
2482 |
2483 | import inspect
2484 | import re
2485 | from typing import Any, Callable, Dict, Optional
2486 |
2487 | from pydantic import BaseModel, Field, TypeAdapter, validate_call
2488 |
2489 | from fastmcp.resources.types import FunctionResource, Resource
2490 |
2491 |
2492 | class ResourceTemplate(BaseModel):
2493 | """A template for dynamically creating resources."""
2494 |
2495 | uri_template: str = Field(
2496 | description="URI template with parameters (e.g. weather://{city}/current)"
2497 | )
2498 | name: str = Field(description="Name of the resource")
2499 | description: str | None = Field(description="Description of what the resource does")
2500 | mime_type: str = Field(
2501 | default="text/plain", description="MIME type of the resource content"
2502 | )
2503 | fn: Callable = Field(exclude=True)
2504 | parameters: dict = Field(description="JSON schema for function parameters")
2505 |
2506 | @classmethod
2507 | def from_function(
2508 | cls,
2509 | fn: Callable,
2510 | uri_template: str,
2511 | name: Optional[str] = None,
2512 | description: Optional[str] = None,
2513 | mime_type: Optional[str] = None,
2514 | ) -> "ResourceTemplate":
2515 | """Create a template from a function."""
2516 | func_name = name or fn.__name__
2517 | if func_name == "":
2518 | raise ValueError("You must provide a name for lambda functions")
2519 |
2520 | # Get schema from TypeAdapter - will fail if function isn't properly typed
2521 | parameters = TypeAdapter(fn).json_schema()
2522 |
2523 | # ensure the arguments are properly cast
2524 | fn = validate_call(fn)
2525 |
2526 | return cls(
2527 | uri_template=uri_template,
2528 | name=func_name,
2529 | description=description or fn.__doc__ or "",
2530 | mime_type=mime_type or "text/plain",
2531 | fn=fn,
2532 | parameters=parameters,
2533 | )
2534 |
2535 | def matches(self, uri: str) -> Optional[Dict[str, Any]]:
2536 | """Check if URI matches template and extract parameters."""
2537 | # Convert template to regex pattern
2538 | pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)")
2539 | match = re.match(f"^{pattern}$", uri)
2540 | if match:
2541 | return match.groupdict()
2542 | return None
2543 |
2544 | async def create_resource(self, uri: str, params: Dict[str, Any]) -> Resource:
2545 | """Create a resource from the template with the given parameters."""
2546 | try:
2547 | # Call function and check if result is a coroutine
2548 | result = self.fn(**params)
2549 | if inspect.iscoroutine(result):
2550 | result = await result
2551 |
2552 | return FunctionResource(
2553 | uri=uri, # type: ignore
2554 | name=self.name,
2555 | description=self.description,
2556 | mime_type=self.mime_type,
2557 | fn=lambda: result, # Capture result in closure
2558 | )
2559 | except Exception as e:
2560 | raise ValueError(f"Error creating resource from template: {e}")
2561 |
2562 |
2563 | ================================================
2564 | File: /src/fastmcp/resources/base.py
2565 | ================================================
2566 | """Base classes and interfaces for FastMCP resources."""
2567 |
2568 | import abc
2569 | from typing import Union, Annotated
2570 |
2571 | from pydantic import (
2572 | AnyUrl,
2573 | BaseModel,
2574 | ConfigDict,
2575 | Field,
2576 | UrlConstraints,
2577 | ValidationInfo,
2578 | field_validator,
2579 | )
2580 |
2581 |
2582 | class Resource(BaseModel, abc.ABC):
2583 | """Base class for all resources."""
2584 |
2585 | model_config = ConfigDict(validate_default=True)
2586 |
2587 | uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field(
2588 | default=..., description="URI of the resource"
2589 | )
2590 | name: str | None = Field(description="Name of the resource", default=None)
2591 | description: str | None = Field(
2592 | description="Description of the resource", default=None
2593 | )
2594 | mime_type: str = Field(
2595 | default="text/plain",
2596 | description="MIME type of the resource content",
2597 | pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
2598 | )
2599 |
2600 | @field_validator("name", mode="before")
2601 | @classmethod
2602 | def set_default_name(cls, name: str | None, info: ValidationInfo) -> str:
2603 | """Set default name from URI if not provided."""
2604 | if name:
2605 | return name
2606 | if uri := info.data.get("uri"):
2607 | return str(uri)
2608 | raise ValueError("Either name or uri must be provided")
2609 |
2610 | @abc.abstractmethod
2611 | async def read(self) -> Union[str, bytes]:
2612 | """Read the resource content."""
2613 | pass
2614 |
2615 |
2616 | ================================================
2617 | File: /src/fastmcp/__init__.py
2618 | ================================================
2619 | """FastMCP - A more ergonomic interface for MCP servers."""
2620 |
2621 | from importlib.metadata import version
2622 | from .server import FastMCP, Context
2623 | from .utilities.types import Image
2624 |
2625 | __version__ = version("fastmcp")
2626 | __all__ = ["FastMCP", "Context", "Image"]
2627 |
2628 |
2629 | ================================================
2630 | File: /src/fastmcp/cli/claude.py
2631 | ================================================
2632 | """Claude app integration utilities."""
2633 |
2634 | import json
2635 | import sys
2636 | from pathlib import Path
2637 | from typing import Optional, Dict
2638 |
2639 | from ..utilities.logging import get_logger
2640 |
2641 | logger = get_logger(__name__)
2642 |
2643 |
2644 | def get_claude_config_path() -> Path | None:
2645 | """Get the Claude config directory based on platform."""
2646 | if sys.platform == "win32":
2647 | path = Path(Path.home(), "AppData", "Roaming", "Claude")
2648 | elif sys.platform == "darwin":
2649 | path = Path(Path.home(), "Library", "Application Support", "Claude")
2650 | else:
2651 | return None
2652 |
2653 | if path.exists():
2654 | return path
2655 | return None
2656 |
2657 |
2658 | def update_claude_config(
2659 | file_spec: str,
2660 | server_name: str,
2661 | *,
2662 | with_editable: Optional[Path] = None,
2663 | with_packages: Optional[list[str]] = None,
2664 | env_vars: Optional[Dict[str, str]] = None,
2665 | ) -> bool:
2666 | """Add or update a FastMCP server in Claude's configuration.
2667 |
2668 | Args:
2669 | file_spec: Path to the server file, optionally with :object suffix
2670 | server_name: Name for the server in Claude's config
2671 | with_editable: Optional directory to install in editable mode
2672 | with_packages: Optional list of additional packages to install
2673 | env_vars: Optional dictionary of environment variables. These are merged with
2674 | any existing variables, with new values taking precedence.
2675 |
2676 | Raises:
2677 | RuntimeError: If Claude Desktop's config directory is not found, indicating
2678 | Claude Desktop may not be installed or properly set up.
2679 | """
2680 | config_dir = get_claude_config_path()
2681 | if not config_dir:
2682 | raise RuntimeError(
2683 | "Claude Desktop config directory not found. Please ensure Claude Desktop "
2684 | "is installed and has been run at least once to initialize its configuration."
2685 | )
2686 |
2687 | config_file = config_dir / "claude_desktop_config.json"
2688 | if not config_file.exists():
2689 | try:
2690 | config_file.write_text("{}")
2691 | except Exception as e:
2692 | logger.error(
2693 | "Failed to create Claude config file",
2694 | extra={
2695 | "error": str(e),
2696 | "config_file": str(config_file),
2697 | },
2698 | )
2699 | return False
2700 |
2701 | try:
2702 | config = json.loads(config_file.read_text())
2703 | if "mcpServers" not in config:
2704 | config["mcpServers"] = {}
2705 |
2706 | # Always preserve existing env vars and merge with new ones
2707 | if (
2708 | server_name in config["mcpServers"]
2709 | and "env" in config["mcpServers"][server_name]
2710 | ):
2711 | existing_env = config["mcpServers"][server_name]["env"]
2712 | if env_vars:
2713 | # New vars take precedence over existing ones
2714 | env_vars = {**existing_env, **env_vars}
2715 | else:
2716 | env_vars = existing_env
2717 |
2718 | # Build uv run command
2719 | args = ["run"]
2720 |
2721 | # Collect all packages in a set to deduplicate
2722 | packages = {"fastmcp"}
2723 | if with_packages:
2724 | packages.update(pkg for pkg in with_packages if pkg)
2725 |
2726 | # Add all packages with --with
2727 | for pkg in sorted(packages):
2728 | args.extend(["--with", pkg])
2729 |
2730 | if with_editable:
2731 | args.extend(["--with-editable", str(with_editable)])
2732 |
2733 | # Convert file path to absolute before adding to command
2734 | # Split off any :object suffix first
2735 | if ":" in file_spec:
2736 | file_path, server_object = file_spec.rsplit(":", 1)
2737 | file_spec = f"{Path(file_path).resolve()}:{server_object}"
2738 | else:
2739 | file_spec = str(Path(file_spec).resolve())
2740 |
2741 | # Add fastmcp run command
2742 | args.extend(["fastmcp", "run", file_spec])
2743 |
2744 | server_config = {
2745 | "command": "uv",
2746 | "args": args,
2747 | }
2748 |
2749 | # Add environment variables if specified
2750 | if env_vars:
2751 | server_config["env"] = env_vars
2752 |
2753 | config["mcpServers"][server_name] = server_config
2754 |
2755 | config_file.write_text(json.dumps(config, indent=2))
2756 | logger.info(
2757 | f"Added server '{server_name}' to Claude config",
2758 | extra={"config_file": str(config_file)},
2759 | )
2760 | return True
2761 | except Exception as e:
2762 | logger.error(
2763 | "Failed to update Claude config",
2764 | extra={
2765 | "error": str(e),
2766 | "config_file": str(config_file),
2767 | },
2768 | )
2769 | return False
2770 |
2771 |
2772 | ================================================
2773 | File: /src/fastmcp/cli/__init__.py
2774 | ================================================
2775 | """FastMCP CLI package."""
2776 |
2777 | from .cli import app
2778 |
2779 |
2780 | if __name__ == "__main__":
2781 | app()
2782 |
2783 |
2784 | ================================================
2785 | File: /src/fastmcp/cli/cli.py
2786 | ================================================
2787 | """FastMCP CLI tools."""
2788 |
2789 | import importlib.metadata
2790 | import importlib.util
2791 | import os
2792 | import subprocess
2793 | import sys
2794 | from pathlib import Path
2795 | from typing import Dict, Optional, Tuple
2796 |
2797 | import dotenv
2798 | import typer
2799 | from typing_extensions import Annotated
2800 |
2801 | from fastmcp.cli import claude
2802 | from fastmcp.utilities.logging import get_logger
2803 |
2804 | logger = get_logger("cli")
2805 |
2806 | app = typer.Typer(
2807 | name="fastmcp",
2808 | help="FastMCP development tools",
2809 | add_completion=False,
2810 | no_args_is_help=True, # Show help if no args provided
2811 | )
2812 |
2813 |
2814 | def _get_npx_command():
2815 | """Get the correct npx command for the current platform."""
2816 | if sys.platform == "win32":
2817 | # Try both npx.cmd and npx.exe on Windows
2818 | for cmd in ["npx.cmd", "npx.exe", "npx"]:
2819 | try:
2820 | subprocess.run(
2821 | [cmd, "--version"], check=True, capture_output=True, shell=True
2822 | )
2823 | return cmd
2824 | except subprocess.CalledProcessError:
2825 | continue
2826 | return None
2827 | return "npx" # On Unix-like systems, just use npx
2828 |
2829 |
2830 | def _parse_env_var(env_var: str) -> Tuple[str, str]:
2831 | """Parse environment variable string in format KEY=VALUE."""
2832 | if "=" not in env_var:
2833 | logger.error(
2834 | f"Invalid environment variable format: {env_var}. Must be KEY=VALUE"
2835 | )
2836 | sys.exit(1)
2837 | key, value = env_var.split("=", 1)
2838 | return key.strip(), value.strip()
2839 |
2840 |
2841 | def _build_uv_command(
2842 | file_spec: str,
2843 | with_editable: Optional[Path] = None,
2844 | with_packages: Optional[list[str]] = None,
2845 | ) -> list[str]:
2846 | """Build the uv run command that runs a FastMCP server through fastmcp run."""
2847 | cmd = ["uv"]
2848 |
2849 | cmd.extend(["run", "--with", "fastmcp"])
2850 |
2851 | if with_editable:
2852 | cmd.extend(["--with-editable", str(with_editable)])
2853 |
2854 | if with_packages:
2855 | for pkg in with_packages:
2856 | if pkg:
2857 | cmd.extend(["--with", pkg])
2858 |
2859 | # Add fastmcp run command
2860 | cmd.extend(["fastmcp", "run", file_spec])
2861 | return cmd
2862 |
2863 |
2864 | def _parse_file_path(file_spec: str) -> Tuple[Path, Optional[str]]:
2865 | """Parse a file path that may include a server object specification.
2866 |
2867 | Args:
2868 | file_spec: Path to file, optionally with :object suffix
2869 |
2870 | Returns:
2871 | Tuple of (file_path, server_object)
2872 | """
2873 | # First check if we have a Windows path (e.g., C:\...)
2874 | has_windows_drive = len(file_spec) > 1 and file_spec[1] == ":"
2875 |
2876 | # Split on the last colon, but only if it's not part of the Windows drive letter
2877 | # and there's actually another colon in the string after the drive letter
2878 | if ":" in (file_spec[2:] if has_windows_drive else file_spec):
2879 | file_str, server_object = file_spec.rsplit(":", 1)
2880 | else:
2881 | file_str, server_object = file_spec, None
2882 |
2883 | # Resolve the file path
2884 | file_path = Path(file_str).expanduser().resolve()
2885 | if not file_path.exists():
2886 | logger.error(f"File not found: {file_path}")
2887 | sys.exit(1)
2888 | if not file_path.is_file():
2889 | logger.error(f"Not a file: {file_path}")
2890 | sys.exit(1)
2891 |
2892 | return file_path, server_object
2893 |
2894 |
2895 | def _import_server(file: Path, server_object: Optional[str] = None):
2896 | """Import a FastMCP server from a file.
2897 |
2898 | Args:
2899 | file: Path to the file
2900 | server_object: Optional object name in format "module:object" or just "object"
2901 |
2902 | Returns:
2903 | The server object
2904 | """
2905 | # Add parent directory to Python path so imports can be resolved
2906 | file_dir = str(file.parent)
2907 | if file_dir not in sys.path:
2908 | sys.path.insert(0, file_dir)
2909 |
2910 | # Import the module
2911 | spec = importlib.util.spec_from_file_location("server_module", file)
2912 | if not spec or not spec.loader:
2913 | logger.error("Could not load module", extra={"file": str(file)})
2914 | sys.exit(1)
2915 |
2916 | module = importlib.util.module_from_spec(spec)
2917 | spec.loader.exec_module(module)
2918 |
2919 | # If no object specified, try common server names
2920 | if not server_object:
2921 | # Look for the most common server object names
2922 | for name in ["mcp", "server", "app"]:
2923 | if hasattr(module, name):
2924 | return getattr(module, name)
2925 |
2926 | logger.error(
2927 | f"No server object found in {file}. Please either:\n"
2928 | "1. Use a standard variable name (mcp, server, or app)\n"
2929 | "2. Specify the object name with file:object syntax",
2930 | extra={"file": str(file)},
2931 | )
2932 | sys.exit(1)
2933 |
2934 | # Handle module:object syntax
2935 | if ":" in server_object:
2936 | module_name, object_name = server_object.split(":", 1)
2937 | try:
2938 | server_module = importlib.import_module(module_name)
2939 | server = getattr(server_module, object_name, None)
2940 | except ImportError:
2941 | logger.error(
2942 | f"Could not import module '{module_name}'",
2943 | extra={"file": str(file)},
2944 | )
2945 | sys.exit(1)
2946 | else:
2947 | # Just object name
2948 | server = getattr(module, server_object, None)
2949 |
2950 | if server is None:
2951 | logger.error(
2952 | f"Server object '{server_object}' not found",
2953 | extra={"file": str(file)},
2954 | )
2955 | sys.exit(1)
2956 |
2957 | return server
2958 |
2959 |
2960 | @app.command()
2961 | def version() -> None:
2962 | """Show the FastMCP version."""
2963 | try:
2964 | version = importlib.metadata.version("fastmcp")
2965 | print(f"FastMCP version {version}")
2966 | except importlib.metadata.PackageNotFoundError:
2967 | print("FastMCP version unknown (package not installed)")
2968 | sys.exit(1)
2969 |
2970 |
2971 | @app.command()
2972 | def dev(
2973 | file_spec: str = typer.Argument(
2974 | ...,
2975 | help="Python file to run, optionally with :object suffix",
2976 | ),
2977 | with_editable: Annotated[
2978 | Optional[Path],
2979 | typer.Option(
2980 | "--with-editable",
2981 | "-e",
2982 | help="Directory containing pyproject.toml to install in editable mode",
2983 | exists=True,
2984 | file_okay=False,
2985 | resolve_path=True,
2986 | ),
2987 | ] = None,
2988 | with_packages: Annotated[
2989 | list[str],
2990 | typer.Option(
2991 | "--with",
2992 | help="Additional packages to install",
2993 | ),
2994 | ] = [],
2995 | ) -> None:
2996 | """Run a FastMCP server with the MCP Inspector."""
2997 | file, server_object = _parse_file_path(file_spec)
2998 |
2999 | logger.debug(
3000 | "Starting dev server",
3001 | extra={
3002 | "file": str(file),
3003 | "server_object": server_object,
3004 | "with_editable": str(with_editable) if with_editable else None,
3005 | "with_packages": with_packages,
3006 | },
3007 | )
3008 |
3009 | try:
3010 | # Import server to get dependencies
3011 | server = _import_server(file, server_object)
3012 | if hasattr(server, "dependencies"):
3013 | with_packages = list(set(with_packages + server.dependencies))
3014 |
3015 | uv_cmd = _build_uv_command(file_spec, with_editable, with_packages)
3016 |
3017 | # Get the correct npx command
3018 | npx_cmd = _get_npx_command()
3019 | if not npx_cmd:
3020 | logger.error(
3021 | "npx not found. Please ensure Node.js and npm are properly installed "
3022 | "and added to your system PATH."
3023 | )
3024 | sys.exit(1)
3025 |
3026 | # Run the MCP Inspector command with shell=True on Windows
3027 | shell = sys.platform == "win32"
3028 | process = subprocess.run(
3029 | [npx_cmd, "@modelcontextprotocol/inspector"] + uv_cmd,
3030 | check=True,
3031 | shell=shell,
3032 | env=dict(os.environ.items()), # Convert to list of tuples for env update
3033 | )
3034 | sys.exit(process.returncode)
3035 | except subprocess.CalledProcessError as e:
3036 | logger.error(
3037 | "Dev server failed",
3038 | extra={
3039 | "file": str(file),
3040 | "error": str(e),
3041 | "returncode": e.returncode,
3042 | },
3043 | )
3044 | sys.exit(e.returncode)
3045 | except FileNotFoundError:
3046 | logger.error(
3047 | "npx not found. Please ensure Node.js and npm are properly installed "
3048 | "and added to your system PATH. You may need to restart your terminal "
3049 | "after installation.",
3050 | extra={"file": str(file)},
3051 | )
3052 | sys.exit(1)
3053 |
3054 |
3055 | @app.command()
3056 | def run(
3057 | file_spec: str = typer.Argument(
3058 | ...,
3059 | help="Python file to run, optionally with :object suffix",
3060 | ),
3061 | transport: Annotated[
3062 | Optional[str],
3063 | typer.Option(
3064 | "--transport",
3065 | "-t",
3066 | help="Transport protocol to use (stdio or sse)",
3067 | ),
3068 | ] = None,
3069 | ) -> None:
3070 | """Run a FastMCP server.
3071 |
3072 | The server can be specified in two ways:
3073 | 1. Module approach: server.py - runs the module directly, expecting a server.run() call
3074 | 2. Import approach: server.py:app - imports and runs the specified server object
3075 |
3076 | Note: This command runs the server directly. You are responsible for ensuring
3077 | all dependencies are available. For dependency management, use fastmcp install
3078 | or fastmcp dev instead.
3079 | """
3080 | file, server_object = _parse_file_path(file_spec)
3081 |
3082 | logger.debug(
3083 | "Running server",
3084 | extra={
3085 | "file": str(file),
3086 | "server_object": server_object,
3087 | "transport": transport,
3088 | },
3089 | )
3090 |
3091 | try:
3092 | # Import and get server object
3093 | server = _import_server(file, server_object)
3094 |
3095 | # Run the server
3096 | kwargs = {}
3097 | if transport:
3098 | kwargs["transport"] = transport
3099 |
3100 | server.run(**kwargs)
3101 |
3102 | except Exception as e:
3103 | logger.error(
3104 | f"Failed to run server: {e}",
3105 | extra={
3106 | "file": str(file),
3107 | "error": str(e),
3108 | },
3109 | )
3110 | sys.exit(1)
3111 |
3112 |
3113 | @app.command()
3114 | def install(
3115 | file_spec: str = typer.Argument(
3116 | ...,
3117 | help="Python file to run, optionally with :object suffix",
3118 | ),
3119 | server_name: Annotated[
3120 | Optional[str],
3121 | typer.Option(
3122 | "--name",
3123 | "-n",
3124 | help="Custom name for the server (defaults to server's name attribute or file name)",
3125 | ),
3126 | ] = None,
3127 | with_editable: Annotated[
3128 | Optional[Path],
3129 | typer.Option(
3130 | "--with-editable",
3131 | "-e",
3132 | help="Directory containing pyproject.toml to install in editable mode",
3133 | exists=True,
3134 | file_okay=False,
3135 | resolve_path=True,
3136 | ),
3137 | ] = None,
3138 | with_packages: Annotated[
3139 | list[str],
3140 | typer.Option(
3141 | "--with",
3142 | help="Additional packages to install",
3143 | ),
3144 | ] = [],
3145 | env_vars: Annotated[
3146 | list[str],
3147 | typer.Option(
3148 | "--env-var",
3149 | "-e",
3150 | help="Environment variables in KEY=VALUE format",
3151 | ),
3152 | ] = [],
3153 | env_file: Annotated[
3154 | Optional[Path],
3155 | typer.Option(
3156 | "--env-file",
3157 | "-f",
3158 | help="Load environment variables from a .env file",
3159 | exists=True,
3160 | file_okay=True,
3161 | dir_okay=False,
3162 | resolve_path=True,
3163 | ),
3164 | ] = None,
3165 | ) -> None:
3166 | """Install a FastMCP server in the Claude desktop app.
3167 |
3168 | Environment variables are preserved once added and only updated if new values
3169 | are explicitly provided.
3170 | """
3171 | file, server_object = _parse_file_path(file_spec)
3172 |
3173 | logger.debug(
3174 | "Installing server",
3175 | extra={
3176 | "file": str(file),
3177 | "server_name": server_name,
3178 | "server_object": server_object,
3179 | "with_editable": str(with_editable) if with_editable else None,
3180 | "with_packages": with_packages,
3181 | },
3182 | )
3183 |
3184 | if not claude.get_claude_config_path():
3185 | logger.error("Claude app not found")
3186 | sys.exit(1)
3187 |
3188 | # Try to import server to get its name, but fall back to file name if dependencies missing
3189 | name = server_name
3190 | server = None
3191 | if not name:
3192 | try:
3193 | server = _import_server(file, server_object)
3194 | name = server.name
3195 | except (ImportError, ModuleNotFoundError) as e:
3196 | logger.debug(
3197 | "Could not import server (likely missing dependencies), using file name",
3198 | extra={"error": str(e)},
3199 | )
3200 | name = file.stem
3201 |
3202 | # Get server dependencies if available
3203 | server_dependencies = getattr(server, "dependencies", []) if server else []
3204 | if server_dependencies:
3205 | with_packages = list(set(with_packages + server_dependencies))
3206 |
3207 | # Process environment variables if provided
3208 | env_dict: Optional[Dict[str, str]] = None
3209 | if env_file or env_vars:
3210 | env_dict = {}
3211 | # Load from .env file if specified
3212 | if env_file:
3213 | try:
3214 | env_dict |= {
3215 | k: v
3216 | for k, v in dotenv.dotenv_values(env_file).items()
3217 | if v is not None
3218 | }
3219 | except Exception as e:
3220 | logger.error(f"Failed to load .env file: {e}")
3221 | sys.exit(1)
3222 |
3223 | # Add command line environment variables
3224 | for env_var in env_vars:
3225 | key, value = _parse_env_var(env_var)
3226 | env_dict[key] = value
3227 |
3228 | if claude.update_claude_config(
3229 | file_spec,
3230 | name,
3231 | with_editable=with_editable,
3232 | with_packages=with_packages,
3233 | env_vars=env_dict,
3234 | ):
3235 | logger.info(f"Successfully installed {name} in Claude app")
3236 | else:
3237 | logger.error(f"Failed to install {name} in Claude app")
3238 | sys.exit(1)
3239 |
3240 |
3241 | ================================================
3242 | File: /src/fastmcp/utilities/logging.py
3243 | ================================================
3244 | """Logging utilities for FastMCP."""
3245 |
3246 | import logging
3247 | from typing import Literal
3248 |
3249 | from rich.console import Console
3250 | from rich.logging import RichHandler
3251 |
3252 |
3253 | def get_logger(name: str) -> logging.Logger:
3254 | """Get a logger nested under FastMCP namespace.
3255 |
3256 | Args:
3257 | name: the name of the logger, which will be prefixed with 'FastMCP.'
3258 |
3259 | Returns:
3260 | a configured logger instance
3261 | """
3262 | return logging.getLogger(f"FastMCP.{name}")
3263 |
3264 |
3265 | def configure_logging(
3266 | level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO",
3267 | ) -> None:
3268 | """Configure logging for FastMCP.
3269 |
3270 | Args:
3271 | level: the log level to use
3272 | """
3273 | logging.basicConfig(
3274 | level=level,
3275 | format="%(message)s",
3276 | handlers=[RichHandler(console=Console(stderr=True), rich_tracebacks=True)],
3277 | )
3278 |
3279 |
3280 | ================================================
3281 | File: /src/fastmcp/utilities/func_metadata.py
3282 | ================================================
3283 | import inspect
3284 | from collections.abc import Callable, Sequence, Awaitable
3285 | from typing import (
3286 | Annotated,
3287 | Any,
3288 | Dict,
3289 | ForwardRef,
3290 | )
3291 | from pydantic import Field
3292 | from fastmcp.exceptions import InvalidSignature
3293 | from pydantic._internal._typing_extra import eval_type_lenient
3294 | import json
3295 | from pydantic import BaseModel
3296 | from pydantic.fields import FieldInfo
3297 | from pydantic import ConfigDict, create_model
3298 | from pydantic import WithJsonSchema
3299 | from pydantic_core import PydanticUndefined
3300 | from fastmcp.utilities.logging import get_logger
3301 |
3302 |
3303 | logger = get_logger(__name__)
3304 |
3305 |
3306 | class ArgModelBase(BaseModel):
3307 | """A model representing the arguments to a function."""
3308 |
3309 | def model_dump_one_level(self) -> dict[str, Any]:
3310 | """Return a dict of the model's fields, one level deep.
3311 |
3312 | That is, sub-models etc are not dumped - they are kept as pydantic models.
3313 | """
3314 | kwargs: dict[str, Any] = {}
3315 | for field_name in self.model_fields.keys():
3316 | kwargs[field_name] = getattr(self, field_name)
3317 | return kwargs
3318 |
3319 | model_config = ConfigDict(
3320 | arbitrary_types_allowed=True,
3321 | )
3322 |
3323 |
3324 | class FuncMetadata(BaseModel):
3325 | arg_model: Annotated[type[ArgModelBase], WithJsonSchema(None)]
3326 | # We can add things in the future like
3327 | # - Maybe some args are excluded from attempting to parse from JSON
3328 | # - Maybe some args are special (like context) for dependency injection
3329 |
3330 | async def call_fn_with_arg_validation(
3331 | self,
3332 | fn: Callable[..., Any] | Awaitable[Any],
3333 | fn_is_async: bool,
3334 | arguments_to_validate: dict[str, Any],
3335 | arguments_to_pass_directly: dict[str, Any] | None,
3336 | ) -> Any:
3337 | """Call the given function with arguments validated and injected.
3338 |
3339 | Arguments are first attempted to be parsed from JSON, then validated against
3340 | the argument model, before being passed to the function.
3341 | """
3342 | arguments_pre_parsed = self.pre_parse_json(arguments_to_validate)
3343 | arguments_parsed_model = self.arg_model.model_validate(arguments_pre_parsed)
3344 | arguments_parsed_dict = arguments_parsed_model.model_dump_one_level()
3345 |
3346 | arguments_parsed_dict |= arguments_to_pass_directly or {}
3347 |
3348 | if fn_is_async:
3349 | if isinstance(fn, Awaitable):
3350 | return await fn
3351 | return await fn(**arguments_parsed_dict)
3352 | if isinstance(fn, Callable):
3353 | return fn(**arguments_parsed_dict)
3354 | raise TypeError("fn must be either Callable or Awaitable")
3355 |
3356 | def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
3357 | """Pre-parse data from JSON.
3358 |
3359 | Return a dict with same keys as input but with values parsed from JSON
3360 | if appropriate.
3361 |
3362 | This is to handle cases like `["a", "b", "c"]` being passed in as JSON inside
3363 | a string rather than an actual list. Claude desktop is prone to this - in fact
3364 | it seems incapable of NOT doing this. For sub-models, it tends to pass
3365 | dicts (JSON objects) as JSON strings, which can be pre-parsed here.
3366 | """
3367 | new_data = data.copy() # Shallow copy
3368 | for field_name, field_info in self.arg_model.model_fields.items():
3369 | if field_name not in data.keys():
3370 | continue
3371 | if isinstance(data[field_name], str):
3372 | try:
3373 | pre_parsed = json.loads(data[field_name])
3374 | except json.JSONDecodeError:
3375 | continue # Not JSON - skip
3376 | if isinstance(pre_parsed, (str, int, float)):
3377 | # This is likely that the raw value is e.g. `"hello"` which we
3378 | # Should really be parsed as '"hello"' in Python - but if we parse
3379 | # it as JSON it'll turn into just 'hello'. So we skip it.
3380 | continue
3381 | new_data[field_name] = pre_parsed
3382 | assert new_data.keys() == data.keys()
3383 | return new_data
3384 |
3385 | model_config = ConfigDict(
3386 | arbitrary_types_allowed=True,
3387 | )
3388 |
3389 |
3390 | def func_metadata(func: Callable, skip_names: Sequence[str] = ()) -> FuncMetadata:
3391 | """Given a function, return metadata including a pydantic model representing its signature.
3392 |
3393 | The use case for this is
3394 | ```
3395 | meta = func_to_pyd(func)
3396 | validated_args = meta.arg_model.model_validate(some_raw_data_dict)
3397 | return func(**validated_args.model_dump_one_level())
3398 | ```
3399 |
3400 | **critically** it also provides pre-parse helper to attempt to parse things from JSON.
3401 |
3402 | Args:
3403 | func: The function to convert to a pydantic model
3404 | skip_names: A list of parameter names to skip. These will not be included in
3405 | the model.
3406 | Returns:
3407 | A pydantic model representing the function's signature.
3408 | """
3409 | sig = _get_typed_signature(func)
3410 | params = sig.parameters
3411 | dynamic_pydantic_model_params: dict[str, Any] = {}
3412 | globalns = getattr(func, "__globals__", {})
3413 | for param in params.values():
3414 | if param.name.startswith("_"):
3415 | raise InvalidSignature(
3416 | f"Parameter {param.name} of {func.__name__} may not start with an underscore"
3417 | )
3418 | if param.name in skip_names:
3419 | continue
3420 | annotation = param.annotation
3421 |
3422 | # `x: None` / `x: None = None`
3423 | if annotation is None:
3424 | annotation = Annotated[
3425 | None,
3426 | Field(
3427 | default=param.default
3428 | if param.default is not inspect.Parameter.empty
3429 | else PydanticUndefined
3430 | ),
3431 | ]
3432 |
3433 | # Untyped field
3434 | if annotation is inspect.Parameter.empty:
3435 | annotation = Annotated[
3436 | Any,
3437 | Field(),
3438 | # 🤷
3439 | WithJsonSchema({"title": param.name, "type": "string"}),
3440 | ]
3441 |
3442 | field_info = FieldInfo.from_annotated_attribute(
3443 | _get_typed_annotation(annotation, globalns),
3444 | param.default
3445 | if param.default is not inspect.Parameter.empty
3446 | else PydanticUndefined,
3447 | )
3448 | dynamic_pydantic_model_params[param.name] = (field_info.annotation, field_info)
3449 | continue
3450 |
3451 | arguments_model = create_model(
3452 | f"{func.__name__}Arguments",
3453 | **dynamic_pydantic_model_params,
3454 | __base__=ArgModelBase,
3455 | )
3456 | resp = FuncMetadata(arg_model=arguments_model)
3457 | return resp
3458 |
3459 |
3460 | def _get_typed_annotation(annotation: Any, globalns: Dict[str, Any]) -> Any:
3461 | if isinstance(annotation, str):
3462 | annotation = ForwardRef(annotation)
3463 | annotation = eval_type_lenient(annotation, globalns, globalns)
3464 |
3465 | return annotation
3466 |
3467 |
3468 | def _get_typed_signature(call: Callable[..., Any]) -> inspect.Signature:
3469 | """Get function signature while evaluating forward references"""
3470 | signature = inspect.signature(call)
3471 | globalns = getattr(call, "__globals__", {})
3472 | typed_params = [
3473 | inspect.Parameter(
3474 | name=param.name,
3475 | kind=param.kind,
3476 | default=param.default,
3477 | annotation=_get_typed_annotation(param.annotation, globalns),
3478 | )
3479 | for param in signature.parameters.values()
3480 | ]
3481 | typed_signature = inspect.Signature(typed_params)
3482 | return typed_signature
3483 |
3484 |
3485 | ================================================
3486 | File: /src/fastmcp/utilities/__init__.py
3487 | ================================================
3488 | """FastMCP utility modules."""
3489 |
3490 |
3491 | ================================================
3492 | File: /src/fastmcp/utilities/types.py
3493 | ================================================
3494 | """Common types used across FastMCP."""
3495 |
3496 | import base64
3497 | from pathlib import Path
3498 | from typing import Optional, Union
3499 |
3500 | from mcp.types import ImageContent
3501 |
3502 |
3503 | class Image:
3504 | """Helper class for returning images from tools."""
3505 |
3506 | def __init__(
3507 | self,
3508 | path: Optional[Union[str, Path]] = None,
3509 | data: Optional[bytes] = None,
3510 | format: Optional[str] = None,
3511 | ):
3512 | if path is None and data is None:
3513 | raise ValueError("Either path or data must be provided")
3514 | if path is not None and data is not None:
3515 | raise ValueError("Only one of path or data can be provided")
3516 |
3517 | self.path = Path(path) if path else None
3518 | self.data = data
3519 | self._format = format
3520 | self._mime_type = self._get_mime_type()
3521 |
3522 | def _get_mime_type(self) -> str:
3523 | """Get MIME type from format or guess from file extension."""
3524 | if self._format:
3525 | return f"image/{self._format.lower()}"
3526 |
3527 | if self.path:
3528 | suffix = self.path.suffix.lower()
3529 | return {
3530 | ".png": "image/png",
3531 | ".jpg": "image/jpeg",
3532 | ".jpeg": "image/jpeg",
3533 | ".gif": "image/gif",
3534 | ".webp": "image/webp",
3535 | }.get(suffix, "application/octet-stream")
3536 | return "image/png" # default for raw binary data
3537 |
3538 | def to_image_content(self) -> ImageContent:
3539 | """Convert to MCP ImageContent."""
3540 | if self.path:
3541 | with open(self.path, "rb") as f:
3542 | data = base64.b64encode(f.read()).decode()
3543 | elif self.data is not None:
3544 | data = base64.b64encode(self.data).decode()
3545 | else:
3546 | raise ValueError("No image data available")
3547 |
3548 | return ImageContent(type="image", data=data, mimeType=self._mime_type)
3549 |
3550 |
3551 | ================================================
3552 | File: /src/fastmcp/prompts/prompt_manager.py
3553 | ================================================
3554 | """Prompt management functionality."""
3555 |
3556 | from typing import Dict, Optional
3557 |
3558 |
3559 | from fastmcp.prompts.base import Prompt
3560 | from fastmcp.utilities.logging import get_logger
3561 |
3562 | logger = get_logger(__name__)
3563 |
3564 |
3565 | class PromptManager:
3566 | """Manages FastMCP prompts."""
3567 |
3568 | def __init__(self, warn_on_duplicate_prompts: bool = True):
3569 | self._prompts: Dict[str, Prompt] = {}
3570 | self.warn_on_duplicate_prompts = warn_on_duplicate_prompts
3571 |
3572 | def add_prompt(self, prompt: Prompt) -> Prompt:
3573 | """Add a prompt to the manager."""
3574 | logger.debug(f"Adding prompt: {prompt.name}")
3575 | existing = self._prompts.get(prompt.name)
3576 | if existing:
3577 | if self.warn_on_duplicate_prompts:
3578 | logger.warning(f"Prompt already exists: {prompt.name}")
3579 | return existing
3580 | self._prompts[prompt.name] = prompt
3581 | return prompt
3582 |
3583 | def get_prompt(self, name: str) -> Optional[Prompt]:
3584 | """Get prompt by name."""
3585 | return self._prompts.get(name)
3586 |
3587 | def list_prompts(self) -> list[Prompt]:
3588 | """List all registered prompts."""
3589 | return list(self._prompts.values())
3590 |
3591 |
3592 | ================================================
3593 | File: /src/fastmcp/prompts/__init__.py
3594 | ================================================
3595 | from .base import Prompt
3596 | from .manager import PromptManager
3597 |
3598 | __all__ = ["Prompt", "PromptManager"]
3599 |
3600 |
3601 | ================================================
3602 | File: /src/fastmcp/prompts/manager.py
3603 | ================================================
3604 | """Prompt management functionality."""
3605 |
3606 | from typing import Any, Dict, Optional
3607 |
3608 | from fastmcp.prompts.base import Message, Prompt
3609 | from fastmcp.utilities.logging import get_logger
3610 |
3611 | logger = get_logger(__name__)
3612 |
3613 |
3614 | class PromptManager:
3615 | """Manages FastMCP prompts."""
3616 |
3617 | def __init__(self, warn_on_duplicate_prompts: bool = True):
3618 | self._prompts: Dict[str, Prompt] = {}
3619 | self.warn_on_duplicate_prompts = warn_on_duplicate_prompts
3620 |
3621 | def get_prompt(self, name: str) -> Optional[Prompt]:
3622 | """Get prompt by name."""
3623 | return self._prompts.get(name)
3624 |
3625 | def list_prompts(self) -> list[Prompt]:
3626 | """List all registered prompts."""
3627 | return list(self._prompts.values())
3628 |
3629 | def add_prompt(
3630 | self,
3631 | prompt: Prompt,
3632 | ) -> Prompt:
3633 | """Add a prompt to the manager."""
3634 |
3635 | # Check for duplicates
3636 | existing = self._prompts.get(prompt.name)
3637 | if existing:
3638 | if self.warn_on_duplicate_prompts:
3639 | logger.warning(f"Prompt already exists: {prompt.name}")
3640 | return existing
3641 |
3642 | self._prompts[prompt.name] = prompt
3643 | return prompt
3644 |
3645 | async def render_prompt(
3646 | self, name: str, arguments: Optional[Dict[str, Any]] = None
3647 | ) -> list[Message]:
3648 | """Render a prompt by name with arguments."""
3649 | prompt = self.get_prompt(name)
3650 | if not prompt:
3651 | raise ValueError(f"Unknown prompt: {name}")
3652 |
3653 | return await prompt.render(arguments)
3654 |
3655 |
3656 | ================================================
3657 | File: /src/fastmcp/prompts/base.py
3658 | ================================================
3659 | """Base classes for FastMCP prompts."""
3660 |
3661 | import json
3662 | from typing import Any, Callable, Dict, Literal, Optional, Sequence, Awaitable
3663 | import inspect
3664 |
3665 | from pydantic import BaseModel, Field, TypeAdapter, validate_call
3666 | from mcp.types import TextContent, ImageContent, EmbeddedResource
3667 | import pydantic_core
3668 |
3669 | CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource
3670 |
3671 |
3672 | class Message(BaseModel):
3673 | """Base class for all prompt messages."""
3674 |
3675 | role: Literal["user", "assistant"]
3676 | content: CONTENT_TYPES
3677 |
3678 | def __init__(self, content: str | CONTENT_TYPES, **kwargs):
3679 | if isinstance(content, str):
3680 | content = TextContent(type="text", text=content)
3681 | super().__init__(content=content, **kwargs)
3682 |
3683 |
3684 | class UserMessage(Message):
3685 | """A message from the user."""
3686 |
3687 | role: Literal["user"] = "user"
3688 |
3689 | def __init__(self, content: str | CONTENT_TYPES, **kwargs):
3690 | super().__init__(content=content, **kwargs)
3691 |
3692 |
3693 | class AssistantMessage(Message):
3694 | """A message from the assistant."""
3695 |
3696 | role: Literal["assistant"] = "assistant"
3697 |
3698 | def __init__(self, content: str | CONTENT_TYPES, **kwargs):
3699 | super().__init__(content=content, **kwargs)
3700 |
3701 |
3702 | message_validator = TypeAdapter(UserMessage | AssistantMessage)
3703 |
3704 | SyncPromptResult = (
3705 | str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]]
3706 | )
3707 | PromptResult = SyncPromptResult | Awaitable[SyncPromptResult]
3708 |
3709 |
3710 | class PromptArgument(BaseModel):
3711 | """An argument that can be passed to a prompt."""
3712 |
3713 | name: str = Field(description="Name of the argument")
3714 | description: str | None = Field(
3715 | None, description="Description of what the argument does"
3716 | )
3717 | required: bool = Field(
3718 | default=False, description="Whether the argument is required"
3719 | )
3720 |
3721 |
3722 | class Prompt(BaseModel):
3723 | """A prompt template that can be rendered with parameters."""
3724 |
3725 | name: str = Field(description="Name of the prompt")
3726 | description: str | None = Field(
3727 | None, description="Description of what the prompt does"
3728 | )
3729 | arguments: list[PromptArgument] | None = Field(
3730 | None, description="Arguments that can be passed to the prompt"
3731 | )
3732 | fn: Callable = Field(exclude=True)
3733 |
3734 | @classmethod
3735 | def from_function(
3736 | cls,
3737 | fn: Callable[..., PromptResult],
3738 | name: Optional[str] = None,
3739 | description: Optional[str] = None,
3740 | ) -> "Prompt":
3741 | """Create a Prompt from a function.
3742 |
3743 | The function can return:
3744 | - A string (converted to a message)
3745 | - A Message object
3746 | - A dict (converted to a message)
3747 | - A sequence of any of the above
3748 | """
3749 | func_name = name or fn.__name__
3750 |
3751 | if func_name == "":
3752 | raise ValueError("You must provide a name for lambda functions")
3753 |
3754 | # Get schema from TypeAdapter - will fail if function isn't properly typed
3755 | parameters = TypeAdapter(fn).json_schema()
3756 |
3757 | # Convert parameters to PromptArguments
3758 | arguments = []
3759 | if "properties" in parameters:
3760 | for param_name, param in parameters["properties"].items():
3761 | required = param_name in parameters.get("required", [])
3762 | arguments.append(
3763 | PromptArgument(
3764 | name=param_name,
3765 | description=param.get("description"),
3766 | required=required,
3767 | )
3768 | )
3769 |
3770 | # ensure the arguments are properly cast
3771 | fn = validate_call(fn)
3772 |
3773 | return cls(
3774 | name=func_name,
3775 | description=description or fn.__doc__ or "",
3776 | arguments=arguments,
3777 | fn=fn,
3778 | )
3779 |
3780 | async def render(self, arguments: Optional[Dict[str, Any]] = None) -> list[Message]:
3781 | """Render the prompt with arguments."""
3782 | # Validate required arguments
3783 | if self.arguments:
3784 | required = {arg.name for arg in self.arguments if arg.required}
3785 | provided = set(arguments or {})
3786 | missing = required - provided
3787 | if missing:
3788 | raise ValueError(f"Missing required arguments: {missing}")
3789 |
3790 | try:
3791 | # Call function and check if result is a coroutine
3792 | result = self.fn(**(arguments or {}))
3793 | if inspect.iscoroutine(result):
3794 | result = await result
3795 |
3796 | # Validate messages
3797 | if not isinstance(result, (list, tuple)):
3798 | result = [result]
3799 |
3800 | # Convert result to messages
3801 | messages = []
3802 | for msg in result:
3803 | try:
3804 | if isinstance(msg, Message):
3805 | messages.append(msg)
3806 | elif isinstance(msg, dict):
3807 | msg = message_validator.validate_python(msg)
3808 | messages.append(msg)
3809 | elif isinstance(msg, str):
3810 | messages.append(
3811 | UserMessage(content=TextContent(type="text", text=msg))
3812 | )
3813 | else:
3814 | msg = json.dumps(pydantic_core.to_jsonable_python(msg))
3815 | messages.append(Message(role="user", content=msg))
3816 | except Exception:
3817 | raise ValueError(
3818 | f"Could not convert prompt result to message: {msg}"
3819 | )
3820 |
3821 | return messages
3822 | except Exception as e:
3823 | raise ValueError(f"Error rendering prompt {self.name}: {e}")
3824 |
3825 |
3826 | ================================================
3827 | File: /src/fastmcp/py.typed
3828 | ================================================
3829 |
3830 |
3831 | ================================================
3832 | File: /src/fastmcp/exceptions.py
3833 | ================================================
3834 | """Custom exceptions for FastMCP."""
3835 |
3836 |
3837 | class FastMCPError(Exception):
3838 | """Base error for FastMCP."""
3839 |
3840 |
3841 | class ValidationError(FastMCPError):
3842 | """Error in validating parameters or return values."""
3843 |
3844 |
3845 | class ResourceError(FastMCPError):
3846 | """Error in resource operations."""
3847 |
3848 |
3849 | class ToolError(FastMCPError):
3850 | """Error in tool operations."""
3851 |
3852 |
3853 | class InvalidSignature(Exception):
3854 | """Invalid signature for use with FastMCP."""
3855 |
3856 |
3857 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | fastmcp==0.4.1
--------------------------------------------------------------------------------
/sqlite_explorer.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | import sqlite3
3 | import os
4 | from typing import List, Dict, Any, Optional
5 | from fastmcp import FastMCP
6 |
7 | # Initialize FastMCP server
8 | mcp = FastMCP("SQLite Explorer",
9 | log_level="CRITICAL")
10 |
11 | # Path to Messages database - must be provided via SQLITE_DB_PATH environment variable
12 | if 'SQLITE_DB_PATH' not in os.environ:
13 | raise ValueError("SQLITE_DB_PATH environment variable must be set")
14 | DB_PATH = Path(os.environ['SQLITE_DB_PATH'])
15 |
16 | class SQLiteConnection:
17 | def __init__(self, db_path: Path):
18 | self.db_path = db_path
19 | self.conn = None
20 |
21 | def __enter__(self):
22 | self.conn = sqlite3.connect(str(self.db_path))
23 | self.conn.row_factory = sqlite3.Row
24 | return self.conn
25 |
26 | def __exit__(self, exc_type, exc_val, exc_tb):
27 | if self.conn:
28 | self.conn.close()
29 |
30 | @mcp.tool()
31 | def read_query(
32 | query: str,
33 | params: Optional[List[Any]] = None,
34 | fetch_all: bool = True,
35 | row_limit: int = 1000
36 | ) -> List[Dict[str, Any]]:
37 | """Execute a query on the Messages database.
38 |
39 | Args:
40 | query: SELECT SQL query to execute
41 | params: Optional list of parameters for the query
42 | fetch_all: If True, fetches all results. If False, fetches one row.
43 | row_limit: Maximum number of rows to return (default 1000)
44 |
45 | Returns:
46 | List of dictionaries containing the query results
47 | """
48 | if not DB_PATH.exists():
49 | raise FileNotFoundError(f"Messages database not found at: {DB_PATH}")
50 |
51 | # Clean and validate the query
52 | query = query.strip()
53 |
54 | # Remove trailing semicolon if present
55 | if query.endswith(';'):
56 | query = query[:-1].strip()
57 |
58 | # Check for multiple statements by looking for semicolons not inside quotes
59 | def contains_multiple_statements(sql: str) -> bool:
60 | in_single_quote = False
61 | in_double_quote = False
62 | for char in sql:
63 | if char == "'" and not in_double_quote:
64 | in_single_quote = not in_single_quote
65 | elif char == '"' and not in_single_quote:
66 | in_double_quote = not in_double_quote
67 | elif char == ';' and not in_single_quote and not in_double_quote:
68 | return True
69 | return False
70 |
71 | if contains_multiple_statements(query):
72 | raise ValueError("Multiple SQL statements are not allowed")
73 |
74 | # Validate query type (allowing common CTEs)
75 | query_lower = query.lower()
76 | if not any(query_lower.startswith(prefix) for prefix in ('select', 'with')):
77 | raise ValueError("Only SELECT queries (including WITH clauses) are allowed for safety")
78 |
79 | params = params or []
80 |
81 | with SQLiteConnection(DB_PATH) as conn:
82 | cursor = conn.cursor()
83 |
84 | try:
85 | # Only add LIMIT if query doesn't already have one
86 | if 'limit' not in query_lower:
87 | query = f"{query} LIMIT {row_limit}"
88 |
89 | cursor.execute(query, params)
90 |
91 | if fetch_all:
92 | results = cursor.fetchall()
93 | else:
94 | results = [cursor.fetchone()]
95 |
96 | return [dict(row) for row in results if row is not None]
97 |
98 | except sqlite3.Error as e:
99 | raise ValueError(f"SQLite error: {str(e)}")
100 |
101 | @mcp.tool()
102 | def list_tables() -> List[str]:
103 | """List all tables in the Messages database.
104 |
105 | Returns:
106 | List of table names in the database
107 | """
108 | if not DB_PATH.exists():
109 | raise FileNotFoundError(f"Messages database not found at: {DB_PATH}")
110 |
111 | with SQLiteConnection(DB_PATH) as conn:
112 | cursor = conn.cursor()
113 |
114 | try:
115 | cursor.execute("""
116 | SELECT name FROM sqlite_master
117 | WHERE type='table'
118 | ORDER BY name
119 | """)
120 |
121 | return [row['name'] for row in cursor.fetchall()]
122 |
123 | except sqlite3.Error as e:
124 | raise ValueError(f"SQLite error: {str(e)}")
125 |
126 | @mcp.tool()
127 | def describe_table(table_name: str) -> List[Dict[str, str]]:
128 | """Get detailed information about a table's schema.
129 |
130 | Args:
131 | table_name: Name of the table to describe
132 |
133 | Returns:
134 | List of dictionaries containing column information:
135 | - name: Column name
136 | - type: Column data type
137 | - notnull: Whether the column can contain NULL values
138 | - dflt_value: Default value for the column
139 | - pk: Whether the column is part of the primary key
140 | """
141 | if not DB_PATH.exists():
142 | raise FileNotFoundError(f"Messages database not found at: {DB_PATH}")
143 |
144 | with SQLiteConnection(DB_PATH) as conn:
145 | cursor = conn.cursor()
146 |
147 | try:
148 | # Verify table exists
149 | cursor.execute("""
150 | SELECT name FROM sqlite_master
151 | WHERE type='table' AND name=?
152 | """, [table_name])
153 |
154 | if not cursor.fetchone():
155 | raise ValueError(f"Table '{table_name}' does not exist")
156 |
157 | # Get table schema
158 | cursor.execute(f"PRAGMA table_info({table_name})")
159 | columns = cursor.fetchall()
160 |
161 | return [dict(row) for row in columns]
162 |
163 | except sqlite3.Error as e:
164 | raise ValueError(f"SQLite error: {str(e)}")
--------------------------------------------------------------------------------