Google Ads MCP

by cohnen
Verified
--- description: Use it to get full context on fastmcp globs: alwaysApply: false --- ├── .github ├── ai-labeler.yml ├── release.yml └── workflows │ ├── ai-labeler.yml │ ├── publish.yml │ ├── run-static.yml │ └── run-tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── LICENSE ├── README.md ├── Windows_Notes.md ├── docs └── assets │ └── demo-inspector.png ├── examples ├── complex_inputs.py ├── desktop.py ├── echo.py ├── memory.py ├── readme-quickstart.py ├── screenshot.py ├── simple_echo.py └── text_me.py ├── pyproject.toml ├── src └── fastmcp │ ├── __init__.py │ ├── cli │ ├── __init__.py │ ├── claude.py │ └── cli.py │ ├── exceptions.py │ ├── prompts │ ├── __init__.py │ ├── base.py │ ├── manager.py │ └── prompt_manager.py │ ├── py.typed │ ├── resources │ ├── __init__.py │ ├── base.py │ ├── resource_manager.py │ ├── templates.py │ └── types.py │ ├── server.py │ ├── tools │ ├── __init__.py │ ├── base.py │ └── tool_manager.py │ └── utilities │ ├── __init__.py │ ├── func_metadata.py │ ├── logging.py │ └── types.py ├── tests ├── __init__.py ├── prompts │ ├── __init__.py │ ├── test_base.py │ └── test_manager.py ├── resources │ ├── __init__.py │ ├── test_file_resources.py │ ├── test_function_resources.py │ ├── test_resource_manager.py │ ├── test_resource_template.py │ └── test_resources.py ├── servers │ ├── __init__.py │ └── test_file_server.py ├── test_cli.py ├── test_func_metadata.py ├── test_server.py └── test_tool_manager.py └── uv.lock /.github/ai-labeler.yml: -------------------------------------------------------------------------------- 1 | instructions: | 2 | Apply the minimal set of labels that accurately characterize the issue/PR: 3 | - Use at most 1-2 labels unless there's a compelling reason for more. It's ok to use no labels. 4 | - Prefer specific labels (bug, feature) over generic ones (question, help wanted) 5 | - For PRs that fix bugs, use 'bug' not 'enhancement' 6 | - Never combine: bug + enhancement, feature + enhancement. For these labels, only choose the most relevant one. 7 | - Reserve 'question' and 'help wanted' for when they're the primary characteristic 8 | 9 | labels: 10 | - bug: 11 | description: "Something isn't working as expected" 12 | instructions: | 13 | Apply when describing or fixing unexpected behavior: 14 | - Issues: Clear error messages or unexpected outcomes 15 | - PRs: Standalone fixes for broken functionality or closing bug reports. 16 | Don't apply bug unless the issue or PR is predominantly about a specific bug. 17 | 18 | - documentation: 19 | description: "Improvements or additions to documentation" 20 | instructions: | 21 | Apply only when documentation is the primary focus: 22 | - README updates 23 | - Code comments and docstrings 24 | - API documentation 25 | - Usage examples 26 | Don't apply for minor doc updates alongside code changes 27 | 28 | - enhancement: 29 | description: "Improvements to existing features" 30 | instructions: | 31 | Apply only for improvements to existing functionality: 32 | - Performance improvements 33 | - UI/UX improvements 34 | - Expanded capabilities of existing features 35 | Don't apply to: 36 | - Bug fixes 37 | - New features 38 | - Minor tweaks 39 | 40 | - feature: 41 | description: "New functionality" 42 | instructions: | 43 | Apply only for net-new functionality: 44 | - New API endpoints 45 | - New commands or tools 46 | - New user-facing capabilities 47 | Don't apply to: 48 | - Improvements to existing features (use enhancement) 49 | - Bug fixes 50 | 51 | - good first issue: 52 | description: "Good for newcomers" 53 | instructions: | 54 | Apply very selectively to issues that are: 55 | - Small in scope 56 | - Well-documented 57 | - Require minimal context 58 | - Have clear success criteria 59 | Don't apply if the task requires significant background knowledge 60 | 61 | - help wanted: 62 | description: "Extra attention is needed" 63 | instructions: | 64 | Apply only when it's the primary characteristic: 65 | - Issue needs external expertise 66 | - Current maintainers can't address it 67 | - Additional contributors would be valuable 68 | Don't apply just because an issue is open or needs work 69 | 70 | - question: 71 | description: "Further information is requested" 72 | instructions: | 73 | Apply only when the primary purpose is seeking information: 74 | - Clarification needed before work can begin 75 | - Architectural discussions 76 | - Implementation strategy questions 77 | Don't apply to: 78 | - Bug reports that need more details 79 | - Feature requests that need refinement 80 | 81 | # These files will be included in the context if they exist 82 | context-files: 83 | - README.md 84 | - CONTRIBUTING.md 85 | - CODE_OF_CONDUCT.md 86 | - .github/ISSUE_TEMPLATE/bug_report.md 87 | - .github/ISSUE_TEMPLATE/feature_request.md 88 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore in release notes 5 | 6 | categories: 7 | - title: New Features 🎉 8 | labels: 9 | - feature 10 | - enhancement 11 | exclude: 12 | labels: 13 | - breaking change 14 | 15 | - title: Fixes 🐞 16 | labels: 17 | - bug 18 | exclude: 19 | labels: 20 | - breaking change 21 | 22 | - title: Breaking Changes 🛫 23 | labels: 24 | - breaking change 25 | 26 | - title: Docs 📚 27 | labels: 28 | - documentation 29 | 30 | - title: Other Changes 🦾 31 | labels: 32 | - "*" 33 | -------------------------------------------------------------------------------- /.github/workflows/ai-labeler.yml: -------------------------------------------------------------------------------- 1 | name: AI Labeler 2 | 3 | on: 4 | issues: 5 | types: [opened, reopened] 6 | issue_comment: 7 | types: [created] 8 | pull_request: 9 | types: [opened, reopened] 10 | 11 | jobs: 12 | ai-labeler: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | issues: write 17 | pull-requests: write 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: jlowin/ai-labeler@v0.5.0 21 | with: 22 | include-repo-labels: false 23 | openai-api-key: ${{ secrets.OPENAI_API_KEY }} 24 | controlflow-llm-model: openai/gpt-4o-mini 25 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish FastMCP to PyPI 2 | on: 3 | release: 4 | types: [published] 5 | workflow_dispatch: 6 | 7 | jobs: 8 | pypi-publish: 9 | name: Upload to PyPI 10 | runs-on: ubuntu-latest 11 | permissions: 12 | id-token: write # For PyPI's trusted publishing 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: "Install uv" 20 | uses: astral-sh/setup-uv@v3 21 | 22 | - name: Build 23 | run: uv build 24 | 25 | - name: Publish to PyPi 26 | run: uv publish -v dist/* 27 | -------------------------------------------------------------------------------- /.github/workflows/run-static.yml: -------------------------------------------------------------------------------- 1 | name: Run Pre-commits 2 | 3 | env: 4 | # enable colored output 5 | # https://github.com/pytest-dev/pytest/issues/7443 6 | PY_COLORS: 1 7 | 8 | on: 9 | push: 10 | branches: ["main"] 11 | pull_request: 12 | workflow_dispatch: 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | static_analysis: 19 | timeout-minutes: 1 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Set up Python 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: "3.12" 29 | - name: Run pre-commit 30 | uses: pre-commit/action@v3.0.1 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install --upgrade pip 34 | pip install ".[tests]" 35 | - name: Run pyright 36 | run: pyright src tests 37 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | env: 4 | # enable colored output 5 | PY_COLORS: 1 6 | 7 | on: 8 | push: 9 | branches: ["main"] 10 | paths: 11 | - "src/**" 12 | - "tests/**" 13 | - "uv.lock" 14 | - "pyproject.toml" 15 | - ".github/workflows/**" 16 | pull_request: 17 | paths: 18 | - "src/**" 19 | - "tests/**" 20 | - "uv.lock" 21 | - "pyproject.toml" 22 | - ".github/workflows/**" 23 | 24 | workflow_dispatch: 25 | 26 | permissions: 27 | contents: read 28 | 29 | jobs: 30 | run_tests: 31 | name: "Run tests: Python ${{ matrix.python-version }} on ${{ matrix.os }}" 32 | runs-on: ${{ matrix.os }} 33 | strategy: 34 | matrix: 35 | os: [ubuntu-latest, windows-latest, macos-latest] 36 | python-version: ["3.10"] 37 | fail-fast: false 38 | 39 | steps: 40 | - uses: actions/checkout@v4 41 | 42 | - name: Install uv 43 | uses: astral-sh/setup-uv@v4 44 | 45 | - name: Set up Python ${{ matrix.python-version }} 46 | run: uv python install ${{ matrix.python-version }} 47 | 48 | - name: Install FastMCP 49 | run: uv sync --extra tests 50 | 51 | - name: Run tests 52 | run: uv run pytest -vv 53 | if: ${{ !(github.event.pull_request.head.repo.fork) }} 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Virtual environments 10 | .venv 11 | .DS_Store 12 | .env 13 | 14 | 15 | src/fastmcp/_version.py 16 | 17 | # editors 18 | .cursorrules 19 | .vscode/ 20 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: true 2 | 3 | repos: 4 | - repo: https://github.com/abravalheri/validate-pyproject 5 | rev: v0.23 6 | hooks: 7 | - id: validate-pyproject 8 | 9 | - repo: https://github.com/pre-commit/mirrors-prettier 10 | rev: v3.1.0 11 | hooks: 12 | - id: prettier 13 | types_or: [yaml, json5] 14 | 15 | - repo: https://github.com/astral-sh/ruff-pre-commit 16 | rev: v0.8.0 17 | hooks: 18 | - id: ruff-format 19 | - id: ruff 20 | args: [--fix, --exit-non-zero-on-fix] 21 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Jeremiah Lowin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | <div align="center"> 2 | 3 | ### 🎉 FastMCP has been added to the official MCP SDK! 🎉 4 | 5 | You can now find FastMCP as part of the official Model Context Protocol Python SDK: 6 | 7 | 👉 [github.com/modelcontextprotocol/python-sdk](https://github.com/modelcontextprotocol/python-sdk) 8 | 9 | *Please note: this repository is no longer maintained.* 10 | 11 | --- 12 | 13 | 14 | </br></br></br> 15 | 16 | </div> 17 | 18 | <div align="center"> 19 | 20 | <!-- omit in toc --> 21 | # FastMCP 🚀 22 | <strong>The fast, Pythonic way to build MCP servers.</strong> 23 | 24 | [![PyPI - Version](https://img.shields.io/pypi/v/fastmcp.svg)](https://pypi.org/project/fastmcp) 25 | [![Tests](https://github.com/jlowin/fastmcp/actions/workflows/run-tests.yml/badge.svg)](https://github.com/jlowin/fastmcp/actions/workflows/run-tests.yml) 26 | [![License](https://img.shields.io/github/license/jlowin/fastmcp.svg)](https://github.com/jlowin/fastmcp/blob/main/LICENSE) 27 | 28 | 29 | </div> 30 | 31 | [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: 32 | 33 | ```python 34 | # demo.py 35 | 36 | from fastmcp import FastMCP 37 | 38 | 39 | mcp = FastMCP("Demo 🚀") 40 | 41 | 42 | @mcp.tool() 43 | def add(a: int, b: int) -> int: 44 | """Add two numbers""" 45 | return a + b 46 | ``` 47 | 48 | That's it! Give Claude access to the server by running: 49 | 50 | ```bash 51 | fastmcp install demo.py 52 | ``` 53 | 54 | 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. 55 | 56 | 57 | ### Key features: 58 | * **Fast**: High-level interface means less code and faster development 59 | * **Simple**: Build MCP servers with minimal boilerplate 60 | * **Pythonic**: Feels natural to Python developers 61 | * **Complete***: FastMCP aims to provide a full implementation of the core MCP specification 62 | 63 | (\*emphasis on *aims*) 64 | 65 | 🚨 🚧 🏗️ *FastMCP is under active development, as is the MCP specification itself. Core features are working but some advanced capabilities are still in progress.* 66 | 67 | 68 | <!-- omit in toc --> 69 | ## Table of Contents 70 | 71 | - [Installation](#installation) 72 | - [Quickstart](#quickstart) 73 | - [What is MCP?](#what-is-mcp) 74 | - [Core Concepts](#core-concepts) 75 | - [Server](#server) 76 | - [Resources](#resources) 77 | - [Tools](#tools) 78 | - [Prompts](#prompts) 79 | - [Images](#images) 80 | - [Context](#context) 81 | - [Running Your Server](#running-your-server) 82 | - [Development Mode (Recommended for Building \& Testing)](#development-mode-recommended-for-building--testing) 83 | - [Claude Desktop Integration (For Regular Use)](#claude-desktop-integration-for-regular-use) 84 | - [Direct Execution (For Advanced Use Cases)](#direct-execution-for-advanced-use-cases) 85 | - [Server Object Names](#server-object-names) 86 | - [Examples](#examples) 87 | - [Echo Server](#echo-server) 88 | - [SQLite Explorer](#sqlite-explorer) 89 | - [Contributing](#contributing) 90 | - [Prerequisites](#prerequisites) 91 | - [Installation](#installation-1) 92 | - [Testing](#testing) 93 | - [Formatting](#formatting) 94 | - [Opening a Pull Request](#opening-a-pull-request) 95 | 96 | ## Installation 97 | 98 | We strongly recommend installing FastMCP with [uv](https://docs.astral.sh/uv/), as it is required for deploying servers: 99 | 100 | ```bash 101 | uv pip install fastmcp 102 | ``` 103 | 104 | 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. 105 | 106 | Alternatively, to use the SDK without deploying, you may use pip: 107 | 108 | ```bash 109 | pip install fastmcp 110 | ``` 111 | 112 | ## Quickstart 113 | 114 | Let's create a simple MCP server that exposes a calculator tool and some data: 115 | 116 | ```python 117 | # server.py 118 | 119 | from fastmcp import FastMCP 120 | 121 | 122 | # Create an MCP server 123 | mcp = FastMCP("Demo") 124 | 125 | 126 | # Add an addition tool 127 | @mcp.tool() 128 | def add(a: int, b: int) -> int: 129 | """Add two numbers""" 130 | return a + b 131 | 132 | 133 | # Add a dynamic greeting resource 134 | @mcp.resource("greeting://{name}") 135 | def get_greeting(name: str) -> str: 136 | """Get a personalized greeting""" 137 | return f"Hello, {name}!" 138 | ``` 139 | 140 | You can install this server in [Claude Desktop](https://claude.ai/download) and interact with it right away by running: 141 | ```bash 142 | fastmcp install server.py 143 | ``` 144 | 145 | Alternatively, you can test it with the MCP Inspector: 146 | ```bash 147 | fastmcp dev server.py 148 | ``` 149 | 150 | ![MCP Inspector](/docs/assets/demo-inspector.png) 151 | 152 | ## What is MCP? 153 | 154 | 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: 155 | 156 | - Expose data through **Resources** (think of these sort of like GET endpoints; they are used to load information into the LLM's context) 157 | - Provide functionality through **Tools** (sort of like POST endpoints; they are used to execute code or otherwise produce a side effect) 158 | - Define interaction patterns through **Prompts** (reusable templates for LLM interactions) 159 | - And more! 160 | 161 | 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. 162 | 163 | ## Core Concepts 164 | 165 | 166 | ### Server 167 | 168 | The FastMCP server is your core interface to the MCP protocol. It handles connection management, protocol compliance, and message routing: 169 | 170 | ```python 171 | from fastmcp import FastMCP 172 | 173 | # Create a named server 174 | mcp = FastMCP("My App") 175 | 176 | # Specify dependencies for deployment and development 177 | mcp = FastMCP("My App", dependencies=["pandas", "numpy"]) 178 | ``` 179 | 180 | ### Resources 181 | 182 | 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: 183 | 184 | - File contents 185 | - Database schemas 186 | - API responses 187 | - System information 188 | 189 | Resources can be static: 190 | ```python 191 | @mcp.resource("config://app") 192 | def get_config() -> str: 193 | """Static configuration data""" 194 | return "App configuration here" 195 | ``` 196 | 197 | Or dynamic with parameters (FastMCP automatically handles these as MCP templates): 198 | ```python 199 | @mcp.resource("users://{user_id}/profile") 200 | def get_user_profile(user_id: str) -> str: 201 | """Dynamic user data""" 202 | return f"Profile data for user {user_id}" 203 | ``` 204 | 205 | ### Tools 206 | 207 | 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. 208 | 209 | Simple calculation example: 210 | ```python 211 | @mcp.tool() 212 | def calculate_bmi(weight_kg: float, height_m: float) -> float: 213 | """Calculate BMI given weight in kg and height in meters""" 214 | return weight_kg / (height_m ** 2) 215 | ``` 216 | 217 | HTTP request example: 218 | ```python 219 | import httpx 220 | 221 | @mcp.tool() 222 | async def fetch_weather(city: str) -> str: 223 | """Fetch current weather for a city""" 224 | async with httpx.AsyncClient() as client: 225 | response = await client.get( 226 | f"https://api.weather.com/{city}" 227 | ) 228 | return response.text 229 | ``` 230 | 231 | Complex input handling example: 232 | ```python 233 | from pydantic import BaseModel, Field 234 | from typing import Annotated 235 | 236 | class ShrimpTank(BaseModel): 237 | class Shrimp(BaseModel): 238 | name: Annotated[str, Field(max_length=10)] 239 | 240 | shrimp: list[Shrimp] 241 | 242 | @mcp.tool() 243 | def name_shrimp( 244 | tank: ShrimpTank, 245 | # You can use pydantic Field in function signatures for validation. 246 | extra_names: Annotated[list[str], Field(max_length=10)], 247 | ) -> list[str]: 248 | """List all shrimp names in the tank""" 249 | return [shrimp.name for shrimp in tank.shrimp] + extra_names 250 | ``` 251 | 252 | ### Prompts 253 | 254 | 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: 255 | 256 | ```python 257 | @mcp.prompt() 258 | def review_code(code: str) -> str: 259 | return f"Please review this code:\n\n{code}" 260 | ``` 261 | 262 | Or a more structured sequence of messages: 263 | ```python 264 | from fastmcp.prompts.base import UserMessage, AssistantMessage 265 | 266 | @mcp.prompt() 267 | def debug_error(error: str) -> list[Message]: 268 | return [ 269 | UserMessage("I'm seeing this error:"), 270 | UserMessage(error), 271 | AssistantMessage("I'll help debug that. What have you tried so far?") 272 | ] 273 | ``` 274 | 275 | 276 | ### Images 277 | 278 | FastMCP provides an `Image` class that automatically handles image data in your server: 279 | 280 | ```python 281 | from fastmcp import FastMCP, Image 282 | from PIL import Image as PILImage 283 | 284 | @mcp.tool() 285 | def create_thumbnail(image_path: str) -> Image: 286 | """Create a thumbnail from an image""" 287 | img = PILImage.open(image_path) 288 | img.thumbnail((100, 100)) 289 | 290 | # FastMCP automatically handles conversion and MIME types 291 | return Image(data=img.tobytes(), format="png") 292 | 293 | @mcp.tool() 294 | def load_image(path: str) -> Image: 295 | """Load an image from disk""" 296 | # FastMCP handles reading and format detection 297 | return Image(path=path) 298 | ``` 299 | 300 | Images can be used as the result of both tools and resources. 301 | 302 | ### Context 303 | 304 | The Context object gives your tools and resources access to MCP capabilities. To use it, add a parameter annotated with `fastmcp.Context`: 305 | 306 | ```python 307 | from fastmcp import FastMCP, Context 308 | 309 | @mcp.tool() 310 | async def long_task(files: list[str], ctx: Context) -> str: 311 | """Process multiple files with progress tracking""" 312 | for i, file in enumerate(files): 313 | ctx.info(f"Processing {file}") 314 | await ctx.report_progress(i, len(files)) 315 | 316 | # Read another resource if needed 317 | data = await ctx.read_resource(f"file://{file}") 318 | 319 | return "Processing complete" 320 | ``` 321 | 322 | The Context object provides: 323 | - Progress reporting through `report_progress()` 324 | - Logging via `debug()`, `info()`, `warning()`, and `error()` 325 | - Resource access through `read_resource()` 326 | - Request metadata via `request_id` and `client_id` 327 | 328 | ## Running Your Server 329 | 330 | There are three main ways to use your FastMCP server, each suited for different stages of development: 331 | 332 | ### Development Mode (Recommended for Building & Testing) 333 | 334 | The fastest way to test and debug your server is with the MCP Inspector: 335 | 336 | ```bash 337 | fastmcp dev server.py 338 | ``` 339 | 340 | This launches a web interface where you can: 341 | - Test your tools and resources interactively 342 | - See detailed logs and error messages 343 | - Monitor server performance 344 | - Set environment variables for testing 345 | 346 | During development, you can: 347 | - Add dependencies with `--with`: 348 | ```bash 349 | fastmcp dev server.py --with pandas --with numpy 350 | ``` 351 | - Mount your local code for live updates: 352 | ```bash 353 | fastmcp dev server.py --with-editable . 354 | ``` 355 | 356 | ### Claude Desktop Integration (For Regular Use) 357 | 358 | Once your server is ready, install it in Claude Desktop to use it with Claude: 359 | 360 | ```bash 361 | fastmcp install server.py 362 | ``` 363 | 364 | Your server will run in an isolated environment with: 365 | - Automatic installation of dependencies specified in your FastMCP instance: 366 | ```python 367 | mcp = FastMCP("My App", dependencies=["pandas", "numpy"]) 368 | ``` 369 | - Custom naming via `--name`: 370 | ```bash 371 | fastmcp install server.py --name "My Analytics Server" 372 | ``` 373 | - Environment variable management: 374 | ```bash 375 | # Set variables individually 376 | fastmcp install server.py -e API_KEY=abc123 -e DB_URL=postgres://... 377 | 378 | # Or load from a .env file 379 | fastmcp install server.py -f .env 380 | ``` 381 | 382 | ### Direct Execution (For Advanced Use Cases) 383 | 384 | For advanced scenarios like custom deployments or running without Claude, you can execute your server directly: 385 | 386 | ```python 387 | from fastmcp import FastMCP 388 | 389 | mcp = FastMCP("My App") 390 | 391 | if __name__ == "__main__": 392 | mcp.run() 393 | ``` 394 | 395 | Run it with: 396 | ```bash 397 | # Using the FastMCP CLI 398 | fastmcp run server.py 399 | 400 | # Or with Python/uv directly 401 | python server.py 402 | uv run python server.py 403 | ``` 404 | 405 | 406 | 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. 407 | 408 | Choose this method when you need: 409 | - Custom deployment configurations 410 | - Integration with other services 411 | - Direct control over the server lifecycle 412 | 413 | ### Server Object Names 414 | 415 | 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`: 416 | 417 | ```bash 418 | # Using a standard name 419 | fastmcp run server.py 420 | 421 | # Using a custom name 422 | fastmcp run server.py:my_custom_server 423 | ``` 424 | 425 | ## Examples 426 | 427 | Here are a few examples of FastMCP servers. For more, see the `examples/` directory. 428 | 429 | ### Echo Server 430 | A simple server demonstrating resources, tools, and prompts: 431 | 432 | ```python 433 | from fastmcp import FastMCP 434 | 435 | mcp = FastMCP("Echo") 436 | 437 | @mcp.resource("echo://{message}") 438 | def echo_resource(message: str) -> str: 439 | """Echo a message as a resource""" 440 | return f"Resource echo: {message}" 441 | 442 | @mcp.tool() 443 | def echo_tool(message: str) -> str: 444 | """Echo a message as a tool""" 445 | return f"Tool echo: {message}" 446 | 447 | @mcp.prompt() 448 | def echo_prompt(message: str) -> str: 449 | """Create an echo prompt""" 450 | return f"Please process this message: {message}" 451 | ``` 452 | 453 | ### SQLite Explorer 454 | A more complex example showing database integration: 455 | 456 | ```python 457 | from fastmcp import FastMCP 458 | import sqlite3 459 | 460 | mcp = FastMCP("SQLite Explorer") 461 | 462 | @mcp.resource("schema://main") 463 | def get_schema() -> str: 464 | """Provide the database schema as a resource""" 465 | conn = sqlite3.connect("database.db") 466 | schema = conn.execute( 467 | "SELECT sql FROM sqlite_master WHERE type='table'" 468 | ).fetchall() 469 | return "\n".join(sql[0] for sql in schema if sql[0]) 470 | 471 | @mcp.tool() 472 | def query_data(sql: str) -> str: 473 | """Execute SQL queries safely""" 474 | conn = sqlite3.connect("database.db") 475 | try: 476 | result = conn.execute(sql).fetchall() 477 | return "\n".join(str(row) for row in result) 478 | except Exception as e: 479 | return f"Error: {str(e)}" 480 | 481 | @mcp.prompt() 482 | def analyze_table(table: str) -> str: 483 | """Create a prompt template for analyzing tables""" 484 | return f"""Please analyze this database table: 485 | Table: {table} 486 | Schema: 487 | {get_schema()} 488 | 489 | What insights can you provide about the structure and relationships?""" 490 | ``` 491 | 492 | ## Contributing 493 | 494 | <details> 495 | 496 | <summary><h3>Open Developer Guide</h3></summary> 497 | 498 | ### Prerequisites 499 | 500 | FastMCP requires Python 3.10+ and [uv](https://docs.astral.sh/uv/). 501 | 502 | ### Installation 503 | 504 | For development, we recommend installing FastMCP with development dependencies, which includes various utilities the maintainers find useful. 505 | 506 | ```bash 507 | git clone https://github.com/jlowin/fastmcp.git 508 | cd fastmcp 509 | uv sync --frozen --extra dev 510 | ``` 511 | 512 | For running tests only (e.g., in CI), you only need the testing dependencies: 513 | 514 | ```bash 515 | uv sync --frozen --extra tests 516 | ``` 517 | 518 | ### Testing 519 | 520 | Please make sure to test any new functionality. Your tests should be simple and atomic and anticipate change rather than cement complex patterns. 521 | 522 | Run tests from the root directory: 523 | 524 | 525 | ```bash 526 | pytest -vv 527 | ``` 528 | 529 | ### Formatting 530 | 531 | FastMCP enforces a variety of required formats, which you can automatically enforce with pre-commit. 532 | 533 | Install the pre-commit hooks: 534 | 535 | ```bash 536 | pre-commit install 537 | ``` 538 | 539 | The hooks will now run on every commit (as well as on every PR). To run them manually: 540 | 541 | ```bash 542 | pre-commit run --all-files 543 | ``` 544 | 545 | ### Opening a Pull Request 546 | 547 | Fork the repository and create a new branch: 548 | 549 | ```bash 550 | git checkout -b my-branch 551 | ``` 552 | 553 | Make your changes and commit them: 554 | 555 | 556 | ```bash 557 | git add . && git commit -m "My changes" 558 | ``` 559 | 560 | Push your changes to your fork: 561 | 562 | 563 | ```bash 564 | git push origin my-branch 565 | ``` 566 | 567 | Feel free to reach out in a GitHub issue or discussion if you have any questions! 568 | 569 | </details> 570 | -------------------------------------------------------------------------------- /Windows_Notes.md: -------------------------------------------------------------------------------- 1 | # Getting your development environment set up properly 2 | To get your environment up and running properly, you'll need a slightly different set of commands that are windows specific: 3 | ```bash 4 | uv venv 5 | .venv\Scripts\activate 6 | uv pip install -e ".[dev]" 7 | ``` 8 | 9 | This will install the package in editable mode, and install the development dependencies. 10 | 11 | 12 | # Fixing `AttributeError: module 'collections' has no attribute 'Callable'` 13 | - open `.venv\Lib\site-packages\pyreadline\py3k_compat.py` 14 | - change `return isinstance(x, collections.Callable)` to 15 | ``` 16 | from collections.abc import Callable 17 | return isinstance(x, Callable) 18 | ``` 19 | 20 | # Helpful notes 21 | For developing FastMCP 22 | ## Install local development version of FastMCP into a local FastMCP project server 23 | - ensure 24 | - change directories to your FastMCP Server location so you can install it in your .venv 25 | - run `.venv\Scripts\activate` to activate your virtual environment 26 | - Then run a series of commands to uninstall the old version and install the new 27 | ```bash 28 | # First uninstall 29 | uv pip uninstall fastmcp 30 | 31 | # Clean any build artifacts in your fastmcp directory 32 | cd C:\path\to\fastmcp 33 | del /s /q *.egg-info 34 | 35 | # Then reinstall in your weather project 36 | cd C:\path\to\new\fastmcp_server 37 | uv pip install --no-cache-dir -e C:\Users\justj\PycharmProjects\fastmcp 38 | 39 | # Check that it installed properly and has the correct git hash 40 | pip show fastmcp 41 | ``` 42 | 43 | ## Running the FastMCP server with Inspector 44 | 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: 45 | ```bash 46 | fastmcp dev server.py 47 | ``` 48 | This will launch a web app on http://localhost:5173/ that you can use to inspect the FastMCP server. 49 | 50 | ## If you start development before creating a fork - your get out of jail free card 51 | - Add your fork as a new remote to your local repository `git remote add fork git@github.com:YOUR-USERNAME/REPOSITORY-NAME.git` 52 | - This will add your repo, short named 'fork', as a remote to your local repository 53 | - Verify that it was added correctly by running `git remote -v` 54 | - Commit your changes 55 | - Push your changes to your fork `git push fork <branch>` 56 | - Create your pull request on GitHub 57 | 58 | 59 | -------------------------------------------------------------------------------- /docs/assets/demo-inspector.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlowin/fastmcp/80c328b3dc0b949f010456ee0e85cc5c90e3305f/docs/assets/demo-inspector.png -------------------------------------------------------------------------------- /examples/complex_inputs.py: -------------------------------------------------------------------------------- 1 | """ 2 | FastMCP Complex inputs Example 3 | 4 | Demonstrates validation via pydantic with complex models. 5 | """ 6 | 7 | from pydantic import BaseModel, Field 8 | from typing import Annotated 9 | from fastmcp.server import FastMCP 10 | 11 | mcp = FastMCP("Shrimp Tank") 12 | 13 | 14 | class ShrimpTank(BaseModel): 15 | class Shrimp(BaseModel): 16 | name: Annotated[str, Field(max_length=10)] 17 | 18 | shrimp: list[Shrimp] 19 | 20 | 21 | @mcp.tool() 22 | def name_shrimp( 23 | tank: ShrimpTank, 24 | # You can use pydantic Field in function signatures for validation. 25 | extra_names: Annotated[list[str], Field(max_length=10)], 26 | ) -> list[str]: 27 | """List all shrimp names in the tank""" 28 | return [shrimp.name for shrimp in tank.shrimp] + extra_names 29 | -------------------------------------------------------------------------------- /examples/desktop.py: -------------------------------------------------------------------------------- 1 | """ 2 | FastMCP Desktop Example 3 | 4 | A simple example that exposes the desktop directory as a resource. 5 | """ 6 | 7 | from pathlib import Path 8 | 9 | from fastmcp.server import FastMCP 10 | 11 | # Create server 12 | mcp = FastMCP("Demo") 13 | 14 | 15 | @mcp.resource("dir://desktop") 16 | def desktop() -> list[str]: 17 | """List the files in the user's desktop""" 18 | desktop = Path.home() / "Desktop" 19 | return [str(f) for f in desktop.iterdir()] 20 | 21 | 22 | @mcp.tool() 23 | def add(a: int, b: int) -> int: 24 | """Add two numbers""" 25 | return a + b 26 | -------------------------------------------------------------------------------- /examples/echo.py: -------------------------------------------------------------------------------- 1 | """ 2 | FastMCP Echo Server 3 | """ 4 | 5 | from fastmcp import FastMCP 6 | 7 | # Create server 8 | mcp = FastMCP("Echo Server") 9 | 10 | 11 | @mcp.tool() 12 | def echo_tool(text: str) -> str: 13 | """Echo the input text""" 14 | return text 15 | 16 | 17 | @mcp.resource("echo://static") 18 | def echo_resource() -> str: 19 | return "Echo!" 20 | 21 | 22 | @mcp.resource("echo://{text}") 23 | def echo_template(text: str) -> str: 24 | """Echo the input text""" 25 | return f"Echo: {text}" 26 | 27 | 28 | @mcp.prompt("echo") 29 | def echo_prompt(text: str) -> str: 30 | return text 31 | -------------------------------------------------------------------------------- /examples/memory.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # dependencies = ["pydantic-ai-slim[openai]", "asyncpg", "numpy", "pgvector", "fastmcp"] 3 | # /// 4 | 5 | # uv pip install 'pydantic-ai-slim[openai]' asyncpg numpy pgvector fastmcp 6 | 7 | """ 8 | Recursive memory system inspired by the human brain's clustering of memories. 9 | Uses OpenAI's 'text-embedding-3-small' model and pgvector for efficient similarity search. 10 | """ 11 | 12 | import asyncio 13 | import math 14 | import os 15 | from dataclasses import dataclass 16 | from datetime import datetime, timezone 17 | from pathlib import Path 18 | from typing import Annotated, Self 19 | 20 | import asyncpg 21 | import numpy as np 22 | from openai import AsyncOpenAI 23 | from pgvector.asyncpg import register_vector # Import register_vector 24 | from pydantic import BaseModel, Field 25 | from pydantic_ai import Agent 26 | 27 | from fastmcp import FastMCP 28 | 29 | MAX_DEPTH = 5 30 | SIMILARITY_THRESHOLD = 0.7 31 | DECAY_FACTOR = 0.99 32 | REINFORCEMENT_FACTOR = 1.1 33 | 34 | DEFAULT_LLM_MODEL = "openai:gpt-4o" 35 | DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small" 36 | 37 | mcp = FastMCP( 38 | "memory", 39 | dependencies=[ 40 | "pydantic-ai-slim[openai]", 41 | "asyncpg", 42 | "numpy", 43 | "pgvector", 44 | ], 45 | ) 46 | 47 | DB_DSN = "postgresql://postgres:postgres@localhost:54320/memory_db" 48 | # reset memory with rm ~/.fastmcp/{USER}/memory/* 49 | PROFILE_DIR = ( 50 | Path.home() / ".fastmcp" / os.environ.get("USER", "anon") / "memory" 51 | ).resolve() 52 | PROFILE_DIR.mkdir(parents=True, exist_ok=True) 53 | 54 | 55 | def cosine_similarity(a: list[float], b: list[float]) -> float: 56 | a_array = np.array(a, dtype=np.float64) 57 | b_array = np.array(b, dtype=np.float64) 58 | return np.dot(a_array, b_array) / ( 59 | np.linalg.norm(a_array) * np.linalg.norm(b_array) 60 | ) 61 | 62 | 63 | async def do_ai[T]( 64 | user_prompt: str, 65 | system_prompt: str, 66 | result_type: type[T] | Annotated, 67 | deps=None, 68 | ) -> T: 69 | agent = Agent( 70 | DEFAULT_LLM_MODEL, 71 | system_prompt=system_prompt, 72 | result_type=result_type, 73 | ) 74 | result = await agent.run(user_prompt, deps=deps) 75 | return result.data 76 | 77 | 78 | @dataclass 79 | class Deps: 80 | openai: AsyncOpenAI 81 | pool: asyncpg.Pool 82 | 83 | 84 | async def get_db_pool() -> asyncpg.Pool: 85 | async def init(conn): 86 | await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;") 87 | await register_vector(conn) 88 | 89 | pool = await asyncpg.create_pool(DB_DSN, init=init) 90 | return pool 91 | 92 | 93 | class MemoryNode(BaseModel): 94 | id: int | None = None 95 | content: str 96 | summary: str = "" 97 | importance: float = 1.0 98 | access_count: int = 0 99 | timestamp: float = Field( 100 | default_factory=lambda: datetime.now(timezone.utc).timestamp() 101 | ) 102 | embedding: list[float] 103 | 104 | @classmethod 105 | async def from_content(cls, content: str, deps: Deps): 106 | embedding = await get_embedding(content, deps) 107 | return cls(content=content, embedding=embedding) 108 | 109 | async def save(self, deps: Deps): 110 | async with deps.pool.acquire() as conn: 111 | if self.id is None: 112 | result = await conn.fetchrow( 113 | """ 114 | INSERT INTO memories (content, summary, importance, access_count, timestamp, embedding) 115 | VALUES ($1, $2, $3, $4, $5, $6) 116 | RETURNING id 117 | """, 118 | self.content, 119 | self.summary, 120 | self.importance, 121 | self.access_count, 122 | self.timestamp, 123 | self.embedding, 124 | ) 125 | self.id = result["id"] 126 | else: 127 | await conn.execute( 128 | """ 129 | UPDATE memories 130 | SET content = $1, summary = $2, importance = $3, 131 | access_count = $4, timestamp = $5, embedding = $6 132 | WHERE id = $7 133 | """, 134 | self.content, 135 | self.summary, 136 | self.importance, 137 | self.access_count, 138 | self.timestamp, 139 | self.embedding, 140 | self.id, 141 | ) 142 | 143 | async def merge_with(self, other: Self, deps: Deps): 144 | self.content = await do_ai( 145 | f"{self.content}\n\n{other.content}", 146 | "Combine the following two texts into a single, coherent text.", 147 | str, 148 | deps, 149 | ) 150 | self.importance += other.importance 151 | self.access_count += other.access_count 152 | self.embedding = [(a + b) / 2 for a, b in zip(self.embedding, other.embedding)] 153 | self.summary = await do_ai( 154 | self.content, "Summarize the following text concisely.", str, deps 155 | ) 156 | await self.save(deps) 157 | # Delete the merged node from the database 158 | if other.id is not None: 159 | await delete_memory(other.id, deps) 160 | 161 | def get_effective_importance(self): 162 | return self.importance * (1 + math.log(self.access_count + 1)) 163 | 164 | 165 | async def get_embedding(text: str, deps: Deps) -> list[float]: 166 | embedding_response = await deps.openai.embeddings.create( 167 | input=text, 168 | model=DEFAULT_EMBEDDING_MODEL, 169 | ) 170 | return embedding_response.data[0].embedding 171 | 172 | 173 | async def delete_memory(memory_id: int, deps: Deps): 174 | async with deps.pool.acquire() as conn: 175 | await conn.execute("DELETE FROM memories WHERE id = $1", memory_id) 176 | 177 | 178 | async def add_memory(content: str, deps: Deps): 179 | new_memory = await MemoryNode.from_content(content, deps) 180 | await new_memory.save(deps) 181 | 182 | similar_memories = await find_similar_memories(new_memory.embedding, deps) 183 | for memory in similar_memories: 184 | if memory.id != new_memory.id: 185 | await new_memory.merge_with(memory, deps) 186 | 187 | await update_importance(new_memory.embedding, deps) 188 | 189 | await prune_memories(deps) 190 | 191 | return f"Remembered: {content}" 192 | 193 | 194 | async def find_similar_memories(embedding: list[float], deps: Deps) -> list[MemoryNode]: 195 | async with deps.pool.acquire() as conn: 196 | rows = await conn.fetch( 197 | """ 198 | SELECT id, content, summary, importance, access_count, timestamp, embedding 199 | FROM memories 200 | ORDER BY embedding <-> $1 201 | LIMIT 5 202 | """, 203 | embedding, 204 | ) 205 | memories = [ 206 | MemoryNode( 207 | id=row["id"], 208 | content=row["content"], 209 | summary=row["summary"], 210 | importance=row["importance"], 211 | access_count=row["access_count"], 212 | timestamp=row["timestamp"], 213 | embedding=row["embedding"], 214 | ) 215 | for row in rows 216 | ] 217 | return memories 218 | 219 | 220 | async def update_importance(user_embedding: list[float], deps: Deps): 221 | async with deps.pool.acquire() as conn: 222 | rows = await conn.fetch( 223 | "SELECT id, importance, access_count, embedding FROM memories" 224 | ) 225 | for row in rows: 226 | memory_embedding = row["embedding"] 227 | similarity = cosine_similarity(user_embedding, memory_embedding) 228 | if similarity > SIMILARITY_THRESHOLD: 229 | new_importance = row["importance"] * REINFORCEMENT_FACTOR 230 | new_access_count = row["access_count"] + 1 231 | else: 232 | new_importance = row["importance"] * DECAY_FACTOR 233 | new_access_count = row["access_count"] 234 | await conn.execute( 235 | """ 236 | UPDATE memories 237 | SET importance = $1, access_count = $2 238 | WHERE id = $3 239 | """, 240 | new_importance, 241 | new_access_count, 242 | row["id"], 243 | ) 244 | 245 | 246 | async def prune_memories(deps: Deps): 247 | async with deps.pool.acquire() as conn: 248 | rows = await conn.fetch( 249 | """ 250 | SELECT id, importance, access_count 251 | FROM memories 252 | ORDER BY importance DESC 253 | OFFSET $1 254 | """, 255 | MAX_DEPTH, 256 | ) 257 | for row in rows: 258 | await conn.execute("DELETE FROM memories WHERE id = $1", row["id"]) 259 | 260 | 261 | async def display_memory_tree(deps: Deps) -> str: 262 | async with deps.pool.acquire() as conn: 263 | rows = await conn.fetch( 264 | """ 265 | SELECT content, summary, importance, access_count 266 | FROM memories 267 | ORDER BY importance DESC 268 | LIMIT $1 269 | """, 270 | MAX_DEPTH, 271 | ) 272 | result = "" 273 | for row in rows: 274 | effective_importance = row["importance"] * ( 275 | 1 + math.log(row["access_count"] + 1) 276 | ) 277 | summary = row["summary"] or row["content"] 278 | result += f"- {summary} (Importance: {effective_importance:.2f})\n" 279 | return result 280 | 281 | 282 | @mcp.tool() 283 | async def remember( 284 | contents: list[str] = Field( 285 | description="List of observations or memories to store" 286 | ), 287 | ): 288 | deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool()) 289 | try: 290 | return "\n".join( 291 | await asyncio.gather(*[add_memory(content, deps) for content in contents]) 292 | ) 293 | finally: 294 | await deps.pool.close() 295 | 296 | 297 | @mcp.tool() 298 | async def read_profile() -> str: 299 | deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool()) 300 | profile = await display_memory_tree(deps) 301 | await deps.pool.close() 302 | return profile 303 | 304 | 305 | async def initialize_database(): 306 | pool = await asyncpg.create_pool( 307 | "postgresql://postgres:postgres@localhost:54320/postgres" 308 | ) 309 | try: 310 | async with pool.acquire() as conn: 311 | await conn.execute(""" 312 | SELECT pg_terminate_backend(pg_stat_activity.pid) 313 | FROM pg_stat_activity 314 | WHERE pg_stat_activity.datname = 'memory_db' 315 | AND pid <> pg_backend_pid(); 316 | """) 317 | await conn.execute("DROP DATABASE IF EXISTS memory_db;") 318 | await conn.execute("CREATE DATABASE memory_db;") 319 | finally: 320 | await pool.close() 321 | 322 | pool = await asyncpg.create_pool(DB_DSN) 323 | try: 324 | async with pool.acquire() as conn: 325 | await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;") 326 | 327 | await register_vector(conn) 328 | 329 | await conn.execute(""" 330 | CREATE TABLE IF NOT EXISTS memories ( 331 | id SERIAL PRIMARY KEY, 332 | content TEXT NOT NULL, 333 | summary TEXT, 334 | importance REAL NOT NULL, 335 | access_count INT NOT NULL, 336 | timestamp DOUBLE PRECISION NOT NULL, 337 | embedding vector(1536) NOT NULL 338 | ); 339 | CREATE INDEX IF NOT EXISTS idx_memories_embedding ON memories USING hnsw (embedding vector_l2_ops); 340 | """) 341 | finally: 342 | await pool.close() 343 | 344 | 345 | if __name__ == "__main__": 346 | asyncio.run(initialize_database()) 347 | -------------------------------------------------------------------------------- /examples/readme-quickstart.py: -------------------------------------------------------------------------------- 1 | from fastmcp import FastMCP 2 | 3 | 4 | # Create an MCP server 5 | mcp = FastMCP("Demo") 6 | 7 | 8 | # Add an addition tool 9 | @mcp.tool() 10 | def add(a: int, b: int) -> int: 11 | """Add two numbers""" 12 | return a + b 13 | 14 | 15 | # Add a dynamic greeting resource 16 | @mcp.resource("greeting://{name}") 17 | def get_greeting(name: str) -> str: 18 | """Get a personalized greeting""" 19 | return f"Hello, {name}!" 20 | -------------------------------------------------------------------------------- /examples/screenshot.py: -------------------------------------------------------------------------------- 1 | """ 2 | FastMCP Screenshot Example 3 | 4 | Give Claude a tool to capture and view screenshots. 5 | """ 6 | 7 | import io 8 | from fastmcp import FastMCP, Image 9 | 10 | 11 | # Create server 12 | mcp = FastMCP("Screenshot Demo", dependencies=["pyautogui", "Pillow"]) 13 | 14 | 15 | @mcp.tool() 16 | def take_screenshot() -> Image: 17 | """ 18 | Take a screenshot of the user's screen and return it as an image. Use 19 | this tool anytime the user wants you to look at something they're doing. 20 | """ 21 | import pyautogui 22 | 23 | buffer = io.BytesIO() 24 | 25 | # if the file exceeds ~1MB, it will be rejected by Claude 26 | screenshot = pyautogui.screenshot() 27 | screenshot.convert("RGB").save(buffer, format="JPEG", quality=60, optimize=True) 28 | return Image(data=buffer.getvalue(), format="jpeg") 29 | -------------------------------------------------------------------------------- /examples/simple_echo.py: -------------------------------------------------------------------------------- 1 | """ 2 | FastMCP Echo Server 3 | """ 4 | 5 | from fastmcp import FastMCP 6 | 7 | 8 | # Create server 9 | mcp = FastMCP("Echo Server") 10 | 11 | 12 | @mcp.tool() 13 | def echo(text: str) -> str: 14 | """Echo the input text""" 15 | return text 16 | -------------------------------------------------------------------------------- /examples/text_me.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # dependencies = ["fastmcp"] 3 | # /// 4 | 5 | """ 6 | FastMCP Text Me Server 7 | -------------------------------- 8 | This defines a simple FastMCP server that sends a text message to a phone number via https://surgemsg.com/. 9 | 10 | To run this example, create a `.env` file with the following values: 11 | 12 | SURGE_API_KEY=... 13 | SURGE_ACCOUNT_ID=... 14 | SURGE_MY_PHONE_NUMBER=... 15 | SURGE_MY_FIRST_NAME=... 16 | SURGE_MY_LAST_NAME=... 17 | 18 | Visit https://surgemsg.com/ and click "Get Started" to obtain these values. 19 | """ 20 | 21 | from typing import Annotated 22 | import httpx 23 | from pydantic import BeforeValidator 24 | from pydantic_settings import BaseSettings, SettingsConfigDict 25 | 26 | from fastmcp import FastMCP 27 | 28 | 29 | class SurgeSettings(BaseSettings): 30 | model_config: SettingsConfigDict = SettingsConfigDict( 31 | env_prefix="SURGE_", env_file=".env" 32 | ) 33 | 34 | api_key: str 35 | account_id: str 36 | my_phone_number: Annotated[ 37 | str, BeforeValidator(lambda v: "+" + v if not v.startswith("+") else v) 38 | ] 39 | my_first_name: str 40 | my_last_name: str 41 | 42 | 43 | # Create server 44 | mcp = FastMCP("Text me") 45 | surge_settings = SurgeSettings() # type: ignore 46 | 47 | 48 | @mcp.tool(name="textme", description="Send a text message to me") 49 | def text_me(text_content: str) -> str: 50 | """Send a text message to a phone number via https://surgemsg.com/""" 51 | with httpx.Client() as client: 52 | response = client.post( 53 | "https://api.surgemsg.com/messages", 54 | headers={ 55 | "Authorization": f"Bearer {surge_settings.api_key}", 56 | "Surge-Account": surge_settings.account_id, 57 | "Content-Type": "application/json", 58 | }, 59 | json={ 60 | "body": text_content, 61 | "conversation": { 62 | "contact": { 63 | "first_name": surge_settings.my_first_name, 64 | "last_name": surge_settings.my_last_name, 65 | "phone_number": surge_settings.my_phone_number, 66 | } 67 | }, 68 | }, 69 | ) 70 | response.raise_for_status() 71 | return f"Message sent: {text_content}" 72 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "fastmcp" 3 | dynamic = ["version"] 4 | description = "A more ergonomic interface for MCP servers" 5 | authors = [{ name = "Jeremiah Lowin" }] 6 | dependencies = [ 7 | "httpx>=0.26.0", 8 | "mcp>=1.0.0,<2.0.0", 9 | "pydantic-settings>=2.6.1", 10 | "pydantic>=2.5.3,<3.0.0", 11 | "typer>=0.9.0", 12 | "python-dotenv>=1.0.1", 13 | ] 14 | requires-python = ">=3.10" 15 | readme = "README.md" 16 | license = { text = "MIT" } 17 | 18 | [project.scripts] 19 | fastmcp = "fastmcp.cli:app" 20 | 21 | [build-system] 22 | requires = ["hatchling>=1.21.0", "hatch-vcs>=0.4.0"] 23 | build-backend = "hatchling.build" 24 | 25 | [project.optional-dependencies] 26 | tests = [ 27 | "pre-commit", 28 | "pyright>=1.1.389", 29 | "pytest>=8.3.3", 30 | "pytest-asyncio>=0.23.5", 31 | "pytest-flakefinder", 32 | "pytest-xdist>=3.6.1", 33 | "ruff", 34 | ] 35 | dev = ["fastmcp[tests]", "copychat>=0.5.2", "ipython>=8.12.3", "pdbpp>=0.10.3"] 36 | 37 | [tool.pytest.ini_options] 38 | asyncio_mode = "auto" 39 | asyncio_default_fixture_loop_scope = "session" 40 | 41 | [tool.hatch.version] 42 | source = "vcs" 43 | 44 | [tool.pyright] 45 | include = ["src", "tests"] 46 | exclude = ["**/node_modules", "**/__pycache__", ".venv", ".git", "dist"] 47 | pythonVersion = "3.10" 48 | pythonPlatform = "Darwin" 49 | typeCheckingMode = "basic" 50 | reportMissingImports = true 51 | reportMissingTypeStubs = false 52 | useLibraryCodeForTypes = true 53 | venvPath = "." 54 | venv = ".venv" 55 | -------------------------------------------------------------------------------- /src/fastmcp/__init__.py: -------------------------------------------------------------------------------- 1 | """FastMCP - A more ergonomic interface for MCP servers.""" 2 | 3 | from importlib.metadata import version 4 | from .server import FastMCP, Context 5 | from .utilities.types import Image 6 | 7 | __version__ = version("fastmcp") 8 | __all__ = ["FastMCP", "Context", "Image"] 9 | -------------------------------------------------------------------------------- /src/fastmcp/cli/__init__.py: -------------------------------------------------------------------------------- 1 | """FastMCP CLI package.""" 2 | 3 | from .cli import app 4 | 5 | 6 | if __name__ == "__main__": 7 | app() 8 | -------------------------------------------------------------------------------- /src/fastmcp/cli/claude.py: -------------------------------------------------------------------------------- 1 | """Claude app integration utilities.""" 2 | 3 | import json 4 | import sys 5 | from pathlib import Path 6 | from typing import Optional, Dict 7 | 8 | from ..utilities.logging import get_logger 9 | 10 | logger = get_logger(__name__) 11 | 12 | 13 | def get_claude_config_path() -> Path | None: 14 | """Get the Claude config directory based on platform.""" 15 | if sys.platform == "win32": 16 | path = Path(Path.home(), "AppData", "Roaming", "Claude") 17 | elif sys.platform == "darwin": 18 | path = Path(Path.home(), "Library", "Application Support", "Claude") 19 | else: 20 | return None 21 | 22 | if path.exists(): 23 | return path 24 | return None 25 | 26 | 27 | def update_claude_config( 28 | file_spec: str, 29 | server_name: str, 30 | *, 31 | with_editable: Optional[Path] = None, 32 | with_packages: Optional[list[str]] = None, 33 | env_vars: Optional[Dict[str, str]] = None, 34 | ) -> bool: 35 | """Add or update a FastMCP server in Claude's configuration. 36 | 37 | Args: 38 | file_spec: Path to the server file, optionally with :object suffix 39 | server_name: Name for the server in Claude's config 40 | with_editable: Optional directory to install in editable mode 41 | with_packages: Optional list of additional packages to install 42 | env_vars: Optional dictionary of environment variables. These are merged with 43 | any existing variables, with new values taking precedence. 44 | 45 | Raises: 46 | RuntimeError: If Claude Desktop's config directory is not found, indicating 47 | Claude Desktop may not be installed or properly set up. 48 | """ 49 | config_dir = get_claude_config_path() 50 | if not config_dir: 51 | raise RuntimeError( 52 | "Claude Desktop config directory not found. Please ensure Claude Desktop " 53 | "is installed and has been run at least once to initialize its configuration." 54 | ) 55 | 56 | config_file = config_dir / "claude_desktop_config.json" 57 | if not config_file.exists(): 58 | try: 59 | config_file.write_text("{}") 60 | except Exception as e: 61 | logger.error( 62 | "Failed to create Claude config file", 63 | extra={ 64 | "error": str(e), 65 | "config_file": str(config_file), 66 | }, 67 | ) 68 | return False 69 | 70 | try: 71 | config = json.loads(config_file.read_text()) 72 | if "mcpServers" not in config: 73 | config["mcpServers"] = {} 74 | 75 | # Always preserve existing env vars and merge with new ones 76 | if ( 77 | server_name in config["mcpServers"] 78 | and "env" in config["mcpServers"][server_name] 79 | ): 80 | existing_env = config["mcpServers"][server_name]["env"] 81 | if env_vars: 82 | # New vars take precedence over existing ones 83 | env_vars = {**existing_env, **env_vars} 84 | else: 85 | env_vars = existing_env 86 | 87 | # Build uv run command 88 | args = ["run"] 89 | 90 | # Collect all packages in a set to deduplicate 91 | packages = {"fastmcp"} 92 | if with_packages: 93 | packages.update(pkg for pkg in with_packages if pkg) 94 | 95 | # Add all packages with --with 96 | for pkg in sorted(packages): 97 | args.extend(["--with", pkg]) 98 | 99 | if with_editable: 100 | args.extend(["--with-editable", str(with_editable)]) 101 | 102 | # Convert file path to absolute before adding to command 103 | # Split off any :object suffix first 104 | if ":" in file_spec: 105 | file_path, server_object = file_spec.rsplit(":", 1) 106 | file_spec = f"{Path(file_path).resolve()}:{server_object}" 107 | else: 108 | file_spec = str(Path(file_spec).resolve()) 109 | 110 | # Add fastmcp run command 111 | args.extend(["fastmcp", "run", file_spec]) 112 | 113 | server_config = { 114 | "command": "uv", 115 | "args": args, 116 | } 117 | 118 | # Add environment variables if specified 119 | if env_vars: 120 | server_config["env"] = env_vars 121 | 122 | config["mcpServers"][server_name] = server_config 123 | 124 | config_file.write_text(json.dumps(config, indent=2)) 125 | logger.info( 126 | f"Added server '{server_name}' to Claude config", 127 | extra={"config_file": str(config_file)}, 128 | ) 129 | return True 130 | except Exception as e: 131 | logger.error( 132 | "Failed to update Claude config", 133 | extra={ 134 | "error": str(e), 135 | "config_file": str(config_file), 136 | }, 137 | ) 138 | return False 139 | -------------------------------------------------------------------------------- /src/fastmcp/cli/cli.py: -------------------------------------------------------------------------------- 1 | """FastMCP CLI tools.""" 2 | 3 | import importlib.metadata 4 | import importlib.util 5 | import os 6 | import subprocess 7 | import sys 8 | from pathlib import Path 9 | from typing import Dict, Optional, Tuple 10 | 11 | import dotenv 12 | import typer 13 | from typing_extensions import Annotated 14 | 15 | from fastmcp.cli import claude 16 | from fastmcp.utilities.logging import get_logger 17 | 18 | logger = get_logger("cli") 19 | 20 | app = typer.Typer( 21 | name="fastmcp", 22 | help="FastMCP development tools", 23 | add_completion=False, 24 | no_args_is_help=True, # Show help if no args provided 25 | ) 26 | 27 | 28 | def _get_npx_command(): 29 | """Get the correct npx command for the current platform.""" 30 | if sys.platform == "win32": 31 | # Try both npx.cmd and npx.exe on Windows 32 | for cmd in ["npx.cmd", "npx.exe", "npx"]: 33 | try: 34 | subprocess.run( 35 | [cmd, "--version"], check=True, capture_output=True, shell=True 36 | ) 37 | return cmd 38 | except subprocess.CalledProcessError: 39 | continue 40 | return None 41 | return "npx" # On Unix-like systems, just use npx 42 | 43 | 44 | def _parse_env_var(env_var: str) -> Tuple[str, str]: 45 | """Parse environment variable string in format KEY=VALUE.""" 46 | if "=" not in env_var: 47 | logger.error( 48 | f"Invalid environment variable format: {env_var}. Must be KEY=VALUE" 49 | ) 50 | sys.exit(1) 51 | key, value = env_var.split("=", 1) 52 | return key.strip(), value.strip() 53 | 54 | 55 | def _build_uv_command( 56 | file_spec: str, 57 | with_editable: Optional[Path] = None, 58 | with_packages: Optional[list[str]] = None, 59 | ) -> list[str]: 60 | """Build the uv run command that runs a FastMCP server through fastmcp run.""" 61 | cmd = ["uv"] 62 | 63 | cmd.extend(["run", "--with", "fastmcp"]) 64 | 65 | if with_editable: 66 | cmd.extend(["--with-editable", str(with_editable)]) 67 | 68 | if with_packages: 69 | for pkg in with_packages: 70 | if pkg: 71 | cmd.extend(["--with", pkg]) 72 | 73 | # Add fastmcp run command 74 | cmd.extend(["fastmcp", "run", file_spec]) 75 | return cmd 76 | 77 | 78 | def _parse_file_path(file_spec: str) -> Tuple[Path, Optional[str]]: 79 | """Parse a file path that may include a server object specification. 80 | 81 | Args: 82 | file_spec: Path to file, optionally with :object suffix 83 | 84 | Returns: 85 | Tuple of (file_path, server_object) 86 | """ 87 | # First check if we have a Windows path (e.g., C:\...) 88 | has_windows_drive = len(file_spec) > 1 and file_spec[1] == ":" 89 | 90 | # Split on the last colon, but only if it's not part of the Windows drive letter 91 | # and there's actually another colon in the string after the drive letter 92 | if ":" in (file_spec[2:] if has_windows_drive else file_spec): 93 | file_str, server_object = file_spec.rsplit(":", 1) 94 | else: 95 | file_str, server_object = file_spec, None 96 | 97 | # Resolve the file path 98 | file_path = Path(file_str).expanduser().resolve() 99 | if not file_path.exists(): 100 | logger.error(f"File not found: {file_path}") 101 | sys.exit(1) 102 | if not file_path.is_file(): 103 | logger.error(f"Not a file: {file_path}") 104 | sys.exit(1) 105 | 106 | return file_path, server_object 107 | 108 | 109 | def _import_server(file: Path, server_object: Optional[str] = None): 110 | """Import a FastMCP server from a file. 111 | 112 | Args: 113 | file: Path to the file 114 | server_object: Optional object name in format "module:object" or just "object" 115 | 116 | Returns: 117 | The server object 118 | """ 119 | # Add parent directory to Python path so imports can be resolved 120 | file_dir = str(file.parent) 121 | if file_dir not in sys.path: 122 | sys.path.insert(0, file_dir) 123 | 124 | # Import the module 125 | spec = importlib.util.spec_from_file_location("server_module", file) 126 | if not spec or not spec.loader: 127 | logger.error("Could not load module", extra={"file": str(file)}) 128 | sys.exit(1) 129 | 130 | module = importlib.util.module_from_spec(spec) 131 | spec.loader.exec_module(module) 132 | 133 | # If no object specified, try common server names 134 | if not server_object: 135 | # Look for the most common server object names 136 | for name in ["mcp", "server", "app"]: 137 | if hasattr(module, name): 138 | return getattr(module, name) 139 | 140 | logger.error( 141 | f"No server object found in {file}. Please either:\n" 142 | "1. Use a standard variable name (mcp, server, or app)\n" 143 | "2. Specify the object name with file:object syntax", 144 | extra={"file": str(file)}, 145 | ) 146 | sys.exit(1) 147 | 148 | # Handle module:object syntax 149 | if ":" in server_object: 150 | module_name, object_name = server_object.split(":", 1) 151 | try: 152 | server_module = importlib.import_module(module_name) 153 | server = getattr(server_module, object_name, None) 154 | except ImportError: 155 | logger.error( 156 | f"Could not import module '{module_name}'", 157 | extra={"file": str(file)}, 158 | ) 159 | sys.exit(1) 160 | else: 161 | # Just object name 162 | server = getattr(module, server_object, None) 163 | 164 | if server is None: 165 | logger.error( 166 | f"Server object '{server_object}' not found", 167 | extra={"file": str(file)}, 168 | ) 169 | sys.exit(1) 170 | 171 | return server 172 | 173 | 174 | @app.command() 175 | def version() -> None: 176 | """Show the FastMCP version.""" 177 | try: 178 | version = importlib.metadata.version("fastmcp") 179 | print(f"FastMCP version {version}") 180 | except importlib.metadata.PackageNotFoundError: 181 | print("FastMCP version unknown (package not installed)") 182 | sys.exit(1) 183 | 184 | 185 | @app.command() 186 | def dev( 187 | file_spec: str = typer.Argument( 188 | ..., 189 | help="Python file to run, optionally with :object suffix", 190 | ), 191 | with_editable: Annotated[ 192 | Optional[Path], 193 | typer.Option( 194 | "--with-editable", 195 | "-e", 196 | help="Directory containing pyproject.toml to install in editable mode", 197 | exists=True, 198 | file_okay=False, 199 | resolve_path=True, 200 | ), 201 | ] = None, 202 | with_packages: Annotated[ 203 | list[str], 204 | typer.Option( 205 | "--with", 206 | help="Additional packages to install", 207 | ), 208 | ] = [], 209 | ) -> None: 210 | """Run a FastMCP server with the MCP Inspector.""" 211 | file, server_object = _parse_file_path(file_spec) 212 | 213 | logger.debug( 214 | "Starting dev server", 215 | extra={ 216 | "file": str(file), 217 | "server_object": server_object, 218 | "with_editable": str(with_editable) if with_editable else None, 219 | "with_packages": with_packages, 220 | }, 221 | ) 222 | 223 | try: 224 | # Import server to get dependencies 225 | server = _import_server(file, server_object) 226 | if hasattr(server, "dependencies"): 227 | with_packages = list(set(with_packages + server.dependencies)) 228 | 229 | uv_cmd = _build_uv_command(file_spec, with_editable, with_packages) 230 | 231 | # Get the correct npx command 232 | npx_cmd = _get_npx_command() 233 | if not npx_cmd: 234 | logger.error( 235 | "npx not found. Please ensure Node.js and npm are properly installed " 236 | "and added to your system PATH." 237 | ) 238 | sys.exit(1) 239 | 240 | # Run the MCP Inspector command with shell=True on Windows 241 | shell = sys.platform == "win32" 242 | process = subprocess.run( 243 | [npx_cmd, "@modelcontextprotocol/inspector"] + uv_cmd, 244 | check=True, 245 | shell=shell, 246 | env=dict(os.environ.items()), # Convert to list of tuples for env update 247 | ) 248 | sys.exit(process.returncode) 249 | except subprocess.CalledProcessError as e: 250 | logger.error( 251 | "Dev server failed", 252 | extra={ 253 | "file": str(file), 254 | "error": str(e), 255 | "returncode": e.returncode, 256 | }, 257 | ) 258 | sys.exit(e.returncode) 259 | except FileNotFoundError: 260 | logger.error( 261 | "npx not found. Please ensure Node.js and npm are properly installed " 262 | "and added to your system PATH. You may need to restart your terminal " 263 | "after installation.", 264 | extra={"file": str(file)}, 265 | ) 266 | sys.exit(1) 267 | 268 | 269 | @app.command() 270 | def run( 271 | file_spec: str = typer.Argument( 272 | ..., 273 | help="Python file to run, optionally with :object suffix", 274 | ), 275 | transport: Annotated[ 276 | Optional[str], 277 | typer.Option( 278 | "--transport", 279 | "-t", 280 | help="Transport protocol to use (stdio or sse)", 281 | ), 282 | ] = None, 283 | ) -> None: 284 | """Run a FastMCP server. 285 | 286 | The server can be specified in two ways: 287 | 1. Module approach: server.py - runs the module directly, expecting a server.run() call 288 | 2. Import approach: server.py:app - imports and runs the specified server object 289 | 290 | Note: This command runs the server directly. You are responsible for ensuring 291 | all dependencies are available. For dependency management, use fastmcp install 292 | or fastmcp dev instead. 293 | """ 294 | file, server_object = _parse_file_path(file_spec) 295 | 296 | logger.debug( 297 | "Running server", 298 | extra={ 299 | "file": str(file), 300 | "server_object": server_object, 301 | "transport": transport, 302 | }, 303 | ) 304 | 305 | try: 306 | # Import and get server object 307 | server = _import_server(file, server_object) 308 | 309 | # Run the server 310 | kwargs = {} 311 | if transport: 312 | kwargs["transport"] = transport 313 | 314 | server.run(**kwargs) 315 | 316 | except Exception as e: 317 | logger.error( 318 | f"Failed to run server: {e}", 319 | extra={ 320 | "file": str(file), 321 | "error": str(e), 322 | }, 323 | ) 324 | sys.exit(1) 325 | 326 | 327 | @app.command() 328 | def install( 329 | file_spec: str = typer.Argument( 330 | ..., 331 | help="Python file to run, optionally with :object suffix", 332 | ), 333 | server_name: Annotated[ 334 | Optional[str], 335 | typer.Option( 336 | "--name", 337 | "-n", 338 | help="Custom name for the server (defaults to server's name attribute or file name)", 339 | ), 340 | ] = None, 341 | with_editable: Annotated[ 342 | Optional[Path], 343 | typer.Option( 344 | "--with-editable", 345 | "-e", 346 | help="Directory containing pyproject.toml to install in editable mode", 347 | exists=True, 348 | file_okay=False, 349 | resolve_path=True, 350 | ), 351 | ] = None, 352 | with_packages: Annotated[ 353 | list[str], 354 | typer.Option( 355 | "--with", 356 | help="Additional packages to install", 357 | ), 358 | ] = [], 359 | env_vars: Annotated[ 360 | list[str], 361 | typer.Option( 362 | "--env-var", 363 | "-e", 364 | help="Environment variables in KEY=VALUE format", 365 | ), 366 | ] = [], 367 | env_file: Annotated[ 368 | Optional[Path], 369 | typer.Option( 370 | "--env-file", 371 | "-f", 372 | help="Load environment variables from a .env file", 373 | exists=True, 374 | file_okay=True, 375 | dir_okay=False, 376 | resolve_path=True, 377 | ), 378 | ] = None, 379 | ) -> None: 380 | """Install a FastMCP server in the Claude desktop app. 381 | 382 | Environment variables are preserved once added and only updated if new values 383 | are explicitly provided. 384 | """ 385 | file, server_object = _parse_file_path(file_spec) 386 | 387 | logger.debug( 388 | "Installing server", 389 | extra={ 390 | "file": str(file), 391 | "server_name": server_name, 392 | "server_object": server_object, 393 | "with_editable": str(with_editable) if with_editable else None, 394 | "with_packages": with_packages, 395 | }, 396 | ) 397 | 398 | if not claude.get_claude_config_path(): 399 | logger.error("Claude app not found") 400 | sys.exit(1) 401 | 402 | # Try to import server to get its name, but fall back to file name if dependencies missing 403 | name = server_name 404 | server = None 405 | if not name: 406 | try: 407 | server = _import_server(file, server_object) 408 | name = server.name 409 | except (ImportError, ModuleNotFoundError) as e: 410 | logger.debug( 411 | "Could not import server (likely missing dependencies), using file name", 412 | extra={"error": str(e)}, 413 | ) 414 | name = file.stem 415 | 416 | # Get server dependencies if available 417 | server_dependencies = getattr(server, "dependencies", []) if server else [] 418 | if server_dependencies: 419 | with_packages = list(set(with_packages + server_dependencies)) 420 | 421 | # Process environment variables if provided 422 | env_dict: Optional[Dict[str, str]] = None 423 | if env_file or env_vars: 424 | env_dict = {} 425 | # Load from .env file if specified 426 | if env_file: 427 | try: 428 | env_dict |= { 429 | k: v 430 | for k, v in dotenv.dotenv_values(env_file).items() 431 | if v is not None 432 | } 433 | except Exception as e: 434 | logger.error(f"Failed to load .env file: {e}") 435 | sys.exit(1) 436 | 437 | # Add command line environment variables 438 | for env_var in env_vars: 439 | key, value = _parse_env_var(env_var) 440 | env_dict[key] = value 441 | 442 | if claude.update_claude_config( 443 | file_spec, 444 | name, 445 | with_editable=with_editable, 446 | with_packages=with_packages, 447 | env_vars=env_dict, 448 | ): 449 | logger.info(f"Successfully installed {name} in Claude app") 450 | else: 451 | logger.error(f"Failed to install {name} in Claude app") 452 | sys.exit(1) 453 | -------------------------------------------------------------------------------- /src/fastmcp/exceptions.py: -------------------------------------------------------------------------------- 1 | """Custom exceptions for FastMCP.""" 2 | 3 | 4 | class FastMCPError(Exception): 5 | """Base error for FastMCP.""" 6 | 7 | 8 | class ValidationError(FastMCPError): 9 | """Error in validating parameters or return values.""" 10 | 11 | 12 | class ResourceError(FastMCPError): 13 | """Error in resource operations.""" 14 | 15 | 16 | class ToolError(FastMCPError): 17 | """Error in tool operations.""" 18 | 19 | 20 | class InvalidSignature(Exception): 21 | """Invalid signature for use with FastMCP.""" 22 | -------------------------------------------------------------------------------- /src/fastmcp/prompts/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Prompt 2 | from .manager import PromptManager 3 | 4 | __all__ = ["Prompt", "PromptManager"] 5 | -------------------------------------------------------------------------------- /src/fastmcp/prompts/base.py: -------------------------------------------------------------------------------- 1 | """Base classes for FastMCP prompts.""" 2 | 3 | import json 4 | from typing import Any, Callable, Dict, Literal, Optional, Sequence, Awaitable 5 | import inspect 6 | 7 | from pydantic import BaseModel, Field, TypeAdapter, validate_call 8 | from mcp.types import TextContent, ImageContent, EmbeddedResource 9 | import pydantic_core 10 | 11 | CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource 12 | 13 | 14 | class Message(BaseModel): 15 | """Base class for all prompt messages.""" 16 | 17 | role: Literal["user", "assistant"] 18 | content: CONTENT_TYPES 19 | 20 | def __init__(self, content: str | CONTENT_TYPES, **kwargs): 21 | if isinstance(content, str): 22 | content = TextContent(type="text", text=content) 23 | super().__init__(content=content, **kwargs) 24 | 25 | 26 | class UserMessage(Message): 27 | """A message from the user.""" 28 | 29 | role: Literal["user"] = "user" 30 | 31 | def __init__(self, content: str | CONTENT_TYPES, **kwargs): 32 | super().__init__(content=content, **kwargs) 33 | 34 | 35 | class AssistantMessage(Message): 36 | """A message from the assistant.""" 37 | 38 | role: Literal["assistant"] = "assistant" 39 | 40 | def __init__(self, content: str | CONTENT_TYPES, **kwargs): 41 | super().__init__(content=content, **kwargs) 42 | 43 | 44 | message_validator = TypeAdapter(UserMessage | AssistantMessage) 45 | 46 | SyncPromptResult = ( 47 | str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]] 48 | ) 49 | PromptResult = SyncPromptResult | Awaitable[SyncPromptResult] 50 | 51 | 52 | class PromptArgument(BaseModel): 53 | """An argument that can be passed to a prompt.""" 54 | 55 | name: str = Field(description="Name of the argument") 56 | description: str | None = Field( 57 | None, description="Description of what the argument does" 58 | ) 59 | required: bool = Field( 60 | default=False, description="Whether the argument is required" 61 | ) 62 | 63 | 64 | class Prompt(BaseModel): 65 | """A prompt template that can be rendered with parameters.""" 66 | 67 | name: str = Field(description="Name of the prompt") 68 | description: str | None = Field( 69 | None, description="Description of what the prompt does" 70 | ) 71 | arguments: list[PromptArgument] | None = Field( 72 | None, description="Arguments that can be passed to the prompt" 73 | ) 74 | fn: Callable = Field(exclude=True) 75 | 76 | @classmethod 77 | def from_function( 78 | cls, 79 | fn: Callable[..., PromptResult], 80 | name: Optional[str] = None, 81 | description: Optional[str] = None, 82 | ) -> "Prompt": 83 | """Create a Prompt from a function. 84 | 85 | The function can return: 86 | - A string (converted to a message) 87 | - A Message object 88 | - A dict (converted to a message) 89 | - A sequence of any of the above 90 | """ 91 | func_name = name or fn.__name__ 92 | 93 | if func_name == "<lambda>": 94 | raise ValueError("You must provide a name for lambda functions") 95 | 96 | # Get schema from TypeAdapter - will fail if function isn't properly typed 97 | parameters = TypeAdapter(fn).json_schema() 98 | 99 | # Convert parameters to PromptArguments 100 | arguments = [] 101 | if "properties" in parameters: 102 | for param_name, param in parameters["properties"].items(): 103 | required = param_name in parameters.get("required", []) 104 | arguments.append( 105 | PromptArgument( 106 | name=param_name, 107 | description=param.get("description"), 108 | required=required, 109 | ) 110 | ) 111 | 112 | # ensure the arguments are properly cast 113 | fn = validate_call(fn) 114 | 115 | return cls( 116 | name=func_name, 117 | description=description or fn.__doc__ or "", 118 | arguments=arguments, 119 | fn=fn, 120 | ) 121 | 122 | async def render(self, arguments: Optional[Dict[str, Any]] = None) -> list[Message]: 123 | """Render the prompt with arguments.""" 124 | # Validate required arguments 125 | if self.arguments: 126 | required = {arg.name for arg in self.arguments if arg.required} 127 | provided = set(arguments or {}) 128 | missing = required - provided 129 | if missing: 130 | raise ValueError(f"Missing required arguments: {missing}") 131 | 132 | try: 133 | # Call function and check if result is a coroutine 134 | result = self.fn(**(arguments or {})) 135 | if inspect.iscoroutine(result): 136 | result = await result 137 | 138 | # Validate messages 139 | if not isinstance(result, (list, tuple)): 140 | result = [result] 141 | 142 | # Convert result to messages 143 | messages = [] 144 | for msg in result: 145 | try: 146 | if isinstance(msg, Message): 147 | messages.append(msg) 148 | elif isinstance(msg, dict): 149 | msg = message_validator.validate_python(msg) 150 | messages.append(msg) 151 | elif isinstance(msg, str): 152 | messages.append( 153 | UserMessage(content=TextContent(type="text", text=msg)) 154 | ) 155 | else: 156 | msg = json.dumps(pydantic_core.to_jsonable_python(msg)) 157 | messages.append(Message(role="user", content=msg)) 158 | except Exception: 159 | raise ValueError( 160 | f"Could not convert prompt result to message: {msg}" 161 | ) 162 | 163 | return messages 164 | except Exception as e: 165 | raise ValueError(f"Error rendering prompt {self.name}: {e}") 166 | -------------------------------------------------------------------------------- /src/fastmcp/prompts/manager.py: -------------------------------------------------------------------------------- 1 | """Prompt management functionality.""" 2 | 3 | from typing import Any, Dict, Optional 4 | 5 | from fastmcp.prompts.base import Message, Prompt 6 | from fastmcp.utilities.logging import get_logger 7 | 8 | logger = get_logger(__name__) 9 | 10 | 11 | class PromptManager: 12 | """Manages FastMCP prompts.""" 13 | 14 | def __init__(self, warn_on_duplicate_prompts: bool = True): 15 | self._prompts: Dict[str, Prompt] = {} 16 | self.warn_on_duplicate_prompts = warn_on_duplicate_prompts 17 | 18 | def get_prompt(self, name: str) -> Optional[Prompt]: 19 | """Get prompt by name.""" 20 | return self._prompts.get(name) 21 | 22 | def list_prompts(self) -> list[Prompt]: 23 | """List all registered prompts.""" 24 | return list(self._prompts.values()) 25 | 26 | def add_prompt( 27 | self, 28 | prompt: Prompt, 29 | ) -> Prompt: 30 | """Add a prompt to the manager.""" 31 | 32 | # Check for duplicates 33 | existing = self._prompts.get(prompt.name) 34 | if existing: 35 | if self.warn_on_duplicate_prompts: 36 | logger.warning(f"Prompt already exists: {prompt.name}") 37 | return existing 38 | 39 | self._prompts[prompt.name] = prompt 40 | return prompt 41 | 42 | async def render_prompt( 43 | self, name: str, arguments: Optional[Dict[str, Any]] = None 44 | ) -> list[Message]: 45 | """Render a prompt by name with arguments.""" 46 | prompt = self.get_prompt(name) 47 | if not prompt: 48 | raise ValueError(f"Unknown prompt: {name}") 49 | 50 | return await prompt.render(arguments) 51 | -------------------------------------------------------------------------------- /src/fastmcp/prompts/prompt_manager.py: -------------------------------------------------------------------------------- 1 | """Prompt management functionality.""" 2 | 3 | from typing import Dict, Optional 4 | 5 | 6 | from fastmcp.prompts.base import Prompt 7 | from fastmcp.utilities.logging import get_logger 8 | 9 | logger = get_logger(__name__) 10 | 11 | 12 | class PromptManager: 13 | """Manages FastMCP prompts.""" 14 | 15 | def __init__(self, warn_on_duplicate_prompts: bool = True): 16 | self._prompts: Dict[str, Prompt] = {} 17 | self.warn_on_duplicate_prompts = warn_on_duplicate_prompts 18 | 19 | def add_prompt(self, prompt: Prompt) -> Prompt: 20 | """Add a prompt to the manager.""" 21 | logger.debug(f"Adding prompt: {prompt.name}") 22 | existing = self._prompts.get(prompt.name) 23 | if existing: 24 | if self.warn_on_duplicate_prompts: 25 | logger.warning(f"Prompt already exists: {prompt.name}") 26 | return existing 27 | self._prompts[prompt.name] = prompt 28 | return prompt 29 | 30 | def get_prompt(self, name: str) -> Optional[Prompt]: 31 | """Get prompt by name.""" 32 | return self._prompts.get(name) 33 | 34 | def list_prompts(self) -> list[Prompt]: 35 | """List all registered prompts.""" 36 | return list(self._prompts.values()) 37 | -------------------------------------------------------------------------------- /src/fastmcp/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlowin/fastmcp/80c328b3dc0b949f010456ee0e85cc5c90e3305f/src/fastmcp/py.typed -------------------------------------------------------------------------------- /src/fastmcp/resources/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Resource 2 | from .types import ( 3 | TextResource, 4 | BinaryResource, 5 | FunctionResource, 6 | FileResource, 7 | HttpResource, 8 | DirectoryResource, 9 | ) 10 | from .templates import ResourceTemplate 11 | from .resource_manager import ResourceManager 12 | 13 | __all__ = [ 14 | "Resource", 15 | "TextResource", 16 | "BinaryResource", 17 | "FunctionResource", 18 | "FileResource", 19 | "HttpResource", 20 | "DirectoryResource", 21 | "ResourceTemplate", 22 | "ResourceManager", 23 | ] 24 | -------------------------------------------------------------------------------- /src/fastmcp/resources/base.py: -------------------------------------------------------------------------------- 1 | """Base classes and interfaces for FastMCP resources.""" 2 | 3 | import abc 4 | from typing import Union, Annotated 5 | 6 | from pydantic import ( 7 | AnyUrl, 8 | BaseModel, 9 | ConfigDict, 10 | Field, 11 | UrlConstraints, 12 | ValidationInfo, 13 | field_validator, 14 | ) 15 | 16 | 17 | class Resource(BaseModel, abc.ABC): 18 | """Base class for all resources.""" 19 | 20 | model_config = ConfigDict(validate_default=True) 21 | 22 | uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field( 23 | default=..., description="URI of the resource" 24 | ) 25 | name: str | None = Field(description="Name of the resource", default=None) 26 | description: str | None = Field( 27 | description="Description of the resource", default=None 28 | ) 29 | mime_type: str = Field( 30 | default="text/plain", 31 | description="MIME type of the resource content", 32 | pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$", 33 | ) 34 | 35 | @field_validator("name", mode="before") 36 | @classmethod 37 | def set_default_name(cls, name: str | None, info: ValidationInfo) -> str: 38 | """Set default name from URI if not provided.""" 39 | if name: 40 | return name 41 | if uri := info.data.get("uri"): 42 | return str(uri) 43 | raise ValueError("Either name or uri must be provided") 44 | 45 | @abc.abstractmethod 46 | async def read(self) -> Union[str, bytes]: 47 | """Read the resource content.""" 48 | pass 49 | -------------------------------------------------------------------------------- /src/fastmcp/resources/resource_manager.py: -------------------------------------------------------------------------------- 1 | """Resource manager functionality.""" 2 | 3 | from typing import Callable, Dict, Optional, Union 4 | 5 | from pydantic import AnyUrl 6 | 7 | from fastmcp.resources.base import Resource 8 | from fastmcp.resources.templates import ResourceTemplate 9 | from fastmcp.utilities.logging import get_logger 10 | 11 | logger = get_logger(__name__) 12 | 13 | 14 | class ResourceManager: 15 | """Manages FastMCP resources.""" 16 | 17 | def __init__(self, warn_on_duplicate_resources: bool = True): 18 | self._resources: Dict[str, Resource] = {} 19 | self._templates: Dict[str, ResourceTemplate] = {} 20 | self.warn_on_duplicate_resources = warn_on_duplicate_resources 21 | 22 | def add_resource(self, resource: Resource) -> Resource: 23 | """Add a resource to the manager. 24 | 25 | Args: 26 | resource: A Resource instance to add 27 | 28 | Returns: 29 | The added resource. If a resource with the same URI already exists, 30 | returns the existing resource. 31 | """ 32 | logger.debug( 33 | "Adding resource", 34 | extra={ 35 | "uri": resource.uri, 36 | "type": type(resource).__name__, 37 | "name": resource.name, 38 | }, 39 | ) 40 | existing = self._resources.get(str(resource.uri)) 41 | if existing: 42 | if self.warn_on_duplicate_resources: 43 | logger.warning(f"Resource already exists: {resource.uri}") 44 | return existing 45 | self._resources[str(resource.uri)] = resource 46 | return resource 47 | 48 | def add_template( 49 | self, 50 | fn: Callable, 51 | uri_template: str, 52 | name: Optional[str] = None, 53 | description: Optional[str] = None, 54 | mime_type: Optional[str] = None, 55 | ) -> ResourceTemplate: 56 | """Add a template from a function.""" 57 | template = ResourceTemplate.from_function( 58 | fn, 59 | uri_template=uri_template, 60 | name=name, 61 | description=description, 62 | mime_type=mime_type, 63 | ) 64 | self._templates[template.uri_template] = template 65 | return template 66 | 67 | async def get_resource(self, uri: Union[AnyUrl, str]) -> Optional[Resource]: 68 | """Get resource by URI, checking concrete resources first, then templates.""" 69 | uri_str = str(uri) 70 | logger.debug("Getting resource", extra={"uri": uri_str}) 71 | 72 | # First check concrete resources 73 | if resource := self._resources.get(uri_str): 74 | return resource 75 | 76 | # Then check templates 77 | for template in self._templates.values(): 78 | if params := template.matches(uri_str): 79 | try: 80 | return await template.create_resource(uri_str, params) 81 | except Exception as e: 82 | raise ValueError(f"Error creating resource from template: {e}") 83 | 84 | raise ValueError(f"Unknown resource: {uri}") 85 | 86 | def list_resources(self) -> list[Resource]: 87 | """List all registered resources.""" 88 | logger.debug("Listing resources", extra={"count": len(self._resources)}) 89 | return list(self._resources.values()) 90 | 91 | def list_templates(self) -> list[ResourceTemplate]: 92 | """List all registered templates.""" 93 | logger.debug("Listing templates", extra={"count": len(self._templates)}) 94 | return list(self._templates.values()) 95 | -------------------------------------------------------------------------------- /src/fastmcp/resources/templates.py: -------------------------------------------------------------------------------- 1 | """Resource template functionality.""" 2 | 3 | import inspect 4 | import re 5 | from typing import Any, Callable, Dict, Optional 6 | 7 | from pydantic import BaseModel, Field, TypeAdapter, validate_call 8 | 9 | from fastmcp.resources.types import FunctionResource, Resource 10 | 11 | 12 | class ResourceTemplate(BaseModel): 13 | """A template for dynamically creating resources.""" 14 | 15 | uri_template: str = Field( 16 | description="URI template with parameters (e.g. weather://{city}/current)" 17 | ) 18 | name: str = Field(description="Name of the resource") 19 | description: str | None = Field(description="Description of what the resource does") 20 | mime_type: str = Field( 21 | default="text/plain", description="MIME type of the resource content" 22 | ) 23 | fn: Callable = Field(exclude=True) 24 | parameters: dict = Field(description="JSON schema for function parameters") 25 | 26 | @classmethod 27 | def from_function( 28 | cls, 29 | fn: Callable, 30 | uri_template: str, 31 | name: Optional[str] = None, 32 | description: Optional[str] = None, 33 | mime_type: Optional[str] = None, 34 | ) -> "ResourceTemplate": 35 | """Create a template from a function.""" 36 | func_name = name or fn.__name__ 37 | if func_name == "<lambda>": 38 | raise ValueError("You must provide a name for lambda functions") 39 | 40 | # Get schema from TypeAdapter - will fail if function isn't properly typed 41 | parameters = TypeAdapter(fn).json_schema() 42 | 43 | # ensure the arguments are properly cast 44 | fn = validate_call(fn) 45 | 46 | return cls( 47 | uri_template=uri_template, 48 | name=func_name, 49 | description=description or fn.__doc__ or "", 50 | mime_type=mime_type or "text/plain", 51 | fn=fn, 52 | parameters=parameters, 53 | ) 54 | 55 | def matches(self, uri: str) -> Optional[Dict[str, Any]]: 56 | """Check if URI matches template and extract parameters.""" 57 | # Convert template to regex pattern 58 | pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)") 59 | match = re.match(f"^{pattern}$", uri) 60 | if match: 61 | return match.groupdict() 62 | return None 63 | 64 | async def create_resource(self, uri: str, params: Dict[str, Any]) -> Resource: 65 | """Create a resource from the template with the given parameters.""" 66 | try: 67 | # Call function and check if result is a coroutine 68 | result = self.fn(**params) 69 | if inspect.iscoroutine(result): 70 | result = await result 71 | 72 | return FunctionResource( 73 | uri=uri, # type: ignore 74 | name=self.name, 75 | description=self.description, 76 | mime_type=self.mime_type, 77 | fn=lambda: result, # Capture result in closure 78 | ) 79 | except Exception as e: 80 | raise ValueError(f"Error creating resource from template: {e}") 81 | -------------------------------------------------------------------------------- /src/fastmcp/resources/types.py: -------------------------------------------------------------------------------- 1 | """Concrete resource implementations.""" 2 | 3 | import asyncio 4 | import json 5 | from pathlib import Path 6 | from typing import Any, Callable, Union 7 | 8 | import httpx 9 | import pydantic.json 10 | import pydantic_core 11 | from pydantic import Field, ValidationInfo 12 | 13 | from fastmcp.resources.base import Resource 14 | 15 | 16 | class TextResource(Resource): 17 | """A resource that reads from a string.""" 18 | 19 | text: str = Field(description="Text content of the resource") 20 | 21 | async def read(self) -> str: 22 | """Read the text content.""" 23 | return self.text 24 | 25 | 26 | class BinaryResource(Resource): 27 | """A resource that reads from bytes.""" 28 | 29 | data: bytes = Field(description="Binary content of the resource") 30 | 31 | async def read(self) -> bytes: 32 | """Read the binary content.""" 33 | return self.data 34 | 35 | 36 | class FunctionResource(Resource): 37 | """A resource that defers data loading by wrapping a function. 38 | 39 | The function is only called when the resource is read, allowing for lazy loading 40 | of potentially expensive data. This is particularly useful when listing resources, 41 | as the function won't be called until the resource is actually accessed. 42 | 43 | The function can return: 44 | - str for text content (default) 45 | - bytes for binary content 46 | - other types will be converted to JSON 47 | """ 48 | 49 | fn: Callable[[], Any] = Field(exclude=True) 50 | 51 | async def read(self) -> Union[str, bytes]: 52 | """Read the resource by calling the wrapped function.""" 53 | try: 54 | result = self.fn() 55 | if isinstance(result, Resource): 56 | return await result.read() 57 | if isinstance(result, bytes): 58 | return result 59 | if isinstance(result, str): 60 | return result 61 | try: 62 | return json.dumps(pydantic_core.to_jsonable_python(result)) 63 | except (TypeError, pydantic_core.PydanticSerializationError): 64 | # If JSON serialization fails, try str() 65 | return str(result) 66 | except Exception as e: 67 | raise ValueError(f"Error reading resource {self.uri}: {e}") 68 | 69 | 70 | class FileResource(Resource): 71 | """A resource that reads from a file. 72 | 73 | Set is_binary=True to read file as binary data instead of text. 74 | """ 75 | 76 | path: Path = Field(description="Path to the file") 77 | is_binary: bool = Field( 78 | default=False, 79 | description="Whether to read the file as binary data", 80 | ) 81 | mime_type: str = Field( 82 | default="text/plain", 83 | description="MIME type of the resource content", 84 | ) 85 | 86 | @pydantic.field_validator("path") 87 | @classmethod 88 | def validate_absolute_path(cls, path: Path) -> Path: 89 | """Ensure path is absolute.""" 90 | if not path.is_absolute(): 91 | raise ValueError("Path must be absolute") 92 | return path 93 | 94 | @pydantic.field_validator("is_binary") 95 | @classmethod 96 | def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool: 97 | """Set is_binary based on mime_type if not explicitly set.""" 98 | if is_binary: 99 | return True 100 | mime_type = info.data.get("mime_type", "text/plain") 101 | return not mime_type.startswith("text/") 102 | 103 | async def read(self) -> Union[str, bytes]: 104 | """Read the file content.""" 105 | try: 106 | if self.is_binary: 107 | return await asyncio.to_thread(self.path.read_bytes) 108 | return await asyncio.to_thread(self.path.read_text) 109 | except Exception as e: 110 | raise ValueError(f"Error reading file {self.path}: {e}") 111 | 112 | 113 | class HttpResource(Resource): 114 | """A resource that reads from an HTTP endpoint.""" 115 | 116 | url: str = Field(description="URL to fetch content from") 117 | mime_type: str | None = Field( 118 | default="application/json", description="MIME type of the resource content" 119 | ) 120 | 121 | async def read(self) -> Union[str, bytes]: 122 | """Read the HTTP content.""" 123 | async with httpx.AsyncClient() as client: 124 | response = await client.get(self.url) 125 | response.raise_for_status() 126 | return response.text 127 | 128 | 129 | class DirectoryResource(Resource): 130 | """A resource that lists files in a directory.""" 131 | 132 | path: Path = Field(description="Path to the directory") 133 | recursive: bool = Field( 134 | default=False, description="Whether to list files recursively" 135 | ) 136 | pattern: str | None = Field( 137 | default=None, description="Optional glob pattern to filter files" 138 | ) 139 | mime_type: str | None = Field( 140 | default="application/json", description="MIME type of the resource content" 141 | ) 142 | 143 | @pydantic.field_validator("path") 144 | @classmethod 145 | def validate_absolute_path(cls, path: Path) -> Path: 146 | """Ensure path is absolute.""" 147 | if not path.is_absolute(): 148 | raise ValueError("Path must be absolute") 149 | return path 150 | 151 | def list_files(self) -> list[Path]: 152 | """List files in the directory.""" 153 | if not self.path.exists(): 154 | raise FileNotFoundError(f"Directory not found: {self.path}") 155 | if not self.path.is_dir(): 156 | raise NotADirectoryError(f"Not a directory: {self.path}") 157 | 158 | try: 159 | if self.pattern: 160 | return ( 161 | list(self.path.glob(self.pattern)) 162 | if not self.recursive 163 | else list(self.path.rglob(self.pattern)) 164 | ) 165 | return ( 166 | list(self.path.glob("*")) 167 | if not self.recursive 168 | else list(self.path.rglob("*")) 169 | ) 170 | except Exception as e: 171 | raise ValueError(f"Error listing directory {self.path}: {e}") 172 | 173 | async def read(self) -> str: # Always returns JSON string 174 | """Read the directory listing.""" 175 | try: 176 | files = await asyncio.to_thread(self.list_files) 177 | file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()] 178 | return json.dumps({"files": file_list}, indent=2) 179 | except Exception as e: 180 | raise ValueError(f"Error reading directory {self.path}: {e}") 181 | -------------------------------------------------------------------------------- /src/fastmcp/server.py: -------------------------------------------------------------------------------- 1 | """FastMCP - A more ergonomic interface for MCP servers.""" 2 | 3 | import asyncio 4 | import functools 5 | import inspect 6 | import json 7 | import re 8 | from itertools import chain 9 | from typing import Any, Callable, Dict, Literal, Sequence, TypeVar, ParamSpec 10 | 11 | import pydantic_core 12 | from pydantic import Field 13 | import uvicorn 14 | from mcp.server import Server as MCPServer 15 | from mcp.server.sse import SseServerTransport 16 | from mcp.server.stdio import stdio_server 17 | from mcp.shared.context import RequestContext 18 | from mcp.types import ( 19 | EmbeddedResource, 20 | GetPromptResult, 21 | ImageContent, 22 | TextContent, 23 | ) 24 | from mcp.types import ( 25 | Prompt as MCPPrompt, 26 | PromptArgument as MCPPromptArgument, 27 | ) 28 | from mcp.types import ( 29 | Resource as MCPResource, 30 | ) 31 | from mcp.types import ( 32 | ResourceTemplate as MCPResourceTemplate, 33 | ) 34 | from mcp.types import ( 35 | Tool as MCPTool, 36 | ) 37 | from pydantic import BaseModel 38 | from pydantic.networks import AnyUrl 39 | from pydantic_settings import BaseSettings, SettingsConfigDict 40 | 41 | from fastmcp.exceptions import ResourceError 42 | from fastmcp.prompts import Prompt, PromptManager 43 | from fastmcp.prompts.base import PromptResult 44 | from fastmcp.resources import FunctionResource, Resource, ResourceManager 45 | from fastmcp.tools import ToolManager 46 | from fastmcp.utilities.logging import configure_logging, get_logger 47 | from fastmcp.utilities.types import Image 48 | 49 | logger = get_logger(__name__) 50 | 51 | P = ParamSpec("P") 52 | R = TypeVar("R") 53 | R_PromptResult = TypeVar("R_PromptResult", bound=PromptResult) 54 | 55 | 56 | class Settings(BaseSettings): 57 | """FastMCP server settings. 58 | 59 | All settings can be configured via environment variables with the prefix FASTMCP_. 60 | For example, FASTMCP_DEBUG=true will set debug=True. 61 | """ 62 | 63 | model_config: SettingsConfigDict = SettingsConfigDict( 64 | env_prefix="FASTMCP_", 65 | env_file=".env", 66 | extra="ignore", 67 | ) 68 | 69 | # Server settings 70 | debug: bool = False 71 | log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO" 72 | 73 | # HTTP settings 74 | host: str = "0.0.0.0" 75 | port: int = 8000 76 | 77 | # resource settings 78 | warn_on_duplicate_resources: bool = True 79 | 80 | # tool settings 81 | warn_on_duplicate_tools: bool = True 82 | 83 | # prompt settings 84 | warn_on_duplicate_prompts: bool = True 85 | 86 | dependencies: list[str] = Field( 87 | default_factory=list, 88 | description="List of dependencies to install in the server environment", 89 | ) 90 | 91 | 92 | class FastMCP: 93 | def __init__(self, name: str | None = None, **settings: Any): 94 | self.settings = Settings(**settings) 95 | self._mcp_server = MCPServer(name=name or "FastMCP") 96 | self._tool_manager = ToolManager( 97 | warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools 98 | ) 99 | self._resource_manager = ResourceManager( 100 | warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources 101 | ) 102 | self._prompt_manager = PromptManager( 103 | warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts 104 | ) 105 | self.dependencies = self.settings.dependencies 106 | 107 | # Set up MCP protocol handlers 108 | self._setup_handlers() 109 | 110 | # Configure logging 111 | configure_logging(self.settings.log_level) 112 | 113 | @property 114 | def name(self) -> str: 115 | return self._mcp_server.name 116 | 117 | def run(self, transport: Literal["stdio", "sse"] = "stdio") -> None: 118 | """Run the FastMCP server. Note this is a synchronous function. 119 | 120 | Args: 121 | transport: Transport protocol to use ("stdio" or "sse") 122 | """ 123 | TRANSPORTS = Literal["stdio", "sse"] 124 | if transport not in TRANSPORTS.__args__: # type: ignore 125 | raise ValueError(f"Unknown transport: {transport}") 126 | 127 | if transport == "stdio": 128 | asyncio.run(self.run_stdio_async()) 129 | else: # transport == "sse" 130 | asyncio.run(self.run_sse_async()) 131 | 132 | def _setup_handlers(self) -> None: 133 | """Set up core MCP protocol handlers.""" 134 | self._mcp_server.list_tools()(self.list_tools) 135 | self._mcp_server.call_tool()(self.call_tool) 136 | self._mcp_server.list_resources()(self.list_resources) 137 | self._mcp_server.read_resource()(self.read_resource) 138 | self._mcp_server.list_prompts()(self.list_prompts) 139 | self._mcp_server.get_prompt()(self.get_prompt) 140 | # TODO: This has not been added to MCP yet, see https://github.com/jlowin/fastmcp/issues/10 141 | # self._mcp_server.list_resource_templates()(self.list_resource_templates) 142 | 143 | async def list_tools(self) -> list[MCPTool]: 144 | """List all available tools.""" 145 | tools = self._tool_manager.list_tools() 146 | return [ 147 | MCPTool( 148 | name=info.name, 149 | description=info.description, 150 | inputSchema=info.parameters, 151 | ) 152 | for info in tools 153 | ] 154 | 155 | def get_context(self) -> "Context": 156 | """ 157 | Returns a Context object. Note that the context will only be valid 158 | during a request; outside a request, most methods will error. 159 | """ 160 | try: 161 | request_context = self._mcp_server.request_context 162 | except LookupError: 163 | request_context = None 164 | return Context(request_context=request_context, fastmcp=self) 165 | 166 | async def call_tool( 167 | self, name: str, arguments: dict 168 | ) -> Sequence[TextContent | ImageContent | EmbeddedResource]: 169 | """Call a tool by name with arguments.""" 170 | context = self.get_context() 171 | result = await self._tool_manager.call_tool(name, arguments, context=context) 172 | converted_result = _convert_to_content(result) 173 | return converted_result 174 | 175 | async def list_resources(self) -> list[MCPResource]: 176 | """List all available resources.""" 177 | 178 | resources = self._resource_manager.list_resources() 179 | return [ 180 | MCPResource( 181 | uri=resource.uri, 182 | name=resource.name or "", 183 | description=resource.description, 184 | mimeType=resource.mime_type, 185 | ) 186 | for resource in resources 187 | ] 188 | 189 | async def list_resource_templates(self) -> list[MCPResourceTemplate]: 190 | templates = self._resource_manager.list_templates() 191 | return [ 192 | MCPResourceTemplate( 193 | uriTemplate=template.uri_template, 194 | name=template.name, 195 | description=template.description, 196 | ) 197 | for template in templates 198 | ] 199 | 200 | async def read_resource(self, uri: AnyUrl | str) -> str | bytes: 201 | """Read a resource by URI.""" 202 | resource = await self._resource_manager.get_resource(uri) 203 | if not resource: 204 | raise ResourceError(f"Unknown resource: {uri}") 205 | 206 | try: 207 | return await resource.read() 208 | except Exception as e: 209 | logger.error(f"Error reading resource {uri}: {e}") 210 | raise ResourceError(str(e)) 211 | 212 | def add_tool( 213 | self, 214 | fn: Callable, 215 | name: str | None = None, 216 | description: str | None = None, 217 | ) -> None: 218 | """Add a tool to the server. 219 | 220 | The tool function can optionally request a Context object by adding a parameter 221 | with the Context type annotation. See the @tool decorator for examples. 222 | 223 | Args: 224 | fn: The function to register as a tool 225 | name: Optional name for the tool (defaults to function name) 226 | description: Optional description of what the tool does 227 | """ 228 | self._tool_manager.add_tool(fn, name=name, description=description) 229 | 230 | def tool( 231 | self, name: str | None = None, description: str | None = None 232 | ) -> Callable[[Callable[P, R]], Callable[P, R]]: 233 | """Decorator to register a tool. 234 | 235 | Tools can optionally request a Context object by adding a parameter with the Context type annotation. 236 | The context provides access to MCP capabilities like logging, progress reporting, and resource access. 237 | 238 | Args: 239 | name: Optional name for the tool (defaults to function name) 240 | description: Optional description of what the tool does 241 | 242 | Example: 243 | @server.tool() 244 | def my_tool(x: int) -> str: 245 | return str(x) 246 | 247 | @server.tool() 248 | def tool_with_context(x: int, ctx: Context) -> str: 249 | ctx.info(f"Processing {x}") 250 | return str(x) 251 | 252 | @server.tool() 253 | async def async_tool(x: int, context: Context) -> str: 254 | await context.report_progress(50, 100) 255 | return str(x) 256 | """ 257 | # Check if user passed function directly instead of calling decorator 258 | if callable(name): 259 | raise TypeError( 260 | "The @tool decorator was used incorrectly. " 261 | "Did you forget to call it? Use @tool() instead of @tool" 262 | ) 263 | 264 | def decorator(fn: Callable[P, R]) -> Callable[P, R]: 265 | self.add_tool(fn, name=name, description=description) 266 | return fn 267 | 268 | return decorator 269 | 270 | def add_resource(self, resource: Resource) -> None: 271 | """Add a resource to the server. 272 | 273 | Args: 274 | resource: A Resource instance to add 275 | """ 276 | self._resource_manager.add_resource(resource) 277 | 278 | def resource( 279 | self, 280 | uri: str, 281 | *, 282 | name: str | None = None, 283 | description: str | None = None, 284 | mime_type: str | None = None, 285 | ) -> Callable[[Callable[P, R]], Callable[P, R]]: 286 | """Decorator to register a function as a resource. 287 | 288 | The function will be called when the resource is read to generate its content. 289 | The function can return: 290 | - str for text content 291 | - bytes for binary content 292 | - other types will be converted to JSON 293 | 294 | If the URI contains parameters (e.g. "resource://{param}") or the function 295 | has parameters, it will be registered as a template resource. 296 | 297 | Args: 298 | uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}") 299 | name: Optional name for the resource 300 | description: Optional description of the resource 301 | mime_type: Optional MIME type for the resource 302 | 303 | Example: 304 | @server.resource("resource://my-resource") 305 | def get_data() -> str: 306 | return "Hello, world!" 307 | 308 | @server.resource("resource://{city}/weather") 309 | def get_weather(city: str) -> str: 310 | return f"Weather for {city}" 311 | """ 312 | # Check if user passed function directly instead of calling decorator 313 | if callable(uri): 314 | raise TypeError( 315 | "The @resource decorator was used incorrectly. " 316 | "Did you forget to call it? Use @resource('uri') instead of @resource" 317 | ) 318 | 319 | def decorator(fn: Callable[P, R]) -> Callable[P, R]: 320 | @functools.wraps(fn) 321 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: 322 | return fn(*args, **kwargs) 323 | 324 | # Check if this should be a template 325 | has_uri_params = "{" in uri and "}" in uri 326 | has_func_params = bool(inspect.signature(fn).parameters) 327 | 328 | if has_uri_params or has_func_params: 329 | # Validate that URI params match function params 330 | uri_params = set(re.findall(r"{(\w+)}", uri)) 331 | func_params = set(inspect.signature(fn).parameters.keys()) 332 | 333 | if uri_params != func_params: 334 | raise ValueError( 335 | f"Mismatch between URI parameters {uri_params} " 336 | f"and function parameters {func_params}" 337 | ) 338 | 339 | # Register as template 340 | self._resource_manager.add_template( 341 | wrapper, 342 | uri_template=uri, 343 | name=name, 344 | description=description, 345 | mime_type=mime_type or "text/plain", 346 | ) 347 | else: 348 | # Register as regular resource 349 | resource = FunctionResource( 350 | uri=AnyUrl(uri), 351 | name=name, 352 | description=description, 353 | mime_type=mime_type or "text/plain", 354 | fn=wrapper, 355 | ) 356 | self.add_resource(resource) 357 | return wrapper 358 | 359 | return decorator 360 | 361 | def add_prompt(self, prompt: Prompt) -> None: 362 | """Add a prompt to the server. 363 | 364 | Args: 365 | prompt: A Prompt instance to add 366 | """ 367 | self._prompt_manager.add_prompt(prompt) 368 | 369 | def prompt( 370 | self, name: str | None = None, description: str | None = None 371 | ) -> Callable[[Callable[P, R_PromptResult]], Callable[P, R_PromptResult]]: 372 | """Decorator to register a prompt. 373 | 374 | Args: 375 | name: Optional name for the prompt (defaults to function name) 376 | description: Optional description of what the prompt does 377 | 378 | Example: 379 | @server.prompt() 380 | def analyze_table(table_name: str) -> list[Message]: 381 | schema = read_table_schema(table_name) 382 | return [ 383 | { 384 | "role": "user", 385 | "content": f"Analyze this schema:\n{schema}" 386 | } 387 | ] 388 | 389 | @server.prompt() 390 | async def analyze_file(path: str) -> list[Message]: 391 | content = await read_file(path) 392 | return [ 393 | { 394 | "role": "user", 395 | "content": { 396 | "type": "resource", 397 | "resource": { 398 | "uri": f"file://{path}", 399 | "text": content 400 | } 401 | } 402 | } 403 | ] 404 | """ 405 | # Check if user passed function directly instead of calling decorator 406 | if callable(name): 407 | raise TypeError( 408 | "The @prompt decorator was used incorrectly. " 409 | "Did you forget to call it? Use @prompt() instead of @prompt" 410 | ) 411 | 412 | def decorator(func: Callable[P, R_PromptResult]) -> Callable[P, R_PromptResult]: 413 | prompt = Prompt.from_function(func, name=name, description=description) 414 | self.add_prompt(prompt) 415 | return func 416 | 417 | return decorator 418 | 419 | async def run_stdio_async(self) -> None: 420 | """Run the server using stdio transport.""" 421 | async with stdio_server() as (read_stream, write_stream): 422 | await self._mcp_server.run( 423 | read_stream, 424 | write_stream, 425 | self._mcp_server.create_initialization_options(), 426 | ) 427 | 428 | async def run_sse_async(self) -> None: 429 | """Run the server using SSE transport.""" 430 | from starlette.applications import Starlette 431 | from starlette.routing import Route 432 | 433 | sse = SseServerTransport("/messages") 434 | 435 | async def handle_sse(request): 436 | async with sse.connect_sse( 437 | request.scope, request.receive, request._send 438 | ) as streams: 439 | await self._mcp_server.run( 440 | streams[0], 441 | streams[1], 442 | self._mcp_server.create_initialization_options(), 443 | ) 444 | 445 | async def handle_messages(request): 446 | await sse.handle_post_message(request.scope, request.receive, request._send) 447 | 448 | starlette_app = Starlette( 449 | debug=self.settings.debug, 450 | routes=[ 451 | Route("/sse", endpoint=handle_sse), 452 | Route("/messages", endpoint=handle_messages, methods=["POST"]), 453 | ], 454 | ) 455 | 456 | config = uvicorn.Config( 457 | starlette_app, 458 | host=self.settings.host, 459 | port=self.settings.port, 460 | log_level=self.settings.log_level.lower(), 461 | ) 462 | server = uvicorn.Server(config) 463 | await server.serve() 464 | 465 | async def list_prompts(self) -> list[MCPPrompt]: 466 | """List all available prompts.""" 467 | prompts = self._prompt_manager.list_prompts() 468 | return [ 469 | MCPPrompt( 470 | name=prompt.name, 471 | description=prompt.description, 472 | arguments=[ 473 | MCPPromptArgument( 474 | name=arg.name, 475 | description=arg.description, 476 | required=arg.required, 477 | ) 478 | for arg in (prompt.arguments or []) 479 | ], 480 | ) 481 | for prompt in prompts 482 | ] 483 | 484 | async def get_prompt( 485 | self, name: str, arguments: Dict[str, Any] | None = None 486 | ) -> GetPromptResult: 487 | """Get a prompt by name with arguments.""" 488 | try: 489 | messages = await self._prompt_manager.render_prompt(name, arguments) 490 | 491 | return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages)) 492 | except Exception as e: 493 | logger.error(f"Error getting prompt {name}: {e}") 494 | raise ValueError(str(e)) 495 | 496 | 497 | def _convert_to_content( 498 | result: Any, 499 | ) -> Sequence[TextContent | ImageContent | EmbeddedResource]: 500 | """Convert a result to a sequence of content objects.""" 501 | if result is None: 502 | return [] 503 | 504 | if isinstance(result, (TextContent, ImageContent, EmbeddedResource)): 505 | return [result] 506 | 507 | if isinstance(result, Image): 508 | return [result.to_image_content()] 509 | 510 | if isinstance(result, (list, tuple)): 511 | return list(chain.from_iterable(_convert_to_content(item) for item in result)) 512 | 513 | if not isinstance(result, str): 514 | try: 515 | result = json.dumps(pydantic_core.to_jsonable_python(result)) 516 | except Exception: 517 | result = str(result) 518 | 519 | return [TextContent(type="text", text=result)] 520 | 521 | 522 | class Context(BaseModel): 523 | """Context object providing access to MCP capabilities. 524 | 525 | This provides a cleaner interface to MCP's RequestContext functionality. 526 | It gets injected into tool and resource functions that request it via type hints. 527 | 528 | To use context in a tool function, add a parameter with the Context type annotation: 529 | 530 | ```python 531 | @server.tool() 532 | def my_tool(x: int, ctx: Context) -> str: 533 | # Log messages to the client 534 | ctx.info(f"Processing {x}") 535 | ctx.debug("Debug info") 536 | ctx.warning("Warning message") 537 | ctx.error("Error message") 538 | 539 | # Report progress 540 | ctx.report_progress(50, 100) 541 | 542 | # Access resources 543 | data = ctx.read_resource("resource://data") 544 | 545 | # Get request info 546 | request_id = ctx.request_id 547 | client_id = ctx.client_id 548 | 549 | return str(x) 550 | ``` 551 | 552 | The context parameter name can be anything as long as it's annotated with Context. 553 | The context is optional - tools that don't need it can omit the parameter. 554 | """ 555 | 556 | _request_context: RequestContext | None 557 | _fastmcp: FastMCP | None 558 | 559 | def __init__( 560 | self, 561 | *, 562 | request_context: RequestContext | None = None, 563 | fastmcp: FastMCP | None = None, 564 | **kwargs: Any, 565 | ): 566 | super().__init__(**kwargs) 567 | self._request_context = request_context 568 | self._fastmcp = fastmcp 569 | 570 | @property 571 | def fastmcp(self) -> FastMCP: 572 | """Access to the FastMCP server.""" 573 | if self._fastmcp is None: 574 | raise ValueError("Context is not available outside of a request") 575 | return self._fastmcp 576 | 577 | @property 578 | def request_context(self) -> RequestContext: 579 | """Access to the underlying request context.""" 580 | if self._request_context is None: 581 | raise ValueError("Context is not available outside of a request") 582 | return self._request_context 583 | 584 | async def report_progress( 585 | self, progress: float, total: float | None = None 586 | ) -> None: 587 | """Report progress for the current operation. 588 | 589 | Args: 590 | progress: Current progress value e.g. 24 591 | total: Optional total value e.g. 100 592 | """ 593 | 594 | progress_token = ( 595 | self.request_context.meta.progressToken 596 | if self.request_context.meta 597 | else None 598 | ) 599 | 600 | if not progress_token: 601 | return 602 | 603 | await self.request_context.session.send_progress_notification( 604 | progress_token=progress_token, progress=progress, total=total 605 | ) 606 | 607 | async def read_resource(self, uri: str | AnyUrl) -> str | bytes: 608 | """Read a resource by URI. 609 | 610 | Args: 611 | uri: Resource URI to read 612 | 613 | Returns: 614 | The resource content as either text or bytes 615 | """ 616 | assert ( 617 | self._fastmcp is not None 618 | ), "Context is not available outside of a request" 619 | return await self._fastmcp.read_resource(uri) 620 | 621 | def log( 622 | self, 623 | level: Literal["debug", "info", "warning", "error"], 624 | message: str, 625 | *, 626 | logger_name: str | None = None, 627 | ) -> None: 628 | """Send a log message to the client. 629 | 630 | Args: 631 | level: Log level (debug, info, warning, error) 632 | message: Log message 633 | logger_name: Optional logger name 634 | **extra: Additional structured data to include 635 | """ 636 | self.request_context.session.send_log_message( 637 | level=level, data=message, logger=logger_name 638 | ) 639 | 640 | @property 641 | def client_id(self) -> str | None: 642 | """Get the client ID if available.""" 643 | return ( 644 | getattr(self.request_context.meta, "client_id", None) 645 | if self.request_context.meta 646 | else None 647 | ) 648 | 649 | @property 650 | def request_id(self) -> str: 651 | """Get the unique ID for this request.""" 652 | return str(self.request_context.request_id) 653 | 654 | @property 655 | def session(self): 656 | """Access to the underlying session for advanced usage.""" 657 | return self.request_context.session 658 | 659 | # Convenience methods for common log levels 660 | def debug(self, message: str, **extra: Any) -> None: 661 | """Send a debug log message.""" 662 | self.log("debug", message, **extra) 663 | 664 | def info(self, message: str, **extra: Any) -> None: 665 | """Send an info log message.""" 666 | self.log("info", message, **extra) 667 | 668 | def warning(self, message: str, **extra: Any) -> None: 669 | """Send a warning log message.""" 670 | self.log("warning", message, **extra) 671 | 672 | def error(self, message: str, **extra: Any) -> None: 673 | """Send an error log message.""" 674 | self.log("error", message, **extra) 675 | -------------------------------------------------------------------------------- /src/fastmcp/tools/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Tool 2 | from .tool_manager import ToolManager 3 | 4 | __all__ = ["Tool", "ToolManager"] 5 | -------------------------------------------------------------------------------- /src/fastmcp/tools/base.py: -------------------------------------------------------------------------------- 1 | import fastmcp 2 | from fastmcp.exceptions import ToolError 3 | 4 | from fastmcp.utilities.func_metadata import func_metadata, FuncMetadata 5 | from pydantic import BaseModel, Field 6 | 7 | 8 | import inspect 9 | from typing import TYPE_CHECKING, Any, Callable, Optional 10 | 11 | if TYPE_CHECKING: 12 | from fastmcp.server import Context 13 | 14 | 15 | class Tool(BaseModel): 16 | """Internal tool registration info.""" 17 | 18 | fn: Callable = Field(exclude=True) 19 | name: str = Field(description="Name of the tool") 20 | description: str = Field(description="Description of what the tool does") 21 | parameters: dict = Field(description="JSON schema for tool parameters") 22 | fn_metadata: FuncMetadata = Field( 23 | description="Metadata about the function including a pydantic model for tool arguments" 24 | ) 25 | is_async: bool = Field(description="Whether the tool is async") 26 | context_kwarg: Optional[str] = Field( 27 | None, description="Name of the kwarg that should receive context" 28 | ) 29 | 30 | @classmethod 31 | def from_function( 32 | cls, 33 | fn: Callable, 34 | name: Optional[str] = None, 35 | description: Optional[str] = None, 36 | context_kwarg: Optional[str] = None, 37 | ) -> "Tool": 38 | """Create a Tool from a function.""" 39 | func_name = name or fn.__name__ 40 | 41 | if func_name == "<lambda>": 42 | raise ValueError("You must provide a name for lambda functions") 43 | 44 | func_doc = description or fn.__doc__ or "" 45 | is_async = inspect.iscoroutinefunction(fn) 46 | 47 | # Find context parameter if it exists 48 | if context_kwarg is None: 49 | sig = inspect.signature(fn) 50 | for param_name, param in sig.parameters.items(): 51 | if param.annotation is fastmcp.Context: 52 | context_kwarg = param_name 53 | break 54 | 55 | func_arg_metadata = func_metadata( 56 | fn, 57 | skip_names=[context_kwarg] if context_kwarg is not None else [], 58 | ) 59 | parameters = func_arg_metadata.arg_model.model_json_schema() 60 | 61 | return cls( 62 | fn=fn, 63 | name=func_name, 64 | description=func_doc, 65 | parameters=parameters, 66 | fn_metadata=func_arg_metadata, 67 | is_async=is_async, 68 | context_kwarg=context_kwarg, 69 | ) 70 | 71 | async def run(self, arguments: dict, context: Optional["Context"] = None) -> Any: 72 | """Run the tool with arguments.""" 73 | try: 74 | return await self.fn_metadata.call_fn_with_arg_validation( 75 | self.fn, 76 | self.is_async, 77 | arguments, 78 | {self.context_kwarg: context} 79 | if self.context_kwarg is not None 80 | else None, 81 | ) 82 | except Exception as e: 83 | raise ToolError(f"Error executing tool {self.name}: {e}") from e 84 | -------------------------------------------------------------------------------- /src/fastmcp/tools/tool_manager.py: -------------------------------------------------------------------------------- 1 | from fastmcp.exceptions import ToolError 2 | 3 | from fastmcp.tools.base import Tool 4 | 5 | 6 | from typing import Any, Callable, Dict, Optional, TYPE_CHECKING 7 | 8 | from fastmcp.utilities.logging import get_logger 9 | 10 | if TYPE_CHECKING: 11 | from fastmcp.server import Context 12 | 13 | logger = get_logger(__name__) 14 | 15 | 16 | class ToolManager: 17 | """Manages FastMCP tools.""" 18 | 19 | def __init__(self, warn_on_duplicate_tools: bool = True): 20 | self._tools: Dict[str, Tool] = {} 21 | self.warn_on_duplicate_tools = warn_on_duplicate_tools 22 | 23 | def get_tool(self, name: str) -> Optional[Tool]: 24 | """Get tool by name.""" 25 | return self._tools.get(name) 26 | 27 | def list_tools(self) -> list[Tool]: 28 | """List all registered tools.""" 29 | return list(self._tools.values()) 30 | 31 | def add_tool( 32 | self, 33 | fn: Callable, 34 | name: Optional[str] = None, 35 | description: Optional[str] = None, 36 | ) -> Tool: 37 | """Add a tool to the server.""" 38 | tool = Tool.from_function(fn, name=name, description=description) 39 | existing = self._tools.get(tool.name) 40 | if existing: 41 | if self.warn_on_duplicate_tools: 42 | logger.warning(f"Tool already exists: {tool.name}") 43 | return existing 44 | self._tools[tool.name] = tool 45 | return tool 46 | 47 | async def call_tool( 48 | self, name: str, arguments: dict, context: Optional["Context"] = None 49 | ) -> Any: 50 | """Call a tool by name with arguments.""" 51 | tool = self.get_tool(name) 52 | if not tool: 53 | raise ToolError(f"Unknown tool: {name}") 54 | 55 | return await tool.run(arguments, context=context) 56 | -------------------------------------------------------------------------------- /src/fastmcp/utilities/__init__.py: -------------------------------------------------------------------------------- 1 | """FastMCP utility modules.""" 2 | -------------------------------------------------------------------------------- /src/fastmcp/utilities/func_metadata.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from collections.abc import Callable, Sequence, Awaitable 3 | from typing import ( 4 | Annotated, 5 | Any, 6 | Dict, 7 | ForwardRef, 8 | ) 9 | from pydantic import Field 10 | from fastmcp.exceptions import InvalidSignature 11 | from pydantic._internal._typing_extra import eval_type_lenient 12 | import json 13 | from pydantic import BaseModel 14 | from pydantic.fields import FieldInfo 15 | from pydantic import ConfigDict, create_model 16 | from pydantic import WithJsonSchema 17 | from pydantic_core import PydanticUndefined 18 | from fastmcp.utilities.logging import get_logger 19 | 20 | 21 | logger = get_logger(__name__) 22 | 23 | 24 | class ArgModelBase(BaseModel): 25 | """A model representing the arguments to a function.""" 26 | 27 | def model_dump_one_level(self) -> dict[str, Any]: 28 | """Return a dict of the model's fields, one level deep. 29 | 30 | That is, sub-models etc are not dumped - they are kept as pydantic models. 31 | """ 32 | kwargs: dict[str, Any] = {} 33 | for field_name in self.model_fields.keys(): 34 | kwargs[field_name] = getattr(self, field_name) 35 | return kwargs 36 | 37 | model_config = ConfigDict( 38 | arbitrary_types_allowed=True, 39 | ) 40 | 41 | 42 | class FuncMetadata(BaseModel): 43 | arg_model: Annotated[type[ArgModelBase], WithJsonSchema(None)] 44 | # We can add things in the future like 45 | # - Maybe some args are excluded from attempting to parse from JSON 46 | # - Maybe some args are special (like context) for dependency injection 47 | 48 | async def call_fn_with_arg_validation( 49 | self, 50 | fn: Callable[..., Any] | Awaitable[Any], 51 | fn_is_async: bool, 52 | arguments_to_validate: dict[str, Any], 53 | arguments_to_pass_directly: dict[str, Any] | None, 54 | ) -> Any: 55 | """Call the given function with arguments validated and injected. 56 | 57 | Arguments are first attempted to be parsed from JSON, then validated against 58 | the argument model, before being passed to the function. 59 | """ 60 | arguments_pre_parsed = self.pre_parse_json(arguments_to_validate) 61 | arguments_parsed_model = self.arg_model.model_validate(arguments_pre_parsed) 62 | arguments_parsed_dict = arguments_parsed_model.model_dump_one_level() 63 | 64 | arguments_parsed_dict |= arguments_to_pass_directly or {} 65 | 66 | if fn_is_async: 67 | if isinstance(fn, Awaitable): 68 | return await fn 69 | return await fn(**arguments_parsed_dict) 70 | if isinstance(fn, Callable): 71 | return fn(**arguments_parsed_dict) 72 | raise TypeError("fn must be either Callable or Awaitable") 73 | 74 | def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]: 75 | """Pre-parse data from JSON. 76 | 77 | Return a dict with same keys as input but with values parsed from JSON 78 | if appropriate. 79 | 80 | This is to handle cases like `["a", "b", "c"]` being passed in as JSON inside 81 | a string rather than an actual list. Claude desktop is prone to this - in fact 82 | it seems incapable of NOT doing this. For sub-models, it tends to pass 83 | dicts (JSON objects) as JSON strings, which can be pre-parsed here. 84 | """ 85 | new_data = data.copy() # Shallow copy 86 | for field_name, field_info in self.arg_model.model_fields.items(): 87 | if field_name not in data.keys(): 88 | continue 89 | if isinstance(data[field_name], str): 90 | try: 91 | pre_parsed = json.loads(data[field_name]) 92 | except json.JSONDecodeError: 93 | continue # Not JSON - skip 94 | if isinstance(pre_parsed, (str, int, float)): 95 | # This is likely that the raw value is e.g. `"hello"` which we 96 | # Should really be parsed as '"hello"' in Python - but if we parse 97 | # it as JSON it'll turn into just 'hello'. So we skip it. 98 | continue 99 | new_data[field_name] = pre_parsed 100 | assert new_data.keys() == data.keys() 101 | return new_data 102 | 103 | model_config = ConfigDict( 104 | arbitrary_types_allowed=True, 105 | ) 106 | 107 | 108 | def func_metadata(func: Callable, skip_names: Sequence[str] = ()) -> FuncMetadata: 109 | """Given a function, return metadata including a pydantic model representing its signature. 110 | 111 | The use case for this is 112 | ``` 113 | meta = func_to_pyd(func) 114 | validated_args = meta.arg_model.model_validate(some_raw_data_dict) 115 | return func(**validated_args.model_dump_one_level()) 116 | ``` 117 | 118 | **critically** it also provides pre-parse helper to attempt to parse things from JSON. 119 | 120 | Args: 121 | func: The function to convert to a pydantic model 122 | skip_names: A list of parameter names to skip. These will not be included in 123 | the model. 124 | Returns: 125 | A pydantic model representing the function's signature. 126 | """ 127 | sig = _get_typed_signature(func) 128 | params = sig.parameters 129 | dynamic_pydantic_model_params: dict[str, Any] = {} 130 | globalns = getattr(func, "__globals__", {}) 131 | for param in params.values(): 132 | if param.name.startswith("_"): 133 | raise InvalidSignature( 134 | f"Parameter {param.name} of {func.__name__} may not start with an underscore" 135 | ) 136 | if param.name in skip_names: 137 | continue 138 | annotation = param.annotation 139 | 140 | # `x: None` / `x: None = None` 141 | if annotation is None: 142 | annotation = Annotated[ 143 | None, 144 | Field( 145 | default=param.default 146 | if param.default is not inspect.Parameter.empty 147 | else PydanticUndefined 148 | ), 149 | ] 150 | 151 | # Untyped field 152 | if annotation is inspect.Parameter.empty: 153 | annotation = Annotated[ 154 | Any, 155 | Field(), 156 | # 🤷 157 | WithJsonSchema({"title": param.name, "type": "string"}), 158 | ] 159 | 160 | field_info = FieldInfo.from_annotated_attribute( 161 | _get_typed_annotation(annotation, globalns), 162 | param.default 163 | if param.default is not inspect.Parameter.empty 164 | else PydanticUndefined, 165 | ) 166 | dynamic_pydantic_model_params[param.name] = (field_info.annotation, field_info) 167 | continue 168 | 169 | arguments_model = create_model( 170 | f"{func.__name__}Arguments", 171 | **dynamic_pydantic_model_params, 172 | __base__=ArgModelBase, 173 | ) 174 | resp = FuncMetadata(arg_model=arguments_model) 175 | return resp 176 | 177 | 178 | def _get_typed_annotation(annotation: Any, globalns: Dict[str, Any]) -> Any: 179 | if isinstance(annotation, str): 180 | annotation = ForwardRef(annotation) 181 | annotation = eval_type_lenient(annotation, globalns, globalns) 182 | 183 | return annotation 184 | 185 | 186 | def _get_typed_signature(call: Callable[..., Any]) -> inspect.Signature: 187 | """Get function signature while evaluating forward references""" 188 | signature = inspect.signature(call) 189 | globalns = getattr(call, "__globals__", {}) 190 | typed_params = [ 191 | inspect.Parameter( 192 | name=param.name, 193 | kind=param.kind, 194 | default=param.default, 195 | annotation=_get_typed_annotation(param.annotation, globalns), 196 | ) 197 | for param in signature.parameters.values() 198 | ] 199 | typed_signature = inspect.Signature(typed_params) 200 | return typed_signature 201 | -------------------------------------------------------------------------------- /src/fastmcp/utilities/logging.py: -------------------------------------------------------------------------------- 1 | """Logging utilities for FastMCP.""" 2 | 3 | import logging 4 | from typing import Literal 5 | 6 | from rich.console import Console 7 | from rich.logging import RichHandler 8 | 9 | 10 | def get_logger(name: str) -> logging.Logger: 11 | """Get a logger nested under FastMCP namespace. 12 | 13 | Args: 14 | name: the name of the logger, which will be prefixed with 'FastMCP.' 15 | 16 | Returns: 17 | a configured logger instance 18 | """ 19 | return logging.getLogger(f"FastMCP.{name}") 20 | 21 | 22 | def configure_logging( 23 | level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO", 24 | ) -> None: 25 | """Configure logging for FastMCP. 26 | 27 | Args: 28 | level: the log level to use 29 | """ 30 | logging.basicConfig( 31 | level=level, 32 | format="%(message)s", 33 | handlers=[RichHandler(console=Console(stderr=True), rich_tracebacks=True)], 34 | ) 35 | -------------------------------------------------------------------------------- /src/fastmcp/utilities/types.py: -------------------------------------------------------------------------------- 1 | """Common types used across FastMCP.""" 2 | 3 | import base64 4 | from pathlib import Path 5 | from typing import Optional, Union 6 | 7 | from mcp.types import ImageContent 8 | 9 | 10 | class Image: 11 | """Helper class for returning images from tools.""" 12 | 13 | def __init__( 14 | self, 15 | path: Optional[Union[str, Path]] = None, 16 | data: Optional[bytes] = None, 17 | format: Optional[str] = None, 18 | ): 19 | if path is None and data is None: 20 | raise ValueError("Either path or data must be provided") 21 | if path is not None and data is not None: 22 | raise ValueError("Only one of path or data can be provided") 23 | 24 | self.path = Path(path) if path else None 25 | self.data = data 26 | self._format = format 27 | self._mime_type = self._get_mime_type() 28 | 29 | def _get_mime_type(self) -> str: 30 | """Get MIME type from format or guess from file extension.""" 31 | if self._format: 32 | return f"image/{self._format.lower()}" 33 | 34 | if self.path: 35 | suffix = self.path.suffix.lower() 36 | return { 37 | ".png": "image/png", 38 | ".jpg": "image/jpeg", 39 | ".jpeg": "image/jpeg", 40 | ".gif": "image/gif", 41 | ".webp": "image/webp", 42 | }.get(suffix, "application/octet-stream") 43 | return "image/png" # default for raw binary data 44 | 45 | def to_image_content(self) -> ImageContent: 46 | """Convert to MCP ImageContent.""" 47 | if self.path: 48 | with open(self.path, "rb") as f: 49 | data = base64.b64encode(f.read()).decode() 50 | elif self.data is not None: 51 | data = base64.b64encode(self.data).decode() 52 | else: 53 | raise ValueError("No image data available") 54 | 55 | return ImageContent(type="image", data=data, mimeType=self._mime_type) 56 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlowin/fastmcp/80c328b3dc0b949f010456ee0e85cc5c90e3305f/tests/__init__.py -------------------------------------------------------------------------------- /tests/prompts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlowin/fastmcp/80c328b3dc0b949f010456ee0e85cc5c90e3305f/tests/prompts/__init__.py -------------------------------------------------------------------------------- /tests/prompts/test_base.py: -------------------------------------------------------------------------------- 1 | from pydantic import FileUrl 2 | import pytest 3 | from fastmcp.prompts.base import ( 4 | Prompt, 5 | UserMessage, 6 | TextContent, 7 | AssistantMessage, 8 | Message, 9 | ) 10 | from mcp.types import EmbeddedResource, TextResourceContents 11 | 12 | 13 | class TestRenderPrompt: 14 | async def test_basic_fn(self): 15 | def fn() -> str: 16 | return "Hello, world!" 17 | 18 | prompt = Prompt.from_function(fn) 19 | assert await prompt.render() == [ 20 | UserMessage(content=TextContent(type="text", text="Hello, world!")) 21 | ] 22 | 23 | async def test_async_fn(self): 24 | async def fn() -> str: 25 | return "Hello, world!" 26 | 27 | prompt = Prompt.from_function(fn) 28 | assert await prompt.render() == [ 29 | UserMessage(content=TextContent(type="text", text="Hello, world!")) 30 | ] 31 | 32 | async def test_fn_with_args(self): 33 | async def fn(name: str, age: int = 30) -> str: 34 | return f"Hello, {name}! You're {age} years old." 35 | 36 | prompt = Prompt.from_function(fn) 37 | assert await prompt.render(arguments=dict(name="World")) == [ 38 | UserMessage( 39 | content=TextContent( 40 | type="text", text="Hello, World! You're 30 years old." 41 | ) 42 | ) 43 | ] 44 | 45 | async def test_fn_with_invalid_kwargs(self): 46 | async def fn(name: str, age: int = 30) -> str: 47 | return f"Hello, {name}! You're {age} years old." 48 | 49 | prompt = Prompt.from_function(fn) 50 | with pytest.raises(ValueError): 51 | await prompt.render(arguments=dict(age=40)) 52 | 53 | async def test_fn_returns_message(self): 54 | async def fn() -> UserMessage: 55 | return UserMessage(content="Hello, world!") 56 | 57 | prompt = Prompt.from_function(fn) 58 | assert await prompt.render() == [ 59 | UserMessage(content=TextContent(type="text", text="Hello, world!")) 60 | ] 61 | 62 | async def test_fn_returns_assistant_message(self): 63 | async def fn() -> AssistantMessage: 64 | return AssistantMessage( 65 | content=TextContent(type="text", text="Hello, world!") 66 | ) 67 | 68 | prompt = Prompt.from_function(fn) 69 | assert await prompt.render() == [ 70 | AssistantMessage(content=TextContent(type="text", text="Hello, world!")) 71 | ] 72 | 73 | async def test_fn_returns_multiple_messages(self): 74 | expected = [ 75 | UserMessage("Hello, world!"), 76 | AssistantMessage("How can I help you today?"), 77 | UserMessage("I'm looking for a restaurant in the center of town."), 78 | ] 79 | 80 | async def fn() -> list[Message]: 81 | return expected 82 | 83 | prompt = Prompt.from_function(fn) 84 | assert await prompt.render() == expected 85 | 86 | async def test_fn_returns_list_of_strings(self): 87 | expected = [ 88 | "Hello, world!", 89 | "I'm looking for a restaurant in the center of town.", 90 | ] 91 | 92 | async def fn() -> list[str]: 93 | return expected 94 | 95 | prompt = Prompt.from_function(fn) 96 | assert await prompt.render() == [UserMessage(t) for t in expected] 97 | 98 | async def test_fn_returns_resource_content(self): 99 | """Test returning a message with resource content.""" 100 | 101 | async def fn() -> UserMessage: 102 | return UserMessage( 103 | content=EmbeddedResource( 104 | type="resource", 105 | resource=TextResourceContents( 106 | uri=FileUrl("file://file.txt"), 107 | text="File contents", 108 | mimeType="text/plain", 109 | ), 110 | ) 111 | ) 112 | 113 | prompt = Prompt.from_function(fn) 114 | assert await prompt.render() == [ 115 | UserMessage( 116 | content=EmbeddedResource( 117 | type="resource", 118 | resource=TextResourceContents( 119 | uri=FileUrl("file://file.txt"), 120 | text="File contents", 121 | mimeType="text/plain", 122 | ), 123 | ) 124 | ) 125 | ] 126 | 127 | async def test_fn_returns_mixed_content(self): 128 | """Test returning messages with mixed content types.""" 129 | 130 | async def fn() -> list[Message]: 131 | return [ 132 | UserMessage(content="Please analyze this file:"), 133 | UserMessage( 134 | content=EmbeddedResource( 135 | type="resource", 136 | resource=TextResourceContents( 137 | uri=FileUrl("file://file.txt"), 138 | text="File contents", 139 | mimeType="text/plain", 140 | ), 141 | ) 142 | ), 143 | AssistantMessage(content="I'll help analyze that file."), 144 | ] 145 | 146 | prompt = Prompt.from_function(fn) 147 | assert await prompt.render() == [ 148 | UserMessage( 149 | content=TextContent(type="text", text="Please analyze this file:") 150 | ), 151 | UserMessage( 152 | content=EmbeddedResource( 153 | type="resource", 154 | resource=TextResourceContents( 155 | uri=FileUrl("file://file.txt"), 156 | text="File contents", 157 | mimeType="text/plain", 158 | ), 159 | ) 160 | ), 161 | AssistantMessage( 162 | content=TextContent(type="text", text="I'll help analyze that file.") 163 | ), 164 | ] 165 | 166 | async def test_fn_returns_dict_with_resource(self): 167 | """Test returning a dict with resource content.""" 168 | 169 | async def fn() -> dict: 170 | return { 171 | "role": "user", 172 | "content": { 173 | "type": "resource", 174 | "resource": { 175 | "uri": FileUrl("file://file.txt"), 176 | "text": "File contents", 177 | "mimeType": "text/plain", 178 | }, 179 | }, 180 | } 181 | 182 | prompt = Prompt.from_function(fn) 183 | assert await prompt.render() == [ 184 | UserMessage( 185 | content=EmbeddedResource( 186 | type="resource", 187 | resource=TextResourceContents( 188 | uri=FileUrl("file://file.txt"), 189 | text="File contents", 190 | mimeType="text/plain", 191 | ), 192 | ) 193 | ) 194 | ] 195 | -------------------------------------------------------------------------------- /tests/prompts/test_manager.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastmcp.prompts.base import UserMessage, TextContent, Prompt 3 | from fastmcp.prompts.manager import PromptManager 4 | 5 | 6 | class TestPromptManager: 7 | def test_add_prompt(self): 8 | """Test adding a prompt to the manager.""" 9 | 10 | def fn() -> str: 11 | return "Hello, world!" 12 | 13 | manager = PromptManager() 14 | prompt = Prompt.from_function(fn) 15 | added = manager.add_prompt(prompt) 16 | assert added == prompt 17 | assert manager.get_prompt("fn") == prompt 18 | 19 | def test_add_duplicate_prompt(self, caplog): 20 | """Test adding the same prompt twice.""" 21 | 22 | def fn() -> str: 23 | return "Hello, world!" 24 | 25 | manager = PromptManager() 26 | prompt = Prompt.from_function(fn) 27 | first = manager.add_prompt(prompt) 28 | second = manager.add_prompt(prompt) 29 | assert first == second 30 | assert "Prompt already exists" in caplog.text 31 | 32 | def test_disable_warn_on_duplicate_prompts(self, caplog): 33 | """Test disabling warning on duplicate prompts.""" 34 | 35 | def fn() -> str: 36 | return "Hello, world!" 37 | 38 | manager = PromptManager(warn_on_duplicate_prompts=False) 39 | prompt = Prompt.from_function(fn) 40 | first = manager.add_prompt(prompt) 41 | second = manager.add_prompt(prompt) 42 | assert first == second 43 | assert "Prompt already exists" not in caplog.text 44 | 45 | def test_list_prompts(self): 46 | """Test listing all prompts.""" 47 | 48 | def fn1() -> str: 49 | return "Hello, world!" 50 | 51 | def fn2() -> str: 52 | return "Goodbye, world!" 53 | 54 | manager = PromptManager() 55 | prompt1 = Prompt.from_function(fn1) 56 | prompt2 = Prompt.from_function(fn2) 57 | manager.add_prompt(prompt1) 58 | manager.add_prompt(prompt2) 59 | prompts = manager.list_prompts() 60 | assert len(prompts) == 2 61 | assert prompts == [prompt1, prompt2] 62 | 63 | async def test_render_prompt(self): 64 | """Test rendering a prompt.""" 65 | 66 | def fn() -> str: 67 | return "Hello, world!" 68 | 69 | manager = PromptManager() 70 | prompt = Prompt.from_function(fn) 71 | manager.add_prompt(prompt) 72 | messages = await manager.render_prompt("fn") 73 | assert messages == [ 74 | UserMessage(content=TextContent(type="text", text="Hello, world!")) 75 | ] 76 | 77 | async def test_render_prompt_with_args(self): 78 | """Test rendering a prompt with arguments.""" 79 | 80 | def fn(name: str) -> str: 81 | return f"Hello, {name}!" 82 | 83 | manager = PromptManager() 84 | prompt = Prompt.from_function(fn) 85 | manager.add_prompt(prompt) 86 | messages = await manager.render_prompt("fn", arguments={"name": "World"}) 87 | assert messages == [ 88 | UserMessage(content=TextContent(type="text", text="Hello, World!")) 89 | ] 90 | 91 | async def test_render_unknown_prompt(self): 92 | """Test rendering a non-existent prompt.""" 93 | manager = PromptManager() 94 | with pytest.raises(ValueError, match="Unknown prompt: unknown"): 95 | await manager.render_prompt("unknown") 96 | 97 | async def test_render_prompt_with_missing_args(self): 98 | """Test rendering a prompt with missing required arguments.""" 99 | 100 | def fn(name: str) -> str: 101 | return f"Hello, {name}!" 102 | 103 | manager = PromptManager() 104 | prompt = Prompt.from_function(fn) 105 | manager.add_prompt(prompt) 106 | with pytest.raises(ValueError, match="Missing required arguments"): 107 | await manager.render_prompt("fn") 108 | -------------------------------------------------------------------------------- /tests/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlowin/fastmcp/80c328b3dc0b949f010456ee0e85cc5c90e3305f/tests/resources/__init__.py -------------------------------------------------------------------------------- /tests/resources/test_file_resources.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | from pathlib import Path 5 | from tempfile import NamedTemporaryFile 6 | from pydantic import FileUrl 7 | 8 | from fastmcp.resources import FileResource 9 | 10 | 11 | @pytest.fixture 12 | def temp_file(): 13 | """Create a temporary file for testing. 14 | 15 | File is automatically cleaned up after the test if it still exists. 16 | """ 17 | content = "test content" 18 | with NamedTemporaryFile(mode="w", delete=False) as f: 19 | f.write(content) 20 | path = Path(f.name).resolve() 21 | yield path 22 | try: 23 | path.unlink() 24 | except FileNotFoundError: 25 | pass # File was already deleted by the test 26 | 27 | 28 | class TestFileResource: 29 | """Test FileResource functionality.""" 30 | 31 | def test_file_resource_creation(self, temp_file: Path): 32 | """Test creating a FileResource.""" 33 | resource = FileResource( 34 | uri=FileUrl(temp_file.as_uri()), 35 | name="test", 36 | description="test file", 37 | path=temp_file, 38 | ) 39 | assert str(resource.uri) == temp_file.as_uri() 40 | assert resource.name == "test" 41 | assert resource.description == "test file" 42 | assert resource.mime_type == "text/plain" # default 43 | assert resource.path == temp_file 44 | assert resource.is_binary is False # default 45 | 46 | def test_file_resource_str_path_conversion(self, temp_file: Path): 47 | """Test FileResource handles string paths.""" 48 | resource = FileResource( 49 | uri=FileUrl(f"file://{temp_file}"), 50 | name="test", 51 | path=Path(str(temp_file)), 52 | ) 53 | assert isinstance(resource.path, Path) 54 | assert resource.path.is_absolute() 55 | 56 | async def test_read_text_file(self, temp_file: Path): 57 | """Test reading a text file.""" 58 | resource = FileResource( 59 | uri=FileUrl(f"file://{temp_file}"), 60 | name="test", 61 | path=temp_file, 62 | ) 63 | content = await resource.read() 64 | assert content == "test content" 65 | assert resource.mime_type == "text/plain" 66 | 67 | async def test_read_binary_file(self, temp_file: Path): 68 | """Test reading a file as binary.""" 69 | resource = FileResource( 70 | uri=FileUrl(f"file://{temp_file}"), 71 | name="test", 72 | path=temp_file, 73 | is_binary=True, 74 | ) 75 | content = await resource.read() 76 | assert isinstance(content, bytes) 77 | assert content == b"test content" 78 | 79 | def test_relative_path_error(self): 80 | """Test error on relative path.""" 81 | with pytest.raises(ValueError, match="Path must be absolute"): 82 | FileResource( 83 | uri=FileUrl("file:///test.txt"), 84 | name="test", 85 | path=Path("test.txt"), 86 | ) 87 | 88 | async def test_missing_file_error(self, temp_file: Path): 89 | """Test error when file doesn't exist.""" 90 | # Create path to non-existent file 91 | missing = temp_file.parent / "missing.txt" 92 | resource = FileResource( 93 | uri=FileUrl("file:///missing.txt"), 94 | name="test", 95 | path=missing, 96 | ) 97 | with pytest.raises(ValueError, match="Error reading file"): 98 | await resource.read() 99 | 100 | @pytest.mark.skipif( 101 | os.name == "nt", reason="File permissions behave differently on Windows" 102 | ) 103 | async def test_permission_error(self, temp_file: Path): 104 | """Test reading a file without permissions.""" 105 | temp_file.chmod(0o000) # Remove all permissions 106 | try: 107 | resource = FileResource( 108 | uri=FileUrl(temp_file.as_uri()), 109 | name="test", 110 | path=temp_file, 111 | ) 112 | with pytest.raises(ValueError, match="Error reading file"): 113 | await resource.read() 114 | finally: 115 | temp_file.chmod(0o644) # Restore permissions 116 | -------------------------------------------------------------------------------- /tests/resources/test_function_resources.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, AnyUrl 2 | import pytest 3 | from fastmcp.resources import FunctionResource 4 | 5 | 6 | class TestFunctionResource: 7 | """Test FunctionResource functionality.""" 8 | 9 | def test_function_resource_creation(self): 10 | """Test creating a FunctionResource.""" 11 | 12 | def my_func() -> str: 13 | return "test content" 14 | 15 | resource = FunctionResource( 16 | uri=AnyUrl("fn://test"), 17 | name="test", 18 | description="test function", 19 | fn=my_func, 20 | ) 21 | assert str(resource.uri) == "fn://test" 22 | assert resource.name == "test" 23 | assert resource.description == "test function" 24 | assert resource.mime_type == "text/plain" # default 25 | assert resource.fn == my_func 26 | 27 | async def test_read_text(self): 28 | """Test reading text from a FunctionResource.""" 29 | 30 | def get_data() -> str: 31 | return "Hello, world!" 32 | 33 | resource = FunctionResource( 34 | uri=AnyUrl("function://test"), 35 | name="test", 36 | fn=get_data, 37 | ) 38 | content = await resource.read() 39 | assert content == "Hello, world!" 40 | assert resource.mime_type == "text/plain" 41 | 42 | async def test_read_binary(self): 43 | """Test reading binary data from a FunctionResource.""" 44 | 45 | def get_data() -> bytes: 46 | return b"Hello, world!" 47 | 48 | resource = FunctionResource( 49 | uri=AnyUrl("function://test"), 50 | name="test", 51 | fn=get_data, 52 | ) 53 | content = await resource.read() 54 | assert content == b"Hello, world!" 55 | 56 | async def test_json_conversion(self): 57 | """Test automatic JSON conversion of non-string results.""" 58 | 59 | def get_data() -> dict: 60 | return {"key": "value"} 61 | 62 | resource = FunctionResource( 63 | uri=AnyUrl("function://test"), 64 | name="test", 65 | fn=get_data, 66 | ) 67 | content = await resource.read() 68 | assert isinstance(content, str) 69 | assert '"key": "value"' in content 70 | 71 | async def test_error_handling(self): 72 | """Test error handling in FunctionResource.""" 73 | 74 | def failing_func() -> str: 75 | raise ValueError("Test error") 76 | 77 | resource = FunctionResource( 78 | uri=AnyUrl("function://test"), 79 | name="test", 80 | fn=failing_func, 81 | ) 82 | with pytest.raises(ValueError, match="Error reading resource function://test"): 83 | await resource.read() 84 | 85 | async def test_basemodel_conversion(self): 86 | """Test handling of BaseModel types.""" 87 | 88 | class MyModel(BaseModel): 89 | name: str 90 | 91 | resource = FunctionResource( 92 | uri=AnyUrl("function://test"), 93 | name="test", 94 | fn=lambda: MyModel(name="test"), 95 | ) 96 | content = await resource.read() 97 | assert content == '{"name": "test"}' 98 | 99 | async def test_custom_type_conversion(self): 100 | """Test handling of custom types.""" 101 | 102 | class CustomData: 103 | def __str__(self) -> str: 104 | return "custom data" 105 | 106 | def get_data() -> CustomData: 107 | return CustomData() 108 | 109 | resource = FunctionResource( 110 | uri=AnyUrl("function://test"), 111 | name="test", 112 | fn=get_data, 113 | ) 114 | content = await resource.read() 115 | assert isinstance(content, str) 116 | -------------------------------------------------------------------------------- /tests/resources/test_resource_manager.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pathlib import Path 3 | from tempfile import NamedTemporaryFile 4 | from pydantic import AnyUrl, FileUrl 5 | 6 | from fastmcp.resources import ( 7 | FileResource, 8 | FunctionResource, 9 | ResourceManager, 10 | ResourceTemplate, 11 | ) 12 | 13 | 14 | @pytest.fixture 15 | def temp_file(): 16 | """Create a temporary file for testing. 17 | 18 | File is automatically cleaned up after the test if it still exists. 19 | """ 20 | content = "test content" 21 | with NamedTemporaryFile(mode="w", delete=False) as f: 22 | f.write(content) 23 | path = Path(f.name).resolve() 24 | yield path 25 | try: 26 | path.unlink() 27 | except FileNotFoundError: 28 | pass # File was already deleted by the test 29 | 30 | 31 | class TestResourceManager: 32 | """Test ResourceManager functionality.""" 33 | 34 | def test_add_resource(self, temp_file: Path): 35 | """Test adding a resource.""" 36 | manager = ResourceManager() 37 | resource = FileResource( 38 | uri=FileUrl(f"file://{temp_file}"), 39 | name="test", 40 | path=temp_file, 41 | ) 42 | added = manager.add_resource(resource) 43 | assert added == resource 44 | assert manager.list_resources() == [resource] 45 | 46 | def test_add_duplicate_resource(self, temp_file: Path): 47 | """Test adding the same resource twice.""" 48 | manager = ResourceManager() 49 | resource = FileResource( 50 | uri=FileUrl(f"file://{temp_file}"), 51 | name="test", 52 | path=temp_file, 53 | ) 54 | first = manager.add_resource(resource) 55 | second = manager.add_resource(resource) 56 | assert first == second 57 | assert manager.list_resources() == [resource] 58 | 59 | def test_warn_on_duplicate_resources(self, temp_file: Path, caplog): 60 | """Test warning on duplicate resources.""" 61 | manager = ResourceManager() 62 | resource = FileResource( 63 | uri=FileUrl(f"file://{temp_file}"), 64 | name="test", 65 | path=temp_file, 66 | ) 67 | manager.add_resource(resource) 68 | manager.add_resource(resource) 69 | assert "Resource already exists" in caplog.text 70 | 71 | def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog): 72 | """Test disabling warning on duplicate resources.""" 73 | manager = ResourceManager(warn_on_duplicate_resources=False) 74 | resource = FileResource( 75 | uri=FileUrl(f"file://{temp_file}"), 76 | name="test", 77 | path=temp_file, 78 | ) 79 | manager.add_resource(resource) 80 | manager.add_resource(resource) 81 | assert "Resource already exists" not in caplog.text 82 | 83 | async def test_get_resource(self, temp_file: Path): 84 | """Test getting a resource by URI.""" 85 | manager = ResourceManager() 86 | resource = FileResource( 87 | uri=FileUrl(f"file://{temp_file}"), 88 | name="test", 89 | path=temp_file, 90 | ) 91 | manager.add_resource(resource) 92 | retrieved = await manager.get_resource(resource.uri) 93 | assert retrieved == resource 94 | 95 | async def test_get_resource_from_template(self): 96 | """Test getting a resource through a template.""" 97 | manager = ResourceManager() 98 | 99 | def greet(name: str) -> str: 100 | return f"Hello, {name}!" 101 | 102 | template = ResourceTemplate.from_function( 103 | fn=greet, 104 | uri_template="greet://{name}", 105 | name="greeter", 106 | ) 107 | manager._templates[template.uri_template] = template 108 | 109 | resource = await manager.get_resource(AnyUrl("greet://world")) 110 | assert isinstance(resource, FunctionResource) 111 | content = await resource.read() 112 | assert content == "Hello, world!" 113 | 114 | async def test_get_unknown_resource(self): 115 | """Test getting a non-existent resource.""" 116 | manager = ResourceManager() 117 | with pytest.raises(ValueError, match="Unknown resource"): 118 | await manager.get_resource(AnyUrl("unknown://test")) 119 | 120 | def test_list_resources(self, temp_file: Path): 121 | """Test listing all resources.""" 122 | manager = ResourceManager() 123 | resource1 = FileResource( 124 | uri=FileUrl(f"file://{temp_file}"), 125 | name="test1", 126 | path=temp_file, 127 | ) 128 | resource2 = FileResource( 129 | uri=FileUrl(f"file://{temp_file}2"), 130 | name="test2", 131 | path=temp_file, 132 | ) 133 | manager.add_resource(resource1) 134 | manager.add_resource(resource2) 135 | resources = manager.list_resources() 136 | assert len(resources) == 2 137 | assert resources == [resource1, resource2] 138 | -------------------------------------------------------------------------------- /tests/resources/test_resource_template.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pytest 3 | from pydantic import BaseModel 4 | 5 | from fastmcp.resources import FunctionResource, ResourceTemplate 6 | 7 | 8 | class TestResourceTemplate: 9 | """Test ResourceTemplate functionality.""" 10 | 11 | def test_template_creation(self): 12 | """Test creating a template from a function.""" 13 | 14 | def my_func(key: str, value: int) -> dict: 15 | return {"key": key, "value": value} 16 | 17 | template = ResourceTemplate.from_function( 18 | fn=my_func, 19 | uri_template="test://{key}/{value}", 20 | name="test", 21 | ) 22 | assert template.uri_template == "test://{key}/{value}" 23 | assert template.name == "test" 24 | assert template.mime_type == "text/plain" # default 25 | test_input = {"key": "test", "value": 42} 26 | assert template.fn(**test_input) == my_func(**test_input) 27 | 28 | def test_template_matches(self): 29 | """Test matching URIs against a template.""" 30 | 31 | def my_func(key: str, value: int) -> dict: 32 | return {"key": key, "value": value} 33 | 34 | template = ResourceTemplate.from_function( 35 | fn=my_func, 36 | uri_template="test://{key}/{value}", 37 | name="test", 38 | ) 39 | 40 | # Valid match 41 | params = template.matches("test://foo/123") 42 | assert params == {"key": "foo", "value": "123"} 43 | 44 | # No match 45 | assert template.matches("test://foo") is None 46 | assert template.matches("other://foo/123") is None 47 | 48 | async def test_create_resource(self): 49 | """Test creating a resource from a template.""" 50 | 51 | def my_func(key: str, value: int) -> dict: 52 | return {"key": key, "value": value} 53 | 54 | template = ResourceTemplate.from_function( 55 | fn=my_func, 56 | uri_template="test://{key}/{value}", 57 | name="test", 58 | ) 59 | 60 | resource = await template.create_resource( 61 | "test://foo/123", 62 | {"key": "foo", "value": 123}, 63 | ) 64 | 65 | assert isinstance(resource, FunctionResource) 66 | content = await resource.read() 67 | assert isinstance(content, str) 68 | data = json.loads(content) 69 | assert data == {"key": "foo", "value": 123} 70 | 71 | async def test_template_error(self): 72 | """Test error handling in template resource creation.""" 73 | 74 | def failing_func(x: str) -> str: 75 | raise ValueError("Test error") 76 | 77 | template = ResourceTemplate.from_function( 78 | fn=failing_func, 79 | uri_template="fail://{x}", 80 | name="fail", 81 | ) 82 | 83 | with pytest.raises(ValueError, match="Error creating resource from template"): 84 | await template.create_resource("fail://test", {"x": "test"}) 85 | 86 | async def test_async_text_resource(self): 87 | """Test creating a text resource from async function.""" 88 | 89 | async def greet(name: str) -> str: 90 | return f"Hello, {name}!" 91 | 92 | template = ResourceTemplate.from_function( 93 | fn=greet, 94 | uri_template="greet://{name}", 95 | name="greeter", 96 | ) 97 | 98 | resource = await template.create_resource( 99 | "greet://world", 100 | {"name": "world"}, 101 | ) 102 | 103 | assert isinstance(resource, FunctionResource) 104 | content = await resource.read() 105 | assert content == "Hello, world!" 106 | 107 | async def test_async_binary_resource(self): 108 | """Test creating a binary resource from async function.""" 109 | 110 | async def get_bytes(value: str) -> bytes: 111 | return value.encode() 112 | 113 | template = ResourceTemplate.from_function( 114 | fn=get_bytes, 115 | uri_template="bytes://{value}", 116 | name="bytes", 117 | ) 118 | 119 | resource = await template.create_resource( 120 | "bytes://test", 121 | {"value": "test"}, 122 | ) 123 | 124 | assert isinstance(resource, FunctionResource) 125 | content = await resource.read() 126 | assert content == b"test" 127 | 128 | async def test_basemodel_conversion(self): 129 | """Test handling of BaseModel types.""" 130 | 131 | class MyModel(BaseModel): 132 | key: str 133 | value: int 134 | 135 | def get_data(key: str, value: int) -> MyModel: 136 | return MyModel(key=key, value=value) 137 | 138 | template = ResourceTemplate.from_function( 139 | fn=get_data, 140 | uri_template="test://{key}/{value}", 141 | name="test", 142 | ) 143 | 144 | resource = await template.create_resource( 145 | "test://foo/123", 146 | {"key": "foo", "value": 123}, 147 | ) 148 | 149 | assert isinstance(resource, FunctionResource) 150 | content = await resource.read() 151 | assert isinstance(content, str) 152 | data = json.loads(content) 153 | assert data == {"key": "foo", "value": 123} 154 | 155 | async def test_custom_type_conversion(self): 156 | """Test handling of custom types.""" 157 | 158 | class CustomData: 159 | def __init__(self, value: str): 160 | self.value = value 161 | 162 | def __str__(self) -> str: 163 | return self.value 164 | 165 | def get_data(value: str) -> CustomData: 166 | return CustomData(value) 167 | 168 | template = ResourceTemplate.from_function( 169 | fn=get_data, 170 | uri_template="test://{value}", 171 | name="test", 172 | ) 173 | 174 | resource = await template.create_resource( 175 | "test://hello", 176 | {"value": "hello"}, 177 | ) 178 | 179 | assert isinstance(resource, FunctionResource) 180 | content = await resource.read() 181 | assert content == "hello" 182 | -------------------------------------------------------------------------------- /tests/resources/test_resources.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pydantic import AnyUrl 3 | 4 | from fastmcp.resources import FunctionResource, Resource 5 | 6 | 7 | class TestResourceValidation: 8 | """Test base Resource validation.""" 9 | 10 | def test_resource_uri_validation(self): 11 | """Test URI validation.""" 12 | 13 | def dummy_func() -> str: 14 | return "data" 15 | 16 | # Valid URI 17 | resource = FunctionResource( 18 | uri=AnyUrl("http://example.com/data"), 19 | name="test", 20 | fn=dummy_func, 21 | ) 22 | assert str(resource.uri) == "http://example.com/data" 23 | 24 | # Missing protocol 25 | with pytest.raises(ValueError, match="Input should be a valid URL"): 26 | FunctionResource( 27 | uri=AnyUrl("invalid"), 28 | name="test", 29 | fn=dummy_func, 30 | ) 31 | 32 | # Missing host 33 | with pytest.raises(ValueError, match="Input should be a valid URL"): 34 | FunctionResource( 35 | uri=AnyUrl("http://"), 36 | name="test", 37 | fn=dummy_func, 38 | ) 39 | 40 | def test_resource_name_from_uri(self): 41 | """Test name is extracted from URI if not provided.""" 42 | 43 | def dummy_func() -> str: 44 | return "data" 45 | 46 | resource = FunctionResource( 47 | uri=AnyUrl("resource://my-resource"), 48 | fn=dummy_func, 49 | ) 50 | assert resource.name == "resource://my-resource" 51 | 52 | def test_resource_name_validation(self): 53 | """Test name validation.""" 54 | 55 | def dummy_func() -> str: 56 | return "data" 57 | 58 | # Must provide either name or URI 59 | with pytest.raises(ValueError, match="Either name or uri must be provided"): 60 | FunctionResource( 61 | fn=dummy_func, 62 | ) 63 | 64 | # Explicit name takes precedence over URI 65 | resource = FunctionResource( 66 | uri=AnyUrl("resource://uri-name"), 67 | name="explicit-name", 68 | fn=dummy_func, 69 | ) 70 | assert resource.name == "explicit-name" 71 | 72 | def test_resource_mime_type(self): 73 | """Test mime type handling.""" 74 | 75 | def dummy_func() -> str: 76 | return "data" 77 | 78 | # Default mime type 79 | resource = FunctionResource( 80 | uri=AnyUrl("resource://test"), 81 | fn=dummy_func, 82 | ) 83 | assert resource.mime_type == "text/plain" 84 | 85 | # Custom mime type 86 | resource = FunctionResource( 87 | uri=AnyUrl("resource://test"), 88 | fn=dummy_func, 89 | mime_type="application/json", 90 | ) 91 | assert resource.mime_type == "application/json" 92 | 93 | async def test_resource_read_abstract(self): 94 | """Test that Resource.read() is abstract.""" 95 | 96 | class ConcreteResource(Resource): 97 | pass 98 | 99 | with pytest.raises(TypeError, match="abstract method"): 100 | ConcreteResource(uri=AnyUrl("test://test"), name="test") # type: ignore 101 | -------------------------------------------------------------------------------- /tests/servers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlowin/fastmcp/80c328b3dc0b949f010456ee0e85cc5c90e3305f/tests/servers/__init__.py -------------------------------------------------------------------------------- /tests/servers/test_file_server.py: -------------------------------------------------------------------------------- 1 | import json 2 | from fastmcp import FastMCP 3 | import pytest 4 | from pathlib import Path 5 | 6 | 7 | @pytest.fixture() 8 | def test_dir(tmp_path_factory) -> Path: 9 | """Create a temporary directory with test files.""" 10 | tmp = tmp_path_factory.mktemp("test_files") 11 | 12 | # Create test files 13 | (tmp / "example.py").write_text("print('hello world')") 14 | (tmp / "readme.md").write_text("# Test Directory\nThis is a test.") 15 | (tmp / "config.json").write_text('{"test": true}') 16 | 17 | return tmp 18 | 19 | 20 | @pytest.fixture 21 | def mcp() -> FastMCP: 22 | mcp = FastMCP() 23 | 24 | return mcp 25 | 26 | 27 | @pytest.fixture(autouse=True) 28 | def resources(mcp: FastMCP, test_dir: Path) -> FastMCP: 29 | @mcp.resource("dir://test_dir") 30 | def list_test_dir() -> list[str]: 31 | """List the files in the test directory""" 32 | return [str(f) for f in test_dir.iterdir()] 33 | 34 | @mcp.resource("file://test_dir/example.py") 35 | def read_example_py() -> str: 36 | """Read the example.py file""" 37 | try: 38 | return (test_dir / "example.py").read_text() 39 | except FileNotFoundError: 40 | return "File not found" 41 | 42 | @mcp.resource("file://test_dir/readme.md") 43 | def read_readme_md() -> str: 44 | """Read the readme.md file""" 45 | try: 46 | return (test_dir / "readme.md").read_text() 47 | except FileNotFoundError: 48 | return "File not found" 49 | 50 | @mcp.resource("file://test_dir/config.json") 51 | def read_config_json() -> str: 52 | """Read the config.json file""" 53 | try: 54 | return (test_dir / "config.json").read_text() 55 | except FileNotFoundError: 56 | return "File not found" 57 | 58 | return mcp 59 | 60 | 61 | @pytest.fixture(autouse=True) 62 | def tools(mcp: FastMCP, test_dir: Path) -> FastMCP: 63 | @mcp.tool() 64 | def delete_file(path: str) -> bool: 65 | # ensure path is in test_dir 66 | if Path(path).resolve().parent != test_dir: 67 | raise ValueError(f"Path must be in test_dir: {path}") 68 | Path(path).unlink() 69 | return True 70 | 71 | return mcp 72 | 73 | 74 | async def test_list_resources(mcp: FastMCP): 75 | resources = await mcp.list_resources() 76 | assert len(resources) == 4 77 | 78 | assert [str(r.uri) for r in resources] == [ 79 | "dir://test_dir", 80 | "file://test_dir/example.py", 81 | "file://test_dir/readme.md", 82 | "file://test_dir/config.json", 83 | ] 84 | 85 | 86 | async def test_read_resource_dir(mcp: FastMCP): 87 | files = await mcp.read_resource("dir://test_dir") 88 | files = json.loads(files) 89 | 90 | assert sorted([Path(f).name for f in files]) == [ 91 | "config.json", 92 | "example.py", 93 | "readme.md", 94 | ] 95 | 96 | 97 | async def test_read_resource_file(mcp: FastMCP): 98 | result = await mcp.read_resource("file://test_dir/example.py") 99 | assert result == "print('hello world')" 100 | 101 | 102 | async def test_delete_file(mcp: FastMCP, test_dir: Path): 103 | await mcp.call_tool( 104 | "delete_file", arguments=dict(path=str(test_dir / "example.py")) 105 | ) 106 | assert not (test_dir / "example.py").exists() 107 | 108 | 109 | async def test_delete_file_and_check_resources(mcp: FastMCP, test_dir: Path): 110 | await mcp.call_tool( 111 | "delete_file", arguments=dict(path=str(test_dir / "example.py")) 112 | ) 113 | result = await mcp.read_resource("file://test_dir/example.py") 114 | assert result == "File not found" 115 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | """Tests for the FastMCP CLI.""" 2 | 3 | import json 4 | import sys 5 | from pathlib import Path 6 | from unittest.mock import call, patch 7 | 8 | import pytest 9 | from typer.testing import CliRunner 10 | 11 | from fastmcp.cli.cli import _parse_env_var, _parse_file_path, app 12 | 13 | 14 | @pytest.fixture 15 | def mock_config(tmp_path): 16 | """Create a mock Claude config file.""" 17 | config = {"mcpServers": {}} 18 | config_file = tmp_path / "claude_desktop_config.json" 19 | config_file.write_text(json.dumps(config)) 20 | return config_file 21 | 22 | 23 | @pytest.fixture 24 | def server_file(tmp_path): 25 | """Create a server file.""" 26 | server_file = tmp_path / "server.py" 27 | server_file.write_text( 28 | """from fastmcp import FastMCP 29 | mcp = FastMCP("test") 30 | """ 31 | ) 32 | return server_file 33 | 34 | 35 | @pytest.fixture 36 | def mock_env_file(tmp_path): 37 | """Create a mock .env file.""" 38 | env_file = tmp_path / ".env" 39 | env_file.write_text("FOO=bar\nBAZ=123") 40 | return env_file 41 | 42 | 43 | def test_parse_env_var(): 44 | """Test parsing environment variables.""" 45 | assert _parse_env_var("FOO=bar") == ("FOO", "bar") 46 | assert _parse_env_var("FOO=") == ("FOO", "") 47 | assert _parse_env_var("FOO=bar baz") == ("FOO", "bar baz") 48 | assert _parse_env_var("FOO = bar ") == ("FOO", "bar") 49 | 50 | with pytest.raises(SystemExit): 51 | _parse_env_var("invalid") 52 | 53 | 54 | @pytest.mark.parametrize( 55 | "args,expected_env", 56 | [ 57 | # Basic env var 58 | ( 59 | ["--env-var", "FOO=bar"], 60 | {"FOO": "bar"}, 61 | ), 62 | # Multiple env vars 63 | ( 64 | ["--env-var", "FOO=bar", "--env-var", "BAZ=123"], 65 | {"FOO": "bar", "BAZ": "123"}, 66 | ), 67 | # Env var with spaces 68 | ( 69 | ["--env-var", "FOO=bar baz"], 70 | {"FOO": "bar baz"}, 71 | ), 72 | ], 73 | ) 74 | def test_install_with_env_vars(mock_config, server_file, args, expected_env): 75 | """Test installing with environment variables.""" 76 | runner = CliRunner() 77 | 78 | with patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path: 79 | mock_config_path.return_value = mock_config.parent 80 | 81 | result = runner.invoke( 82 | app, 83 | ["install", str(server_file)] + args, 84 | ) 85 | 86 | assert result.exit_code == 0 87 | 88 | # Read the config file and check env vars 89 | config = json.loads(mock_config.read_text()) 90 | assert "mcpServers" in config 91 | assert len(config["mcpServers"]) == 1 92 | server = next(iter(config["mcpServers"].values())) 93 | assert server["env"] == expected_env 94 | 95 | 96 | def test_parse_file_path_windows_drive(): 97 | """Test parsing a Windows file path with a drive letter.""" 98 | file_spec = r"C:\path\to\file.txt" 99 | with ( 100 | patch("pathlib.Path.exists", return_value=True), 101 | patch("pathlib.Path.is_file", return_value=True), 102 | ): 103 | file_path, server_object = _parse_file_path(file_spec) 104 | assert file_path == Path(r"C:\path\to\file.txt").resolve() 105 | assert server_object is None 106 | 107 | 108 | def test_parse_file_path_with_object(): 109 | """Test parsing a file path with an object specification.""" 110 | file_spec = "/path/to/file.txt:object" 111 | with patch("sys.exit") as mock_exit: 112 | _parse_file_path(file_spec) 113 | 114 | # Check that sys.exit was called twice with code 1 115 | assert mock_exit.call_count == 2 116 | mock_exit.assert_has_calls([call(1), call(1)]) 117 | 118 | 119 | def test_parse_file_path_windows_with_object(): 120 | """Test parsing a Windows file path with an object specification.""" 121 | file_spec = r"C:\path\to\file.txt:object" 122 | with ( 123 | patch("pathlib.Path.exists", return_value=True), 124 | patch("pathlib.Path.is_file", return_value=True), 125 | ): 126 | file_path, server_object = _parse_file_path(file_spec) 127 | assert file_path == Path(r"C:\path\to\file.txt").resolve() 128 | assert server_object == "object" 129 | 130 | 131 | def test_install_with_env_file(mock_config, server_file, mock_env_file): 132 | """Test installing with environment variables from a file.""" 133 | runner = CliRunner() 134 | 135 | with patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path: 136 | mock_config_path.return_value = mock_config.parent 137 | 138 | result = runner.invoke( 139 | app, 140 | ["install", str(server_file), "--env-file", str(mock_env_file)], 141 | ) 142 | 143 | assert result.exit_code == 0 144 | 145 | # Read the config file and check env vars 146 | config = json.loads(mock_config.read_text()) 147 | assert "mcpServers" in config 148 | assert len(config["mcpServers"]) == 1 149 | server = next(iter(config["mcpServers"].values())) 150 | assert server["env"] == {"FOO": "bar", "BAZ": "123"} 151 | 152 | 153 | def test_install_preserves_existing_env_vars(mock_config, server_file): 154 | """Test that installing preserves existing environment variables.""" 155 | # Set up initial config with env vars 156 | config = { 157 | "mcpServers": { 158 | "test": { 159 | "command": "uv", 160 | "args": [ 161 | "run", 162 | "--with", 163 | "fastmcp", 164 | "fastmcp", 165 | "run", 166 | str(server_file), 167 | ], 168 | "env": {"FOO": "bar", "BAZ": "123"}, 169 | } 170 | } 171 | } 172 | mock_config.write_text(json.dumps(config)) 173 | 174 | runner = CliRunner() 175 | 176 | with patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path: 177 | mock_config_path.return_value = mock_config.parent 178 | 179 | # Install with a new env var 180 | result = runner.invoke( 181 | app, 182 | ["install", str(server_file), "--env-var", "NEW=value"], 183 | ) 184 | 185 | assert result.exit_code == 0 186 | 187 | # Read the config file and check env vars are preserved 188 | config = json.loads(mock_config.read_text()) 189 | server = next(iter(config["mcpServers"].values())) 190 | assert server["env"] == {"FOO": "bar", "BAZ": "123", "NEW": "value"} 191 | 192 | 193 | def test_install_updates_existing_env_vars(mock_config, server_file): 194 | """Test that installing updates existing environment variables.""" 195 | # Set up initial config with env vars 196 | config = { 197 | "mcpServers": { 198 | "test": { 199 | "command": "uv", 200 | "args": [ 201 | "run", 202 | "--with", 203 | "fastmcp", 204 | "fastmcp", 205 | "run", 206 | str(server_file), 207 | ], 208 | "env": {"FOO": "bar", "BAZ": "123"}, 209 | } 210 | } 211 | } 212 | mock_config.write_text(json.dumps(config)) 213 | 214 | runner = CliRunner() 215 | 216 | with patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path: 217 | mock_config_path.return_value = mock_config.parent 218 | 219 | # Update an existing env var 220 | result = runner.invoke( 221 | app, 222 | ["install", str(server_file), "--env-var", "FOO=newvalue"], 223 | ) 224 | 225 | assert result.exit_code == 0 226 | 227 | # Read the config file and check env var was updated 228 | config = json.loads(mock_config.read_text()) 229 | server = next(iter(config["mcpServers"].values())) 230 | assert server["env"] == {"FOO": "newvalue", "BAZ": "123"} 231 | 232 | 233 | def test_server_dependencies(mock_config, server_file): 234 | """Test that server dependencies are correctly handled.""" 235 | # Create a server file with dependencies 236 | server_file = server_file.parent / "server_with_deps.py" 237 | server_file.write_text( 238 | """from fastmcp import FastMCP 239 | mcp = FastMCP("test", dependencies=["pandas", "numpy"]) 240 | """ 241 | ) 242 | 243 | runner = CliRunner() 244 | 245 | with patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path: 246 | mock_config_path.return_value = mock_config.parent 247 | 248 | result = runner.invoke(app, ["install", str(server_file)]) 249 | 250 | assert result.exit_code == 0 251 | 252 | # Read the config file and check dependencies were added as --with args 253 | config = json.loads(mock_config.read_text()) 254 | server = next(iter(config["mcpServers"].values())) 255 | assert "--with" in server["args"] 256 | assert "pandas" in server["args"] 257 | assert "numpy" in server["args"] 258 | 259 | 260 | def test_server_dependencies_empty(mock_config, server_file): 261 | """Test that server with no dependencies works correctly.""" 262 | runner = CliRunner() 263 | 264 | with patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path: 265 | mock_config_path.return_value = mock_config.parent 266 | 267 | result = runner.invoke(app, ["install", str(server_file)]) 268 | 269 | assert result.exit_code == 0 270 | 271 | # Read the config file and check only fastmcp is in --with args 272 | config = json.loads(mock_config.read_text()) 273 | server = next(iter(config["mcpServers"].values())) 274 | assert server["args"].count("--with") == 1 275 | assert "fastmcp" in server["args"] 276 | 277 | 278 | def test_dev_with_dependencies(mock_config, server_file): 279 | """Test that dev command handles dependencies correctly.""" 280 | server_file = server_file.parent / "server_with_deps.py" 281 | server_file.write_text( 282 | """from fastmcp import FastMCP 283 | mcp = FastMCP("test", dependencies=["pandas", "numpy"]) 284 | """ 285 | ) 286 | 287 | runner = CliRunner() 288 | 289 | with patch("subprocess.run") as mock_run: 290 | mock_run.return_value.returncode = 0 291 | result = runner.invoke(app, ["dev", str(server_file)]) 292 | assert result.exit_code == 0 293 | 294 | if sys.platform == "win32": 295 | # On Windows, expect two calls 296 | assert mock_run.call_count == 2 297 | assert mock_run.call_args_list[0] == call( 298 | ["npx.cmd", "--version"], check=True, capture_output=True, shell=True 299 | ) 300 | 301 | # get the actual command and expected command without dependencies 302 | actual_cmd = mock_run.call_args_list[1][0][0] 303 | expected_start = [ 304 | "npx.cmd", 305 | "@modelcontextprotocol/inspector", 306 | "uv", 307 | "run", 308 | "--with", 309 | "fastmcp", 310 | ] 311 | expected_end = ["fastmcp", "run", str(server_file)] 312 | 313 | # verify start and end of command 314 | assert actual_cmd[: len(expected_start)] == expected_start 315 | assert actual_cmd[-len(expected_end) :] == expected_end 316 | 317 | # verify dependencies are present (order-independent) 318 | deps_section = actual_cmd[len(expected_start) : -len(expected_end)] 319 | assert all( 320 | x in deps_section for x in ["--with", "numpy", "--with", "pandas"] 321 | ) 322 | 323 | # Verify subprocess call kwargs, allowing for environment variables 324 | call_kwargs = mock_run.call_args_list[1][1] 325 | assert call_kwargs["check"] is True 326 | assert call_kwargs["shell"] is True 327 | assert isinstance(call_kwargs["env"], dict) 328 | else: 329 | # same verification for unix, just with different command prefix 330 | actual_cmd = mock_run.call_args_list[0][0][0] 331 | expected_start = [ 332 | "npx", 333 | "@modelcontextprotocol/inspector", 334 | "uv", 335 | "run", 336 | "--with", 337 | "fastmcp", 338 | ] 339 | expected_end = ["fastmcp", "run", str(server_file)] 340 | 341 | assert actual_cmd[: len(expected_start)] == expected_start 342 | assert actual_cmd[-len(expected_end) :] == expected_end 343 | 344 | deps_section = actual_cmd[len(expected_start) : -len(expected_end)] 345 | assert all( 346 | x in deps_section for x in ["--with", "numpy", "--with", "pandas"] 347 | ) 348 | 349 | # Verify subprocess call kwargs, allowing for environment variables 350 | call_kwargs = mock_run.call_args_list[0][1] 351 | assert call_kwargs["check"] is True 352 | assert call_kwargs["shell"] is False 353 | assert isinstance(call_kwargs["env"], dict) 354 | 355 | 356 | def test_run_with_dependencies(mock_config, server_file): 357 | """Test that run command does not handle dependencies.""" 358 | # Create a server file with dependencies 359 | server_file = server_file.parent / "server_with_deps.py" 360 | server_file.write_text( 361 | """from fastmcp import FastMCP 362 | mcp = FastMCP("test", dependencies=["pandas", "numpy"]) 363 | 364 | if __name__ == "__main__": 365 | mcp.run() 366 | """ 367 | ) 368 | 369 | runner = CliRunner() 370 | 371 | with patch("subprocess.run") as mock_run: 372 | result = runner.invoke(app, ["run", str(server_file)]) 373 | assert result.exit_code == 0 374 | 375 | # Run command should not call subprocess.run 376 | mock_run.assert_not_called() 377 | -------------------------------------------------------------------------------- /tests/test_func_metadata.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | import annotated_types 4 | import pytest 5 | from pydantic import BaseModel, Field 6 | 7 | from fastmcp.utilities.func_metadata import func_metadata 8 | 9 | 10 | class SomeInputModelA(BaseModel): 11 | pass 12 | 13 | 14 | class SomeInputModelB(BaseModel): 15 | class InnerModel(BaseModel): 16 | x: int 17 | 18 | how_many_shrimp: Annotated[int, Field(description="How many shrimp in the tank???")] 19 | ok: InnerModel 20 | y: None 21 | 22 | 23 | def complex_arguments_fn( 24 | an_int: int, 25 | must_be_none: None, 26 | must_be_none_dumb_annotation: Annotated[None, "blah"], 27 | list_of_ints: list[int], 28 | # list[str] | str is an interesting case because if it comes in as JSON like 29 | # "[\"a\", \"b\"]" then it will be naively parsed as a string. 30 | list_str_or_str: list[str] | str, 31 | an_int_annotated_with_field: Annotated[ 32 | int, Field(description="An int with a field") 33 | ], 34 | an_int_annotated_with_field_and_others: Annotated[ 35 | int, 36 | str, # Should be ignored, really 37 | Field(description="An int with a field"), 38 | annotated_types.Gt(1), 39 | ], 40 | an_int_annotated_with_junk: Annotated[ 41 | int, 42 | "123", 43 | 456, 44 | ], 45 | field_with_default_via_field_annotation_before_nondefault_arg: Annotated[ 46 | int, Field(1) 47 | ], 48 | unannotated, 49 | my_model_a: SomeInputModelA, 50 | my_model_a_forward_ref: "SomeInputModelA", 51 | my_model_b: SomeInputModelB, 52 | an_int_annotated_with_field_default: Annotated[ 53 | int, 54 | Field(1, description="An int with a field"), 55 | ], 56 | unannotated_with_default=5, 57 | my_model_a_with_default: SomeInputModelA = SomeInputModelA(), # noqa: B008 58 | an_int_with_default: int = 1, 59 | must_be_none_with_default: None = None, 60 | an_int_with_equals_field: int = Field(1, ge=0), 61 | int_annotated_with_default: Annotated[int, Field(description="hey")] = 5, 62 | ) -> str: 63 | _ = ( 64 | an_int, 65 | must_be_none, 66 | must_be_none_dumb_annotation, 67 | list_of_ints, 68 | list_str_or_str, 69 | an_int_annotated_with_field, 70 | an_int_annotated_with_field_and_others, 71 | an_int_annotated_with_junk, 72 | field_with_default_via_field_annotation_before_nondefault_arg, 73 | unannotated, 74 | an_int_annotated_with_field_default, 75 | unannotated_with_default, 76 | my_model_a, 77 | my_model_a_forward_ref, 78 | my_model_b, 79 | my_model_a_with_default, 80 | an_int_with_default, 81 | must_be_none_with_default, 82 | an_int_with_equals_field, 83 | int_annotated_with_default, 84 | ) 85 | return "ok!" 86 | 87 | 88 | async def test_complex_function_runtime_arg_validation_non_json(): 89 | """Test that basic non-JSON arguments are validated correctly""" 90 | meta = func_metadata(complex_arguments_fn) 91 | 92 | # Test with minimum required arguments 93 | result = await meta.call_fn_with_arg_validation( 94 | complex_arguments_fn, 95 | fn_is_async=False, 96 | arguments_to_validate={ 97 | "an_int": 1, 98 | "must_be_none": None, 99 | "must_be_none_dumb_annotation": None, 100 | "list_of_ints": [1, 2, 3], 101 | "list_str_or_str": "hello", 102 | "an_int_annotated_with_field": 42, 103 | "an_int_annotated_with_field_and_others": 5, 104 | "an_int_annotated_with_junk": 100, 105 | "unannotated": "test", 106 | "my_model_a": {}, 107 | "my_model_a_forward_ref": {}, 108 | "my_model_b": {"how_many_shrimp": 5, "ok": {"x": 1}, "y": None}, 109 | }, 110 | arguments_to_pass_directly=None, 111 | ) 112 | assert result == "ok!" 113 | 114 | # Test with invalid types 115 | with pytest.raises(ValueError): 116 | await meta.call_fn_with_arg_validation( 117 | complex_arguments_fn, 118 | fn_is_async=False, 119 | arguments_to_validate={"an_int": "not an int"}, 120 | arguments_to_pass_directly=None, 121 | ) 122 | 123 | 124 | async def test_complex_function_runtime_arg_validation_with_json(): 125 | """Test that JSON string arguments are parsed and validated correctly""" 126 | meta = func_metadata(complex_arguments_fn) 127 | 128 | result = await meta.call_fn_with_arg_validation( 129 | complex_arguments_fn, 130 | fn_is_async=False, 131 | arguments_to_validate={ 132 | "an_int": 1, 133 | "must_be_none": None, 134 | "must_be_none_dumb_annotation": None, 135 | "list_of_ints": "[1, 2, 3]", # JSON string 136 | "list_str_or_str": '["a", "b", "c"]', # JSON string 137 | "an_int_annotated_with_field": 42, 138 | "an_int_annotated_with_field_and_others": "5", # JSON string 139 | "an_int_annotated_with_junk": 100, 140 | "unannotated": "test", 141 | "my_model_a": "{}", # JSON string 142 | "my_model_a_forward_ref": "{}", # JSON string 143 | "my_model_b": '{"how_many_shrimp": 5, "ok": {"x": 1}, "y": null}', # JSON string 144 | }, 145 | arguments_to_pass_directly=None, 146 | ) 147 | assert result == "ok!" 148 | 149 | 150 | def test_str_vs_list_str(): 151 | """Test handling of string vs list[str] type annotations. 152 | 153 | This is tricky as '"hello"' can be parsed as a JSON string or a Python string. 154 | We want to make sure it's kept as a python string. 155 | """ 156 | 157 | def func_with_str_types(str_or_list: str | list[str]): 158 | return str_or_list 159 | 160 | meta = func_metadata(func_with_str_types) 161 | 162 | # Test string input for union type 163 | result = meta.pre_parse_json({"str_or_list": "hello"}) 164 | assert result["str_or_list"] == "hello" 165 | 166 | # Test string input that contains valid JSON for union type 167 | # We want to see here that the JSON-vali string is NOT parsed as JSON, but rather 168 | # kept as a raw string 169 | result = meta.pre_parse_json({"str_or_list": '"hello"'}) 170 | assert result["str_or_list"] == '"hello"' 171 | 172 | # Test list input for union type 173 | result = meta.pre_parse_json({"str_or_list": '["hello", "world"]'}) 174 | assert result["str_or_list"] == ["hello", "world"] 175 | 176 | 177 | def test_str_vs_int(): 178 | """ 179 | Test that string values are kept as strings even when they contain numbers, 180 | while numbers are parsed correctly. 181 | """ 182 | 183 | def func_with_str_and_int(a: str, b: int): 184 | return a 185 | 186 | meta = func_metadata(func_with_str_and_int) 187 | result = meta.pre_parse_json({"a": "123", "b": 123}) 188 | assert result["a"] == "123" 189 | assert result["b"] == 123 190 | 191 | 192 | def test_skip_names(): 193 | """Test that skipped parameters are not included in the model""" 194 | 195 | def func_with_many_params( 196 | keep_this: int, skip_this: str, also_keep: float, also_skip: bool 197 | ): 198 | return keep_this, skip_this, also_keep, also_skip 199 | 200 | # Skip some parameters 201 | meta = func_metadata(func_with_many_params, skip_names=["skip_this", "also_skip"]) 202 | 203 | # Check model fields 204 | assert "keep_this" in meta.arg_model.model_fields 205 | assert "also_keep" in meta.arg_model.model_fields 206 | assert "skip_this" not in meta.arg_model.model_fields 207 | assert "also_skip" not in meta.arg_model.model_fields 208 | 209 | # Validate that we can call with only non-skipped parameters 210 | model: BaseModel = meta.arg_model.model_validate({"keep_this": 1, "also_keep": 2.5}) # type: ignore 211 | assert model.keep_this == 1 # type: ignore 212 | assert model.also_keep == 2.5 # type: ignore 213 | 214 | 215 | async def test_lambda_function(): 216 | """Test lambda function schema and validation""" 217 | fn = lambda x, y=5: x # noqa: E731 218 | meta = func_metadata(lambda x, y=5: x) 219 | 220 | # Test schema 221 | assert meta.arg_model.model_json_schema() == { 222 | "properties": { 223 | "x": {"title": "x", "type": "string"}, 224 | "y": {"default": 5, "title": "y", "type": "string"}, 225 | }, 226 | "required": ["x"], 227 | "title": "<lambda>Arguments", 228 | "type": "object", 229 | } 230 | 231 | async def check_call(args): 232 | return await meta.call_fn_with_arg_validation( 233 | fn, 234 | fn_is_async=False, 235 | arguments_to_validate=args, 236 | arguments_to_pass_directly=None, 237 | ) 238 | 239 | # Basic calls 240 | assert await check_call({"x": "hello"}) == "hello" 241 | assert await check_call({"x": "hello", "y": "world"}) == "hello" 242 | assert await check_call({"x": '"hello"'}) == '"hello"' 243 | 244 | # Missing required arg 245 | with pytest.raises(ValueError): 246 | await check_call({"y": "world"}) 247 | 248 | 249 | def test_complex_function_json_schema(): 250 | meta = func_metadata(complex_arguments_fn) 251 | assert meta.arg_model.model_json_schema() == { 252 | "$defs": { 253 | "InnerModel": { 254 | "properties": {"x": {"title": "X", "type": "integer"}}, 255 | "required": ["x"], 256 | "title": "InnerModel", 257 | "type": "object", 258 | }, 259 | "SomeInputModelA": { 260 | "properties": {}, 261 | "title": "SomeInputModelA", 262 | "type": "object", 263 | }, 264 | "SomeInputModelB": { 265 | "properties": { 266 | "how_many_shrimp": { 267 | "description": "How many shrimp in the tank???", 268 | "title": "How Many Shrimp", 269 | "type": "integer", 270 | }, 271 | "ok": {"$ref": "#/$defs/InnerModel"}, 272 | "y": {"title": "Y", "type": "null"}, 273 | }, 274 | "required": ["how_many_shrimp", "ok", "y"], 275 | "title": "SomeInputModelB", 276 | "type": "object", 277 | }, 278 | }, 279 | "properties": { 280 | "an_int": {"title": "An Int", "type": "integer"}, 281 | "must_be_none": {"title": "Must Be None", "type": "null"}, 282 | "must_be_none_dumb_annotation": { 283 | "title": "Must Be None Dumb Annotation", 284 | "type": "null", 285 | }, 286 | "list_of_ints": { 287 | "items": {"type": "integer"}, 288 | "title": "List Of Ints", 289 | "type": "array", 290 | }, 291 | "list_str_or_str": { 292 | "anyOf": [ 293 | {"items": {"type": "string"}, "type": "array"}, 294 | {"type": "string"}, 295 | ], 296 | "title": "List Str Or Str", 297 | }, 298 | "an_int_annotated_with_field": { 299 | "description": "An int with a field", 300 | "title": "An Int Annotated With Field", 301 | "type": "integer", 302 | }, 303 | "an_int_annotated_with_field_and_others": { 304 | "description": "An int with a field", 305 | "exclusiveMinimum": 1, 306 | "title": "An Int Annotated With Field And Others", 307 | "type": "integer", 308 | }, 309 | "an_int_annotated_with_junk": { 310 | "title": "An Int Annotated With Junk", 311 | "type": "integer", 312 | }, 313 | "field_with_default_via_field_annotation_before_nondefault_arg": { 314 | "default": 1, 315 | "title": "Field With Default Via Field Annotation Before Nondefault Arg", 316 | "type": "integer", 317 | }, 318 | "unannotated": {"title": "unannotated", "type": "string"}, 319 | "my_model_a": {"$ref": "#/$defs/SomeInputModelA"}, 320 | "my_model_a_forward_ref": {"$ref": "#/$defs/SomeInputModelA"}, 321 | "my_model_b": {"$ref": "#/$defs/SomeInputModelB"}, 322 | "an_int_annotated_with_field_default": { 323 | "default": 1, 324 | "description": "An int with a field", 325 | "title": "An Int Annotated With Field Default", 326 | "type": "integer", 327 | }, 328 | "unannotated_with_default": { 329 | "default": 5, 330 | "title": "unannotated_with_default", 331 | "type": "string", 332 | }, 333 | "my_model_a_with_default": { 334 | "$ref": "#/$defs/SomeInputModelA", 335 | "default": {}, 336 | }, 337 | "an_int_with_default": { 338 | "default": 1, 339 | "title": "An Int With Default", 340 | "type": "integer", 341 | }, 342 | "must_be_none_with_default": { 343 | "default": None, 344 | "title": "Must Be None With Default", 345 | "type": "null", 346 | }, 347 | "an_int_with_equals_field": { 348 | "default": 1, 349 | "minimum": 0, 350 | "title": "An Int With Equals Field", 351 | "type": "integer", 352 | }, 353 | "int_annotated_with_default": { 354 | "default": 5, 355 | "description": "hey", 356 | "title": "Int Annotated With Default", 357 | "type": "integer", 358 | }, 359 | }, 360 | "required": [ 361 | "an_int", 362 | "must_be_none", 363 | "must_be_none_dumb_annotation", 364 | "list_of_ints", 365 | "list_str_or_str", 366 | "an_int_annotated_with_field", 367 | "an_int_annotated_with_field_and_others", 368 | "an_int_annotated_with_junk", 369 | "unannotated", 370 | "my_model_a", 371 | "my_model_a_forward_ref", 372 | "my_model_b", 373 | ], 374 | "title": "complex_arguments_fnArguments", 375 | "type": "object", 376 | } 377 | -------------------------------------------------------------------------------- /tests/test_tool_manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional 3 | 4 | import pytest 5 | from pydantic import BaseModel 6 | import json 7 | from fastmcp.exceptions import ToolError 8 | from fastmcp.tools import ToolManager 9 | 10 | 11 | class TestAddTools: 12 | def test_basic_function(self): 13 | """Test registering and running a basic function.""" 14 | 15 | def add(a: int, b: int) -> int: 16 | """Add two numbers.""" 17 | return a + b 18 | 19 | manager = ToolManager() 20 | manager.add_tool(add) 21 | 22 | tool = manager.get_tool("add") 23 | assert tool is not None 24 | assert tool.name == "add" 25 | assert tool.description == "Add two numbers." 26 | assert tool.is_async is False 27 | assert tool.parameters["properties"]["a"]["type"] == "integer" 28 | assert tool.parameters["properties"]["b"]["type"] == "integer" 29 | 30 | async def test_async_function(self): 31 | """Test registering and running an async function.""" 32 | 33 | async def fetch_data(url: str) -> str: 34 | """Fetch data from URL.""" 35 | return f"Data from {url}" 36 | 37 | manager = ToolManager() 38 | manager.add_tool(fetch_data) 39 | 40 | tool = manager.get_tool("fetch_data") 41 | assert tool is not None 42 | assert tool.name == "fetch_data" 43 | assert tool.description == "Fetch data from URL." 44 | assert tool.is_async is True 45 | assert tool.parameters["properties"]["url"]["type"] == "string" 46 | 47 | def test_pydantic_model_function(self): 48 | """Test registering a function that takes a Pydantic model.""" 49 | 50 | class UserInput(BaseModel): 51 | name: str 52 | age: int 53 | 54 | def create_user(user: UserInput, flag: bool) -> dict: 55 | """Create a new user.""" 56 | return {"id": 1, **user.model_dump()} 57 | 58 | manager = ToolManager() 59 | manager.add_tool(create_user) 60 | 61 | tool = manager.get_tool("create_user") 62 | assert tool is not None 63 | assert tool.name == "create_user" 64 | assert tool.description == "Create a new user." 65 | assert tool.is_async is False 66 | assert "name" in tool.parameters["$defs"]["UserInput"]["properties"] 67 | assert "age" in tool.parameters["$defs"]["UserInput"]["properties"] 68 | assert "flag" in tool.parameters["properties"] 69 | 70 | def test_add_invalid_tool(self): 71 | manager = ToolManager() 72 | with pytest.raises(AttributeError): 73 | manager.add_tool(1) # type: ignore 74 | 75 | def test_add_lambda(self): 76 | manager = ToolManager() 77 | tool = manager.add_tool(lambda x: x, name="my_tool") 78 | assert tool.name == "my_tool" 79 | 80 | def test_add_lambda_with_no_name(self): 81 | manager = ToolManager() 82 | with pytest.raises( 83 | ValueError, match="You must provide a name for lambda functions" 84 | ): 85 | manager.add_tool(lambda x: x) 86 | 87 | def test_warn_on_duplicate_tools(self, caplog): 88 | """Test warning on duplicate tools.""" 89 | 90 | def f(x: int) -> int: 91 | return x 92 | 93 | manager = ToolManager() 94 | manager.add_tool(f) 95 | with caplog.at_level(logging.WARNING): 96 | manager.add_tool(f) 97 | assert "Tool already exists: f" in caplog.text 98 | 99 | def test_disable_warn_on_duplicate_tools(self, caplog): 100 | """Test disabling warning on duplicate tools.""" 101 | 102 | def f(x: int) -> int: 103 | return x 104 | 105 | manager = ToolManager() 106 | manager.add_tool(f) 107 | manager.warn_on_duplicate_tools = False 108 | with caplog.at_level(logging.WARNING): 109 | manager.add_tool(f) 110 | assert "Tool already exists: f" not in caplog.text 111 | 112 | 113 | class TestCallTools: 114 | async def test_call_tool(self): 115 | def add(a: int, b: int) -> int: 116 | """Add two numbers.""" 117 | return a + b 118 | 119 | manager = ToolManager() 120 | manager.add_tool(add) 121 | result = await manager.call_tool("add", {"a": 1, "b": 2}) 122 | assert result == 3 123 | 124 | async def test_call_async_tool(self): 125 | async def double(n: int) -> int: 126 | """Double a number.""" 127 | return n * 2 128 | 129 | manager = ToolManager() 130 | manager.add_tool(double) 131 | result = await manager.call_tool("double", {"n": 5}) 132 | assert result == 10 133 | 134 | async def test_call_tool_with_default_args(self): 135 | def add(a: int, b: int = 1) -> int: 136 | """Add two numbers.""" 137 | return a + b 138 | 139 | manager = ToolManager() 140 | manager.add_tool(add) 141 | result = await manager.call_tool("add", {"a": 1}) 142 | assert result == 2 143 | 144 | async def test_call_tool_with_missing_args(self): 145 | def add(a: int, b: int) -> int: 146 | """Add two numbers.""" 147 | return a + b 148 | 149 | manager = ToolManager() 150 | manager.add_tool(add) 151 | with pytest.raises(ToolError): 152 | await manager.call_tool("add", {"a": 1}) 153 | 154 | async def test_call_unknown_tool(self): 155 | manager = ToolManager() 156 | with pytest.raises(ToolError): 157 | await manager.call_tool("unknown", {"a": 1}) 158 | 159 | async def test_call_tool_with_list_int_input(self): 160 | def sum_vals(vals: list[int]) -> int: 161 | return sum(vals) 162 | 163 | manager = ToolManager() 164 | manager.add_tool(sum_vals) 165 | # Try both with plain list and with JSON list 166 | result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"}) 167 | assert result == 6 168 | result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]}) 169 | assert result == 6 170 | 171 | async def test_call_tool_with_list_str_or_str_input(self): 172 | def concat_strs(vals: list[str] | str) -> str: 173 | return vals if isinstance(vals, str) else "".join(vals) 174 | 175 | manager = ToolManager() 176 | manager.add_tool(concat_strs) 177 | # Try both with plain python object and with JSON list 178 | result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]}) 179 | assert result == "abc" 180 | result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'}) 181 | assert result == "abc" 182 | result = await manager.call_tool("concat_strs", {"vals": "a"}) 183 | assert result == "a" 184 | result = await manager.call_tool("concat_strs", {"vals": '"a"'}) 185 | assert result == '"a"' 186 | 187 | async def test_call_tool_with_complex_model(self): 188 | from fastmcp import Context 189 | 190 | class MyShrimpTank(BaseModel): 191 | class Shrimp(BaseModel): 192 | name: str 193 | 194 | shrimp: list[Shrimp] 195 | x: None 196 | 197 | def name_shrimp(tank: MyShrimpTank, ctx: Context) -> list[str]: 198 | return [x.name for x in tank.shrimp] 199 | 200 | manager = ToolManager() 201 | manager.add_tool(name_shrimp) 202 | result = await manager.call_tool( 203 | "name_shrimp", 204 | {"tank": {"x": None, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}}, 205 | ) 206 | assert result == ["rex", "gertrude"] 207 | result = await manager.call_tool( 208 | "name_shrimp", 209 | {"tank": '{"x": null, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}'}, 210 | ) 211 | assert result == ["rex", "gertrude"] 212 | 213 | 214 | class TestToolSchema: 215 | async def test_context_arg_excluded_from_schema(self): 216 | from fastmcp import Context 217 | 218 | def something(a: int, ctx: Context) -> int: 219 | return a 220 | 221 | manager = ToolManager() 222 | tool = manager.add_tool(something) 223 | assert "ctx" not in json.dumps(tool.parameters) 224 | assert "Context" not in json.dumps(tool.parameters) 225 | assert "ctx" not in tool.fn_metadata.arg_model.model_fields 226 | 227 | 228 | class TestContextHandling: 229 | """Test context handling in the tool manager.""" 230 | 231 | def test_context_parameter_detection(self): 232 | """Test that context parameters are properly detected in Tool.from_function().""" 233 | from fastmcp import Context 234 | 235 | def tool_with_context(x: int, ctx: Context) -> str: 236 | return str(x) 237 | 238 | manager = ToolManager() 239 | tool = manager.add_tool(tool_with_context) 240 | assert tool.context_kwarg == "ctx" 241 | 242 | def tool_without_context(x: int) -> str: 243 | return str(x) 244 | 245 | tool = manager.add_tool(tool_without_context) 246 | assert tool.context_kwarg is None 247 | 248 | async def test_context_injection(self): 249 | """Test that context is properly injected during tool execution.""" 250 | from fastmcp import Context, FastMCP 251 | 252 | def tool_with_context(x: int, ctx: Context) -> str: 253 | assert isinstance(ctx, Context) 254 | return str(x) 255 | 256 | manager = ToolManager() 257 | manager.add_tool(tool_with_context) 258 | 259 | mcp = FastMCP() 260 | ctx = mcp.get_context() 261 | result = await manager.call_tool("tool_with_context", {"x": 42}, context=ctx) 262 | assert result == "42" 263 | 264 | async def test_context_injection_async(self): 265 | """Test that context is properly injected in async tools.""" 266 | from fastmcp import Context, FastMCP 267 | 268 | async def async_tool(x: int, ctx: Context) -> str: 269 | assert isinstance(ctx, Context) 270 | return str(x) 271 | 272 | manager = ToolManager() 273 | manager.add_tool(async_tool) 274 | 275 | mcp = FastMCP() 276 | ctx = mcp.get_context() 277 | result = await manager.call_tool("async_tool", {"x": 42}, context=ctx) 278 | assert result == "42" 279 | 280 | async def test_context_optional(self): 281 | """Test that context is optional when calling tools.""" 282 | from fastmcp import Context 283 | 284 | def tool_with_context(x: int, ctx: Optional[Context] = None) -> str: 285 | return str(x) 286 | 287 | manager = ToolManager() 288 | manager.add_tool(tool_with_context) 289 | # Should not raise an error when context is not provided 290 | result = await manager.call_tool("tool_with_context", {"x": 42}) 291 | assert result == "42" 292 | 293 | async def test_context_error_handling(self): 294 | """Test error handling when context injection fails.""" 295 | from fastmcp import Context, FastMCP 296 | 297 | def tool_with_context(x: int, ctx: Context) -> str: 298 | raise ValueError("Test error") 299 | 300 | manager = ToolManager() 301 | manager.add_tool(tool_with_context) 302 | 303 | mcp = FastMCP() 304 | ctx = mcp.get_context() 305 | with pytest.raises(ToolError, match="Error executing tool tool_with_context"): 306 | await manager.call_tool("tool_with_context", {"x": 42}, context=ctx) 307 | --------------------------------------------------------------------------------