├── .cursor └── rules │ └── core-mcp-objects.mdc ├── .github ├── ISSUE_TEMPLATE │ ├── bug.yml │ ├── config.yml │ └── enhancement.yml ├── release.yml └── workflows │ ├── 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 ├── clients │ ├── client.mdx │ └── transports.mdx ├── docs.json ├── getting-started │ ├── installation.mdx │ ├── quickstart.mdx │ └── welcome.mdx ├── patterns │ ├── composition.mdx │ ├── contrib.mdx │ ├── decorating-methods.mdx │ ├── fastapi.mdx │ ├── openapi.mdx │ └── proxy.mdx ├── servers │ ├── context.mdx │ ├── fastmcp.mdx │ ├── prompts.mdx │ ├── resources.mdx │ └── tools.mdx ├── snippets │ └── version-badge.mdx └── style.css ├── examples ├── complex_inputs.py ├── desktop.py ├── echo.py ├── memory.py ├── mount_example.py ├── readme-quickstart.py ├── sampling.py ├── screenshot.py ├── simple_echo.py ├── smart_home │ ├── README.md │ ├── pyproject.toml │ ├── src │ │ └── smart_home │ │ │ ├── __init__.py │ │ │ ├── __main__.py │ │ │ ├── hub.py │ │ │ ├── lights │ │ │ ├── __init__.py │ │ │ ├── hue_utils.py │ │ │ └── server.py │ │ │ ├── py.typed │ │ │ └── settings.py │ └── uv.lock └── text_me.py ├── justfile ├── pyproject.toml ├── src └── fastmcp │ ├── __init__.py │ ├── cli │ ├── __init__.py │ ├── claude.py │ └── cli.py │ ├── client │ ├── __init__.py │ ├── base.py │ ├── client.py │ ├── roots.py │ ├── sampling.py │ └── transports.py │ ├── contrib │ ├── README.md │ ├── bulk_tool_caller │ │ ├── README.md │ │ ├── __init__.py │ │ ├── bulk_tool_caller.py │ │ └── example.py │ └── mcp_mixin │ │ ├── README.md │ │ ├── __init__.py │ │ ├── example.py │ │ └── mcp_mixin.py │ ├── exceptions.py │ ├── prompts │ ├── __init__.py │ ├── prompt.py │ └── prompt_manager.py │ ├── py.typed │ ├── resources │ ├── __init__.py │ ├── resource.py │ ├── resource_manager.py │ ├── template.py │ └── types.py │ ├── server │ ├── __init__.py │ ├── context.py │ ├── openapi.py │ ├── proxy.py │ └── server.py │ ├── settings.py │ ├── tools │ ├── __init__.py │ ├── tool.py │ └── tool_manager.py │ └── utilities │ ├── __init__.py │ ├── decorators.py │ ├── func_metadata.py │ ├── logging.py │ ├── openapi.py │ └── types.py ├── tests ├── __init__.py ├── cli │ └── test_run.py ├── client │ ├── __init__.py │ ├── test_client.py │ ├── test_roots.py │ └── test_sampling.py ├── conftest.py ├── contrib │ ├── __init__.py │ ├── test_bulk_tool_caller.py │ └── test_mcp_mixin.py ├── prompts │ ├── __init__.py │ ├── test_base.py │ └── test_prompt_manager.py ├── resources │ ├── __init__.py │ ├── test_file_resources.py │ ├── test_function_resources.py │ ├── test_resource_manager.py │ ├── test_resource_template.py │ └── test_resources.py ├── server │ ├── __init__.py │ ├── test_file_server.py │ ├── test_import_server.py │ ├── test_lifespan.py │ ├── test_mount.py │ ├── test_openapi.py │ ├── test_proxy.py │ ├── test_run_server.py │ └── test_server.py ├── test_servers │ ├── fastmcp_server.py │ ├── sse.py │ └── stdio.py ├── tools │ ├── __init__.py │ └── test_tool_manager.py └── utilities │ ├── __init__.py │ ├── openapi │ ├── __init__.py │ ├── conftest.py │ ├── test_openapi.py │ ├── test_openapi_advanced.py │ └── test_openapi_fastapi.py │ ├── test_decorated_function.py │ ├── test_func_metadata.py │ └── test_logging.py └── uv.lock /.cursor/rules/core-mcp-objects.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | There are four major MCP object types: 7 | 8 | - Tools (src/tools/) 9 | - Resources (src/resources/) 10 | - Resource Templates (src/resources/) 11 | - Prompts (src/prompts) 12 | 13 | While these have slightly different semantics and implementations, in general changes that affect interactions with any one (like adding tags, importing, etc.) will need to be adopted, applied, and tested on all others. Be sure to look at not only the object definition but also the related `Manager` (e.g. `ToolManager`, `ResourceManager`, and `PromptManager`). Also note that while resources and resource templates are different objects, they both are handled by the `ResourceManager`. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug Report 2 | description: Report a bug or unexpected behavior in FastMCP 3 | labels: [bug, pending] 4 | 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Thank you for contributing to FastMCP! 🙏 9 | 10 | - type: textarea 11 | id: description 12 | attributes: 13 | label: Description 14 | description: | 15 | Please explain what you're experiencing and what you would expect to happen instead. 16 | 17 | Provide as much detail as possible to help us understand and solve your problem quickly. 18 | validations: 19 | required: true 20 | 21 | - type: textarea 22 | id: example 23 | attributes: 24 | label: Example Code 25 | description: > 26 | If applicable, please provide a self-contained, 27 | [minimal, reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) 28 | demonstrating the bug. 29 | 30 | placeholder: | 31 | from fastmcp import FastMCP 32 | 33 | ... 34 | render: Python 35 | 36 | - type: textarea 37 | id: version 38 | attributes: 39 | label: Version Information 40 | description: | 41 | Please provide information about your FastMCP version, MCP version, Python version, and OS. 42 | 43 | To get this information, run the following command in your terminal and paste the output below: 44 | 45 | ```bash 46 | fastmcp version 47 | ``` 48 | 49 | If there is other information that would be helpful, please include it as well. 50 | render: Text 51 | validations: 52 | required: true 53 | 54 | - type: textarea 55 | id: additional_context 56 | attributes: 57 | label: Additional Context 58 | description: | 59 | Add any other context about the problem here. This could include: 60 | - The full error message and traceback (if applicable) 61 | - Information about your environment (e.g., virtual environment, installed packages) 62 | - Steps to reproduce the issue 63 | - Any recent changes in your code or setup that might be relevant 64 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: FastMCP Documentation 4 | url: https://gofastmcp.com 5 | about: Please review the documentation before opening an issue. 6 | - name: MCP Python SDK 7 | url: https://github.com/modelcontextprotocol/python-sdk/issues 8 | about: Issues related to the low-level MCP Python SDK, including the FastMCP 1.0 module that is included in the `mcp` package, should be filed on the official MCP repository. 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.yml: -------------------------------------------------------------------------------- 1 | name: 💡 Enhancement Request 2 | description: Suggest an idea or improvement for FastMCP 3 | labels: [enhancement, pending] 4 | 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Thank you for contributing to FastMCP! We value your ideas for improving the framework. 💡 9 | 10 | - type: textarea 11 | id: description 12 | attributes: 13 | label: Enhancement Description 14 | description: | 15 | Please describe the enhancement you'd like to see in FastMCP. 16 | 17 | - What problem would this solve? 18 | - How would this improve your workflow or experience with FastMCP? 19 | - Are there any alternative solutions you've considered? 20 | validations: 21 | required: true 22 | 23 | - type: textarea 24 | id: use_case 25 | attributes: 26 | label: Use Case 27 | description: | 28 | Describe a specific use case or scenario where this enhancement would be beneficial. 29 | If possible, provide an example of how you envision using this feature. 30 | 31 | - type: textarea 32 | id: example 33 | attributes: 34 | label: Proposed Implementation 35 | description: > 36 | If you have ideas about how this enhancement could be implemented, 37 | please share them here. Code snippets, pseudocode, or general approaches are welcome. 38 | render: Python 39 | -------------------------------------------------------------------------------- /.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/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 static analysis 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 | paths: 12 | - "src/**" 13 | - "tests/**" 14 | - "uv.lock" 15 | - "pyproject.toml" 16 | - ".github/workflows/**" 17 | pull_request: 18 | paths: 19 | - "src/**" 20 | - "tests/**" 21 | - "uv.lock" 22 | - "pyproject.toml" 23 | - ".github/workflows/**" 24 | workflow_dispatch: 25 | 26 | permissions: 27 | contents: read 28 | 29 | jobs: 30 | static_analysis: 31 | timeout-minutes: 2 32 | 33 | runs-on: ubuntu-latest 34 | 35 | steps: 36 | - uses: actions/checkout@v4 37 | - name: Install uv 38 | uses: astral-sh/setup-uv@v5 39 | with: 40 | enable-cache: true 41 | cache-dependency-glob: "uv.lock" 42 | - name: Set up Python 43 | uses: actions/setup-python@v5 44 | with: 45 | python-version: "3.12" 46 | - name: Install dependencies 47 | run: uv sync --dev 48 | - name: Run pre-commit 49 | uses: pre-commit/action@v3.0.1 50 | -------------------------------------------------------------------------------- /.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] 36 | python-version: ["3.10"] 37 | fail-fast: false 38 | timeout-minutes: 5 39 | 40 | steps: 41 | - uses: actions/checkout@v4 42 | 43 | - name: Install uv 44 | uses: astral-sh/setup-uv@v5 45 | with: 46 | enable-cache: true 47 | cache-dependency-glob: "uv.lock" 48 | 49 | - name: Set up Python ${{ matrix.python-version }} 50 | run: uv python install ${{ matrix.python-version }} 51 | 52 | - name: Install FastMCP 53 | run: uv sync --dev 54 | 55 | - name: Fix pyreadline on Windows 56 | if: matrix.os == 'windows-latest' 57 | run: | 58 | uv pip uninstall -y pyreadline 59 | uv pip install pyreadline3 60 | 61 | - name: Run tests 62 | run: uv run pytest -vv 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | build/ 6 | dist/ 7 | wheels/ 8 | *.egg-info/ 9 | *.egg 10 | MANIFEST 11 | .pytest_cache/ 12 | .coverage 13 | htmlcov/ 14 | .tox/ 15 | nosetests.xml 16 | coverage.xml 17 | *.cover 18 | 19 | # Virtual environments 20 | .venv 21 | venv/ 22 | env/ 23 | ENV/ 24 | .env 25 | 26 | # System files 27 | .DS_Store 28 | 29 | # Version file 30 | src/fastmcp/_version.py 31 | 32 | # Editors and IDEs 33 | .cursorrules 34 | .vscode/ 35 | .idea/ 36 | *.swp 37 | *.swo 38 | *~ 39 | .project 40 | .pydevproject 41 | .settings/ 42 | 43 | # Jupyter Notebook 44 | .ipynb_checkpoints 45 | 46 | # Type checking 47 | .mypy_cache/ 48 | .dmypy.json 49 | dmypy.json 50 | .pyre/ 51 | .pytype/ 52 | 53 | # Local development 54 | .python-version 55 | .envrc 56 | .direnv/ 57 | 58 | # Logs and databases 59 | *.log 60 | *.sqlite 61 | *.db 62 | *.ddb 63 | -------------------------------------------------------------------------------- /.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 | # Ruff version. 17 | rev: v0.11.4 18 | hooks: 19 | # Run the linter. 20 | - id: ruff 21 | args: [--fix, --exit-non-zero-on-fix] 22 | # Run the formatter. 23 | - id: ruff-format 24 | 25 | - repo: https://github.com/northisup/pyright-pretty 26 | rev: v0.1.0 27 | hooks: 28 | - id: pyright-pretty 29 | files: ^src/|^tests/ 30 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /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 ` 56 | - Create your pull request on GitHub 57 | 58 | 59 | -------------------------------------------------------------------------------- /docs/assets/demo-inspector.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlowin/fastmcp/a4e233711fda1e232e24fd586cfd11c495bd14fe/docs/assets/demo-inspector.png -------------------------------------------------------------------------------- /docs/docs.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://mintlify.com/docs.json", 3 | "background": { 4 | "color": { 5 | "dark": "#222831", 6 | "light": "#EEEEEE" 7 | }, 8 | "decoration": "windows" 9 | }, 10 | "colors": { 11 | "dark": "#f72585", 12 | "light": "#4cc9f0", 13 | "primary": "#2d00f7" 14 | }, 15 | "description": "The fast, Pythonic way to build MCP servers and clients.", 16 | "footer": { 17 | "socials": { 18 | "bluesky": "https://bsky.app/profile/jlowin.dev", 19 | "github": "https://github.com/jlowin/fastmcp", 20 | "x": "https://x.com/jlowin" 21 | } 22 | }, 23 | "name": "FastMCP", 24 | "navbar": { 25 | "primary": { 26 | "href": "https://github.com/jlowin/fastmcp", 27 | "type": "github" 28 | } 29 | }, 30 | "navigation": { 31 | "groups": [ 32 | { 33 | "group": "Get Started", 34 | "pages": [ 35 | "getting-started/welcome", 36 | "getting-started/installation", 37 | "getting-started/quickstart" 38 | ] 39 | }, 40 | { 41 | "group": "Servers", 42 | "pages": [ 43 | "servers/fastmcp", 44 | "servers/tools", 45 | "servers/resources", 46 | "servers/prompts", 47 | "servers/context" 48 | ] 49 | }, 50 | { 51 | "group": "Clients", 52 | "pages": [ 53 | "clients/client", 54 | "clients/transports" 55 | ] 56 | }, 57 | { 58 | "group": "Patterns", 59 | "pages": [ 60 | "patterns/proxy", 61 | "patterns/composition", 62 | "patterns/decorating-methods", 63 | "patterns/openapi", 64 | "patterns/fastapi", 65 | "patterns/contrib" 66 | ] 67 | }, 68 | { 69 | "group": "Deployment", 70 | "pages": [] 71 | } 72 | ] 73 | }, 74 | "theme": "mint" 75 | } -------------------------------------------------------------------------------- /docs/getting-started/installation.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Installation 3 | icon: arrow-down-to-line 4 | --- 5 | ## Install FastMCP 6 | 7 | We recommend using [uv](https://docs.astral.sh/uv/getting-started/installation/) to install and manage FastMCP. 8 | 9 | If you plan to use FastMCP in your project, you can add it as a dependency with: 10 | 11 | ```bash 12 | uv add fastmcp 13 | ``` 14 | 15 | Alternatively, you can install it directly with `pip` or `uv pip`: 16 | 17 | ```bash uv 18 | uv pip install fastmcp 19 | ``` 20 | 21 | ```bash pip 22 | pip install fastmcp 23 | ``` 24 | 25 | 26 | ## Verify Installation 27 | 28 | To verify that FastMCP is installed correctly, you can run the following command: 29 | 30 | ```bash 31 | fastmcp version 32 | ``` 33 | 34 | You should see output like the following: 35 | 36 | ```bash 37 | $ fastmcp version 38 | 39 | FastMCP version: 0.4.2.dev41+ga077727.d20250410 40 | MCP version: 1.6.0 41 | Python version: 3.12.2 42 | Platform: macOS-15.3.1-arm64-arm-64bit 43 | FastMCP root path: ~/Developer/fastmcp 44 | ``` 45 | 46 | ## Installing for Development 47 | 48 | If you plan to contribute to FastMCP, you should begin by cloning the repository and using uv to install all dependencies. 49 | 50 | ```bash 51 | git clone https://github.com/jlowin/fastmcp.git 52 | cd fastmcp 53 | uv sync 54 | ``` 55 | 56 | This will install all dependencies, including ones for development, and create a virtual environment. 57 | 58 | To run the tests, use pytest: 59 | 60 | ```bash 61 | pytest 62 | ``` -------------------------------------------------------------------------------- /docs/getting-started/quickstart.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Quickstart 3 | icon: rocket 4 | --- 5 | 6 | Welcome! This guide will help you quickly set up FastMCP and run your first MCP server. 7 | 8 | If you haven't already installed FastMCP, follow the [installation instructions](/getting-started/installation). 9 | 10 | ## Creating a FastMCP Server 11 | 12 | A FastMCP server is a collection of tools, resources, and other MCP components. To create a server, start by instantiating the `FastMCP` class. 13 | 14 | Create a new file called `my_server.py` and add the following code: 15 | 16 | ```python my_server.py 17 | from fastmcp import FastMCP 18 | 19 | mcp = FastMCP("My MCP Server") 20 | ``` 21 | 22 | 23 | That's it! You've created a FastMCP server, albeit a very boring one. Let's add a tool to make it more interesting. 24 | 25 | 26 | ## Adding a Tool 27 | 28 | To add a tool that returns a simple greeting, write a function and decorate it with `@mcp.tool` to register it with the server: 29 | 30 | ```python my_server.py {5-7} 31 | from fastmcp import FastMCP 32 | 33 | mcp = FastMCP("My MCP Server") 34 | 35 | @mcp.tool() 36 | def greet(name: str) -> str: 37 | return f"Hello, {name}!" 38 | ``` 39 | 40 | 41 | ## Testing the Server 42 | 43 | 44 | To test the server, create a FastMCP client and point it at the server object. 45 | 46 | ```python my_server.py {1, 9-16} 47 | from fastmcp import FastMCP, Client 48 | 49 | mcp = FastMCP("My MCP Server") 50 | 51 | @mcp.tool() 52 | def greet(name: str) -> str: 53 | return f"Hello, {name}!" 54 | 55 | client = Client(mcp) 56 | 57 | async def call_tool(name: str): 58 | async with client: 59 | result = await client.call_tool("greet", {"name": name}) 60 | print(result) 61 | 62 | asyncio.run(call_tool("Ford")) 63 | ``` 64 | 65 | There are a few things to note here: 66 | - Clients are asynchronous, so we need to use `asyncio.run` to run the client. 67 | - We must enter a client context (`async with client:`) before using the client. You can make multiple client calls within the same context. 68 | 69 | ## Running the server 70 | 71 | In order to run the server with Python, we need to add a `run` statement to the `__main__` block of the server file. 72 | 73 | ```python my_server.py {9-10} 74 | from fastmcp import FastMCP, Client 75 | 76 | mcp = FastMCP("My MCP Server") 77 | 78 | @mcp.tool() 79 | def greet(name: str) -> str: 80 | return f"Hello, {name}!" 81 | 82 | if __name__ == "__main__": 83 | mcp.run() 84 | ``` 85 | 86 | This lets us run the server with `python my_server.py`, using the default `stdio` transport, which is the standard way to expose an MCP server to a client. 87 | 88 | 89 | Why do we need the `if __name__ == "__main__":` block? 90 | 91 | Within the FastMCP ecosystem, this line may be unecessary. However, including it ensures that your FastMCP server runs for all users and clients in a consistent way and is therefore recommended as best practice. 92 | 93 | 94 | ### Interacting with the Python server 95 | 96 | Now that the server can be executed with `python my_server.py`, we can interact with it like any other MCP server. 97 | 98 | In a new file, create a client and point it at the server file: 99 | 100 | ```python my_client.py 101 | from fastmcp import Client 102 | 103 | client = Client("my_server.py") 104 | 105 | async def call_tool(name: str): 106 | async with client: 107 | result = await client.call_tool("greet", {"name": name}) 108 | print(result) 109 | 110 | asyncio.run(call_tool("Ford")) 111 | ``` 112 | 113 | 114 | 115 | ### Using the FastMCP CLI 116 | 117 | To have FastMCP run the server for us, we can use the `fastmcp run` command. This will start the server and keep it running until it is stopped. By default, it will use the `stdio` transport, which is a simple text-based protocol for interacting with the server. 118 | 119 | ```bash 120 | fastmcp run my_server.py:mcp 121 | ``` 122 | 123 | Note that FastMCP *does not* require the `__main__` block in the server file, and will ignore it if it is present. Instead, it looks for the server object provided in the CLI command (here, `mcp`). If no server object is provided, `fastmcp run` will automatically search for servers called "mcp", "app", or "server" in the file. 124 | 125 | 126 | We pointed our client at the server file, which is recognized as a Python MCP server and executed with `python my_server.py` by default. This exceutes the `__main__` block of the server file. There are other ways to run the server, which are described in the [server configuration](/servers/fastmcp#running-the-server) guide. 127 | 128 | -------------------------------------------------------------------------------- /docs/getting-started/welcome.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Welcome to FastMCP!" 3 | sidebarTitle: "Welcome!" 4 | description: The fast, Pythonic way to build MCP servers and clients. 5 | 6 | icon: hand-wave 7 | --- 8 | 9 | 10 | The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) is a new, standardized way to provide context and tools to your LLMs, and FastMCP makes building MCP servers and clients simple and intuitive. Create tools, expose resources, define prompts, and more with clean, Pythonic code: 11 | 12 | ```python {1, 3, 5, 11} 13 | from fastmcp import FastMCP 14 | 15 | mcp = FastMCP("Demo 🚀") 16 | 17 | @mcp.tool() 18 | def add(a: int, b: int) -> int: 19 | """Add two numbers""" 20 | return a + b 21 | 22 | if __name__ == "__main__": 23 | mcp.run() 24 | ``` 25 | 26 | 27 | ## What is MCP? 28 | The Model Context Protocol lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. It is often described as "the USB-C port for AI", providing a uniform way to connect LLMs to resources they can use. It may be easier to think of it as an API, but specifically designed for LLM interactions. MCP servers can: 29 | 30 | - Expose data through `Resources` (think of these sort of like GET endpoints; they are used to load information into the LLM's context) 31 | - Provide functionality through `Tools` (sort of like POST endpoints; they are used to execute code or otherwise produce a side effect) 32 | - Define interaction patterns through `Prompts` (reusable templates for LLM interactions) 33 | - And more! 34 | 35 | There is a low-level Python SDK available for implementing the protocol directly, but FastMCP aims to make that easier by providing a high-level, Pythonic interface. 36 | 37 | 38 | FastMCP 1.0 was so successful that it is now included as part of the official [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)! 39 | 40 | 41 | 42 | 43 | 44 | ## Why FastMCP? 45 | 46 | The MCP protocol is powerful but implementing it involves a lot of boilerplate - server setup, protocol handlers, content types, error management. 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. 47 | 48 | FastMCP aims to be: 49 | 50 | 🚀 **Fast**: High-level interface means less code and faster development 51 | 52 | 🍀 **Simple**: Build MCP servers with minimal boilerplate 53 | 54 | 🐍 **Pythonic**: Feels natural to Python developers 55 | 56 | 🔍 **Complete**: FastMCP aims to provide a full implementation of the core MCP specification 57 | 58 | **FastMCP v1** focused on abstracting the most common boilerplate of exposing MCP server functionality, and is now included in the official MCP Python SDK. **FastMCP v2** expands on that foundation to introduce novel functionality mainly focused on simplifying server interactions, including flexible clients, proxying and composition, and deployment. 59 | 60 | -------------------------------------------------------------------------------- /docs/patterns/contrib.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Contrib Modules" 3 | description: "Community-contributed modules extending FastMCP" 4 | icon: "cubes" 5 | --- 6 | 7 | 8 | FastMCP includes a `contrib` package that holds community-contributed modules. These modules extend FastMCP's functionality but aren't officially maintained by the core team. 9 | 10 | Contrib modules provide additional features, integrations, or patterns that complement the core FastMCP library. They offer a way for the community to share useful extensions while keeping the core library focused and maintainable. 11 | 12 | The available modules can be viewed in the [contrib directory](https://github.com/jlowin/fastmcp/tree/main/src/contrib). 13 | 14 | ## Usage 15 | 16 | To use a contrib module, import it from the `fastmcp.contrib` package: 17 | 18 | ```python 19 | from fastmcp.contrib import my_module 20 | ``` 21 | 22 | ## Important Considerations 23 | 24 | - **Stability**: Modules in `contrib` may have different testing requirements or stability guarantees compared to the core library. 25 | - **Compatibility**: Changes to core FastMCP might break modules in `contrib` without explicit warnings in the main changelog. 26 | - **Dependencies**: Contrib modules may have additional dependencies not required by the core library. These dependencies are typically documented in the module's README or separate requirements files. 27 | 28 | ## Contributing 29 | 30 | We welcome contributions to the `contrib` package! If you have a module that extends FastMCP in a useful way, consider contributing it: 31 | 32 | 1. Create a new directory in `src/fastmcp/contrib/` for your module 33 | 3. Add proper tests for your module in `tests/contrib/` 34 | 2. Include comprehensive documentation in a README.md file, including usage and examples, as well as any additional dependencies or installation instructions 35 | 5. Submit a pull request 36 | 37 | The ideal contrib module: 38 | - Solves a specific use case or integration need 39 | - Follows FastMCP coding standards 40 | - Includes thorough documentation and examples 41 | - Has comprehensive tests 42 | - Specifies any additional dependencies 43 | -------------------------------------------------------------------------------- /docs/patterns/decorating-methods.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Decorating Methods 3 | sidebarTitle: Decorating Methods 4 | description: Properly use instance methods, class methods, and static methods with FastMCP decorators. 5 | icon: at 6 | --- 7 | 8 | FastMCP's decorator system is designed to work with functions, but you may see unexpected behavior if you try to decorate an instance or class method. This guide explains the correct approach for using methods with all FastMCP decorators (`@tool()`, `@resource()`, and `@prompt()`). 9 | 10 | ## Why Are Methods Hard? 11 | 12 | When you apply a FastMCP decorator like `@tool()`, `@resource()`, or `@prompt()` to a method, the decorator captures the function at decoration time. For instance methods and class methods, this poses a challenge because: 13 | 14 | 1. For instance methods: The decorator gets the unbound method before any instance exists 15 | 2. For class methods: The decorator gets the function before it's bound to the class 16 | 17 | This means directly decorating these methods doesn't work as expected. In practice, the LLM would see parameters like `self` or `cls` that it cannot provide values for. 18 | 19 | ## Recommended Patterns 20 | 21 | ### Instance Methods 22 | 23 | **Don't do this** (it doesn't work properly): 24 | 25 | ```python 26 | from fastmcp import FastMCP 27 | 28 | mcp = FastMCP() 29 | 30 | class MyClass: 31 | @mcp.tool() # This won't work correctly 32 | def add(self, x, y): 33 | return x + y 34 | 35 | @mcp.resource("resource://{param}") # This won't work correctly 36 | def get_resource(self, param: str): 37 | return f"Resource data for {param}" 38 | ``` 39 | 40 | When the decorator is applied this way, it captures the unbound method. When the LLM later tries to use this component, it will see `self` as a required parameter, but it won't know what to provide for it, causing errors or unexpected behavior. 41 | 42 | **Do this instead**: 43 | 44 | ```python 45 | from fastmcp import FastMCP 46 | 47 | mcp = FastMCP() 48 | 49 | class MyClass: 50 | def add(self, x, y): 51 | return x + y 52 | 53 | def get_resource(self, param: str): 54 | return f"Resource data for {param}" 55 | 56 | # Create an instance first, then add the bound methods 57 | obj = MyClass() 58 | mcp.add_tool(obj.add) 59 | mcp.add_resource_fn(obj.get_resource, uri="resource://{param}") # For resources or templates 60 | 61 | # Note: FastMCP provides add_resource() for adding Resource objects directly and 62 | # add_resource_fn() for adding functions that generate resources or templates 63 | 64 | # Now you can call it without 'self' showing up as a parameter 65 | await mcp.call_tool('add', {'x': 1, 'y': 2}) # Returns 3 66 | ``` 67 | 68 | This approach works because: 69 | 1. You first create an instance of the class (`obj`) 70 | 2. When you access the method through the instance (`obj.add`), Python creates a bound method where `self` is already set to that instance 71 | 3. When you register this bound method, the system sees a callable that only expects the appropriate parameters, not `self` 72 | 73 | ### Class Methods 74 | 75 | Similar to instance methods, decorating class methods directly doesn't work properly: 76 | 77 | **Don't do this**: 78 | 79 | ```python 80 | from fastmcp import FastMCP 81 | 82 | mcp = FastMCP() 83 | 84 | class MyClass: 85 | @classmethod 86 | @mcp.tool() # This won't work correctly 87 | def from_string(cls, s): 88 | return cls(s) 89 | ``` 90 | 91 | The problem here is that the FastMCP decorator is applied before the `@classmethod` decorator (Python applies decorators bottom-to-top). So it captures the function before it's transformed into a class method, leading to incorrect behavior. 92 | 93 | **Do this instead**: 94 | 95 | ```python 96 | from fastmcp import FastMCP 97 | 98 | mcp = FastMCP() 99 | 100 | class MyClass: 101 | @classmethod 102 | def from_string(cls, s): 103 | return cls(s) 104 | 105 | # Add the class method after the class is defined 106 | mcp.add_tool(MyClass.from_string) 107 | ``` 108 | 109 | This works because: 110 | 1. The `@classmethod` decorator is applied properly during class definition 111 | 2. When you access `MyClass.from_string`, Python provides a special method object that automatically binds the class to the `cls` parameter 112 | 3. When registered, only the appropriate parameters are exposed to the LLM, hiding the implementation detail of the `cls` parameter 113 | 114 | ### Static Methods 115 | 116 | Unlike instance and class methods, static methods work fine with FastMCP decorators: 117 | 118 | ```python 119 | from fastmcp import FastMCP 120 | 121 | mcp = FastMCP() 122 | 123 | class MyClass: 124 | @staticmethod 125 | @mcp.tool() # This works! 126 | def utility(x, y): 127 | return x + y 128 | 129 | @staticmethod 130 | @mcp.resource("resource://data") # This works too! 131 | def get_data(): 132 | return "Static resource data" 133 | ``` 134 | 135 | This approach works because: 136 | 1. The `@staticmethod` decorator is applied first (executed last), transforming the method into a regular function 137 | 2. When the FastMCP decorator is applied, it's capturing what is effectively just a regular function 138 | 3. A static method doesn't have any binding requirements - it doesn't receive a `self` or `cls` parameter 139 | 140 | Alternatively, you can use the same pattern as the other methods: 141 | 142 | ```python 143 | from fastmcp import FastMCP 144 | 145 | mcp = FastMCP() 146 | 147 | class MyClass: 148 | @staticmethod 149 | def utility(x, y): 150 | return x + y 151 | 152 | # This also works 153 | mcp.add_tool(MyClass.utility) 154 | ``` 155 | 156 | This works for the same reason - a static method is essentially just a function in a class namespace. 157 | 158 | ## Additional Patterns 159 | 160 | ### Creating Components at Class Initialization 161 | 162 | You can automatically register instance methods when creating an object: 163 | 164 | ```python 165 | from fastmcp import FastMCP 166 | 167 | mcp = FastMCP() 168 | 169 | class ComponentProvider: 170 | def __init__(self, mcp_instance): 171 | # Register methods 172 | mcp_instance.add_tool(self.tool_method) 173 | mcp_instance.add_resource_fn(self.resource_method, uri="resource://data") 174 | 175 | def tool_method(self, x): 176 | return x * 2 177 | 178 | def resource_method(self): 179 | return "Resource data" 180 | 181 | # The methods are automatically registered when creating the instance 182 | provider = ComponentProvider(mcp) 183 | ``` 184 | 185 | This pattern is useful when: 186 | - You want to encapsulate registration logic within the class itself 187 | - You have multiple related components that should be registered together 188 | - You want to ensure that methods are always properly registered when creating an instance 189 | 190 | The class automatically registers its methods during initialization, ensuring they're properly bound to the instance before registration. 191 | 192 | ## Summary 193 | 194 | While FastMCP's decorator pattern works seamlessly with regular functions and static methods, for instance methods and class methods, you should add them after creating the instance or class. This ensures that the methods are properly bound before being registered. 195 | 196 | These patterns apply to all FastMCP decorators and registration methods: 197 | - `@tool()` and `add_tool()` 198 | - `@resource()` and `add_resource_fn()` 199 | - `@prompt()` and `add_prompt()` 200 | 201 | Understanding these patterns allows you to effectively organize your components into classes while maintaining proper method binding, giving you the benefits of object-oriented design without sacrificing the simplicity of FastMCP's decorator system. 202 | -------------------------------------------------------------------------------- /docs/patterns/fastapi.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: FastAPI Integration 3 | sidebarTitle: FastAPI 4 | description: Generate MCP servers from FastAPI apps 5 | icon: square-bolt 6 | --- 7 | import { VersionBadge } from '/snippets/version-badge.mdx' 8 | 9 | 10 | 11 | 12 | FastMCP can automatically convert FastAPI applications into MCP servers. 13 | 14 | 15 | FastMCP does *not* include FastAPI as a dependency; you must install it separately to run these examples. 16 | 17 | 18 | 19 | ```python {2, 22, 25} 20 | from fastapi import FastAPI 21 | from fastmcp import FastMCP 22 | 23 | 24 | # A FastAPI app 25 | app = FastAPI() 26 | 27 | @app.get("/items") 28 | def list_items(): 29 | return [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}] 30 | 31 | @app.get("/items/{item_id}") 32 | def get_item(item_id: int): 33 | return {"id": item_id, "name": f"Item {item_id}"} 34 | 35 | @app.post("/items") 36 | def create_item(name: str): 37 | return {"id": 3, "name": name} 38 | 39 | 40 | # Create an MCP server from your FastAPI app 41 | mcp = FastMCP.from_fastapi(app=app) 42 | 43 | if __name__ == "__main__": 44 | mcp.run() # Start the MCP server 45 | ``` 46 | 47 | ## Route Mapping 48 | 49 | By default, FastMCP will map FastAPI routes to MCP components according to the following rules: 50 | 51 | | FastAPI Route Type | FastAPI Example | MCP Component | Notes | 52 | |--------------------|--------------|---------|-------| 53 | | GET without path params | `@app.get("/stats")` | Resource | Simple resources for fetching data | 54 | | GET with path params | `@app.get("/users/{id}")` | Resource Template | Path parameters become template parameters | 55 | | POST, PUT, DELETE, etc. | `@app.post("/users")` | Tool | Operations that modify data | 56 | 57 | For more details on route mapping or custom mapping rules, see the [OpenAPI integration documentation](/patterns/openapi#route-mapping); FastMCP uses the same mapping rules for both FastAPI and OpenAPI integrations. 58 | 59 | ## Complete Example 60 | 61 | Here's a more detailed example with a data model: 62 | 63 | ```python 64 | import asyncio 65 | from fastapi import FastAPI, HTTPException 66 | from pydantic import BaseModel 67 | from fastmcp import FastMCP, Client 68 | 69 | # Define your Pydantic model 70 | class Item(BaseModel): 71 | name: str 72 | price: float 73 | 74 | # Create your FastAPI app 75 | app = FastAPI() 76 | items = {} # In-memory database 77 | 78 | @app.get("/items") 79 | def list_items(): 80 | """List all items""" 81 | return list(items.values()) 82 | 83 | @app.get("/items/{item_id}") 84 | def get_item(item_id: int): 85 | """Get item by ID""" 86 | if item_id not in items: 87 | raise HTTPException(404, "Item not found") 88 | return items[item_id] 89 | 90 | @app.post("/items") 91 | def create_item(item: Item): 92 | """Create a new item""" 93 | item_id = len(items) + 1 94 | items[item_id] = {"id": item_id, **item.model_dump()} 95 | return items[item_id] 96 | 97 | # Test your MCP server with a client 98 | async def test(): 99 | # Create MCP server from FastAPI app 100 | mcp = await FastMCP.from_fastapi(app=app) 101 | 102 | # List the components that were created 103 | tools = await mcp.list_tools() 104 | resources = await mcp.list_resources() 105 | templates = await mcp.list_resource_templates() 106 | 107 | print(f"Generated {len(tools)} tools") 108 | print(f"Generated {len(resources)} resources") 109 | print(f"Generated {len(templates)} templates") 110 | 111 | # In a real scenario, you would run the server: 112 | # mcp.run() 113 | 114 | if __name__ == "__main__": 115 | asyncio.run(test()) 116 | ``` 117 | 118 | ## Benefits 119 | 120 | - **Leverage existing FastAPI apps** - No need to rewrite your API logic 121 | - **Schema reuse** - FastAPI's Pydantic models and validation are inherited 122 | - **Full feature support** - Works with FastAPI's authentication, dependencies, etc. 123 | - **ASGI transport** - Direct communication without additional HTTP overhead 124 | -------------------------------------------------------------------------------- /docs/patterns/openapi.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: OpenAPI Integration 3 | sidebarTitle: OpenAPI 4 | description: Generate MCP servers from OpenAPI specs 5 | icon: code-branch 6 | --- 7 | import { VersionBadge } from '/snippets/version-badge.mdx' 8 | 9 | 10 | 11 | FastMCP can automatically generate an MCP server from an OpenAPI specification. Users only need to provide an OpenAPI specification (3.0 or 3.1) and an API client. 12 | 13 | ```python 14 | import httpx 15 | from fastmcp import FastMCP 16 | 17 | # Create a client for your API 18 | api_client = httpx.AsyncClient(base_url="https://api.example.com") 19 | 20 | # Load your OpenAPI spec 21 | spec = {...} 22 | 23 | # Create an MCP server from your OpenAPI spec 24 | mcp = FastMCP.from_openapi(openapi_spec=spec, client=api_client) 25 | 26 | if __name__ == "__main__": 27 | mcp.run() 28 | ``` 29 | 30 | ## Route Mapping 31 | 32 | By default, OpenAPI routes are mapped to MCP components based on these rules: 33 | 34 | | OpenAPI Route | Example |MCP Component | Notes | 35 | |- | - | - | - | 36 | | `GET` without path params | `GET /stats` | Resource | Simple resources for fetching data | 37 | | `GET` with path params | `GET /users/{id}` | Resource Template | Path parameters become template parameters | 38 | | `POST`, `PUT`, `PATCH`, `DELETE`, etc. | `POST /users` | Tool | Operations that modify data | 39 | 40 | 41 | Internally, FastMCP uses a priority-ordered set of `RouteMap` objects to determine the component type. Route maps indicate that a specific HTTP method (or methods) and path pattern should be treated as a specific component type. This is the default set of route maps: 42 | 43 | ```python 44 | # Simplified version of the actual mapping rules 45 | DEFAULT_ROUTE_MAPPINGS = [ 46 | # GET with path parameters -> ResourceTemplate 47 | RouteMap(methods=["GET"], pattern=r".*\{.*\}.*", 48 | route_type=RouteType.RESOURCE_TEMPLATE), 49 | 50 | # GET without path parameters -> Resource 51 | RouteMap(methods=["GET"], pattern=r".*", 52 | route_type=RouteType.RESOURCE), 53 | 54 | # All other methods -> Tool 55 | RouteMap(methods=["POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], 56 | pattern=r".*", route_type=RouteType.TOOL), 57 | ] 58 | ``` 59 | 60 | ### Custom Route Maps 61 | 62 | Users can add custom route maps to override the default mapping behavior. User-supplied route maps are always applied first, before the default route maps. 63 | 64 | ```python 65 | from fastmcp.server.openapi import RouteMap, RouteType 66 | 67 | # Custom mapping rules 68 | custom_maps = [ 69 | # Force all analytics endpoints to be Tools 70 | RouteMap(methods=["GET"], 71 | pattern=r"^/analytics/.*", 72 | route_type=RouteType.TOOL) 73 | ] 74 | 75 | # Apply custom mappings 76 | mcp = await FastMCP.from_openapi( 77 | openapi_spec=spec, 78 | client=api_client, 79 | route_maps=custom_maps 80 | ) 81 | ``` 82 | 83 | ## How It Works 84 | 85 | 1. FastMCP parses your OpenAPI spec to extract routes and schemas 86 | 2. It applies mapping rules to categorize each route 87 | 3. When an MCP client calls a tool or accesses a resource: 88 | - FastMCP constructs an HTTP request based on the OpenAPI definition 89 | - It sends the request through the provided httpx client 90 | - It translates the HTTP response to the appropriate MCP format 91 | 92 | ## Complete Example 93 | 94 | ```python 95 | import asyncio 96 | import httpx 97 | from fastmcp import FastMCP 98 | 99 | # Sample OpenAPI spec for a Pet Store API 100 | petstore_spec = { 101 | "openapi": "3.0.0", 102 | "paths": { 103 | "/pets": { 104 | "get": { 105 | "operationId": "listPets", 106 | "summary": "List all pets" 107 | }, 108 | "post": { 109 | "operationId": "createPet", 110 | "summary": "Create a new pet" 111 | } 112 | }, 113 | "/pets/{petId}": { 114 | "get": { 115 | "operationId": "getPet", 116 | "summary": "Get a pet by ID", 117 | "parameters": [ 118 | { 119 | "name": "petId", 120 | "in": "path", 121 | "required": True, 122 | "schema": {"type": "string"} 123 | } 124 | ] 125 | } 126 | } 127 | } 128 | } 129 | 130 | async def main(): 131 | # Client for the Pet Store API 132 | client = httpx.AsyncClient(base_url="https://petstore.example.com/api") 133 | 134 | # Create the MCP server 135 | mcp = await FastMCP.from_openapi( 136 | openapi_spec=petstore_spec, 137 | client=client, 138 | name="PetStore" 139 | ) 140 | 141 | # List what components were created 142 | tools = await mcp.list_tools() 143 | resources = await mcp.list_resources() 144 | templates = await mcp.list_resource_templates() 145 | 146 | print(f"Tools: {len(tools)}") # Should include createPet 147 | print(f"Resources: {len(resources)}") # Should include listPets 148 | print(f"Templates: {len(templates)}") # Should include getPet 149 | 150 | # Start the MCP server 151 | mcp.run() 152 | 153 | if __name__ == "__main__": 154 | asyncio.run(main()) 155 | ``` 156 | 157 | -------------------------------------------------------------------------------- /docs/patterns/proxy.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Proxying Servers 3 | sidebarTitle: Proxying 4 | description: Use FastMCP to act as an intermediary or change transport for other MCP servers. 5 | icon: arrows-retweet 6 | --- 7 | import { VersionBadge } from '/snippets/version-badge.mdx' 8 | 9 | 10 | 11 | FastMCP provides a powerful proxying capability that allows one FastMCP server instance to act as a frontend for another MCP server (which could be remote, running on a different transport, or even another FastMCP instance). This is achieved using the `FastMCP.from_client()` class method. 12 | 13 | ## What is Proxying? 14 | 15 | Proxying means setting up a FastMCP server that doesn't implement its own tools or resources directly. Instead, when it receives a request (like `tools/call` or `resources/read`), it forwards that request to a *backend* MCP server, receives the response, and then relays that response back to the original client. 16 | 17 | ```mermaid 18 | sequenceDiagram 19 | participant Client 20 | participant ProxyServer as FastMCP Proxy Server 21 | participant BackendServer as Backend MCP Server 22 | 23 | Client->>ProxyServer: Request (e.g., stdio) 24 | ProxyServer->>BackendServer: Request (e.g., sse) 25 | BackendServer-->>ProxyServer: Response (e.g., sse) 26 | ProxyServer-->>Client: Response (e.g., stdio) 27 | ``` 28 | 29 | ### Use Cases 30 | 31 | - **Transport Bridging**: Expose a server running on one transport (e.g., a remote SSE server) via a different transport (e.g., local Stdio for Claude Desktop). 32 | - **Adding Functionality**: Insert a layer in front of an existing server to add caching, logging, authentication, or modify requests/responses (though direct modification requires subclassing `FastMCPProxy`). 33 | - **Security Boundary**: Use the proxy as a controlled gateway to an internal server. 34 | - **Simplifying Client Configuration**: Provide a single, stable endpoint (the proxy) even if the backend server's location or transport changes. 35 | 36 | ## Creating a Proxy 37 | 38 | The easiest way to create a proxy is using the `FastMCP.from_client()` class method. This creates a standard FastMCP server that forwards requests to another MCP server. 39 | 40 | ```python 41 | from fastmcp import FastMCP, Client 42 | 43 | # Create a client configured to talk to the backend server 44 | # This could be any MCP server - remote, local, or using any transport 45 | backend_client = Client("backend_server.py") # Could be "http://remote.server/sse", etc. 46 | 47 | # Create the proxy server with from_client() 48 | proxy_server = FastMCP.from_client( 49 | backend_client, 50 | name="MyProxyServer" # Optional settings for the proxy 51 | ) 52 | 53 | # That's it! You now have a proxy FastMCP server that can be used 54 | # with any transport (SSE, stdio, etc.) just like any other FastMCP server 55 | ``` 56 | 57 | **How `from_client` Works:** 58 | 59 | 1. It connects to the backend server using the provided client. 60 | 2. It discovers all the tools, resources, resource templates, and prompts available on the backend server. 61 | 3. It creates corresponding "proxy" components that forward requests to the backend. 62 | 4. It returns a standard `FastMCP` server instance that can be used like any other. 63 | 64 | 65 | Currently, proxying focuses primarily on exposing the major MCP objects (tools, resources, templates, and prompts). Some advanced MCP features like notifications and sampling are not fully supported in proxies in the current version. Support for these additional features may be added in future releases. 66 | 67 | 68 | ### Bridging Transports 69 | 70 | A common use case is to bridge transports. For example, making a remote SSE server available locally via Stdio: 71 | 72 | ```python 73 | from fastmcp import FastMCP, Client 74 | 75 | # Client targeting a remote SSE server 76 | client = Client("http://example.com/mcp/sse") 77 | 78 | # Create a proxy server - it's just a regular FastMCP server 79 | proxy = FastMCP.from_client(client, name="SSE to Stdio Proxy") 80 | 81 | # The proxy can now be used with any transport 82 | # No special handling needed - it works like any FastMCP server 83 | ``` 84 | 85 | ### In-Memory Proxies 86 | 87 | You can also proxy an in-memory `FastMCP` instance, which is useful for adjusting the configuration or behavior of a server you don't completely control. 88 | 89 | ```python 90 | from fastmcp import FastMCP 91 | 92 | # Original server 93 | original_server = FastMCP(name="Original") 94 | 95 | @original_server.tool() 96 | def tool_a() -> str: 97 | return "A" 98 | 99 | # Create a proxy of the original server 100 | proxy = FastMCP.from_client( 101 | original_server, 102 | name="Proxy Server" 103 | ) 104 | 105 | # proxy is now a regular FastMCP server that forwards 106 | # requests to original_server 107 | ``` 108 | 109 | ## `FastMCPProxy` Class 110 | 111 | Internally, `FastMCP.from_client()` uses the `FastMCPProxy` class. You generally don't need to interact with this class directly, but it's available if needed. 112 | 113 | Using the class directly might be necessary for advanced scenarios, like subclassing `FastMCPProxy` to add custom logic before or after forwarding requests. -------------------------------------------------------------------------------- /docs/snippets/version-badge.mdx: -------------------------------------------------------------------------------- 1 | export const VersionBadge = ({ version }) => { 2 | return ( 3 | 4 | 5 | New in version {version} 6 | 7 | ); 8 | }; -------------------------------------------------------------------------------- /docs/style.css: -------------------------------------------------------------------------------- 1 | /* Code highlighting -- target only inline code elements, not code blocks */ 2 | p code:not(pre code), 3 | table code:not(pre code), 4 | li code:not(pre code), 5 | h1 code:not(pre code), 6 | h2 code:not(pre code), 7 | h3 code:not(pre code), 8 | h4 code:not(pre code), 9 | h5 code:not(pre code), 10 | h6 code:not(pre code) { 11 | color: #f72585 !important; 12 | background-color: rgba(247, 37, 133, 0.09); 13 | } 14 | 15 | /* Version badge -- display a badge with the current version of the documentation */ 16 | .version-badge { 17 | display: inline-flex; 18 | align-items: center; 19 | gap: 0.3em; 20 | padding: 0.32em 1em; 21 | font-size: 0.92em; 22 | font-weight: 600; 23 | letter-spacing: 0.01em; 24 | color: #7417e5; 25 | background: #f3e8ff; 26 | border: 1.5px solid #c084fc; 27 | border-radius: 6px; 28 | box-shadow: none; 29 | vertical-align: middle; 30 | position: relative; 31 | transition: box-shadow 0.2s, transform 0.15s; 32 | } 33 | 34 | .version-badge:hover { 35 | box-shadow: 0 2px 8px 0 rgba(160, 132, 252, 0.1); 36 | transform: translateY(-1px) scale(1.03); 37 | } 38 | 39 | .dark .version-badge { 40 | color: #fff; 41 | background: #312e81; 42 | border: 1.5px solid #a78bfa; 43 | } 44 | 45 | .badge-emoji { 46 | font-size: 1.15em; 47 | line-height: 1; 48 | text-shadow: 0 1px 2px #fff, 0 0px 2px #c084fc; 49 | } 50 | 51 | .dark .badge-emoji { 52 | text-shadow: 0 1px 2px #312e81, 0 0px 2px #a78bfa; 53 | } 54 | -------------------------------------------------------------------------------- /examples/complex_inputs.py: -------------------------------------------------------------------------------- 1 | """ 2 | FastMCP Complex inputs Example 3 | 4 | Demonstrates validation via pydantic with complex models. 5 | """ 6 | 7 | from typing import Annotated 8 | 9 | from pydantic import BaseModel, Field 10 | 11 | from fastmcp.server import FastMCP 12 | 13 | mcp = FastMCP("Shrimp Tank") 14 | 15 | 16 | class ShrimpTank(BaseModel): 17 | class Shrimp(BaseModel): 18 | name: Annotated[str, Field(max_length=10)] 19 | 20 | shrimp: list[Shrimp] 21 | 22 | 23 | @mcp.tool() 24 | def name_shrimp( 25 | tank: ShrimpTank, 26 | # You can use pydantic Field in function signatures for validation. 27 | extra_names: Annotated[list[str], Field(max_length=10)], 28 | ) -> list[str]: 29 | """List all shrimp names in the tank""" 30 | return [shrimp.name for shrimp in tank.shrimp] + extra_names 31 | -------------------------------------------------------------------------------- /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/mount_example.py: -------------------------------------------------------------------------------- 1 | """Example of mounting FastMCP apps together. 2 | 3 | This example demonstrates how to mount FastMCP apps together using 4 | the ToolManager's import_tools functionality. It shows how to: 5 | 6 | 1. Create sub-applications for different domains 7 | 2. Mount those sub-applications to a main application 8 | 3. Access tools with prefixed names and resources with prefixed URIs 9 | """ 10 | 11 | import asyncio 12 | 13 | from fastmcp import FastMCP 14 | 15 | # Weather sub-application 16 | weather_app = FastMCP("Weather App") 17 | 18 | 19 | @weather_app.tool() 20 | def get_weather_forecast(location: str) -> str: 21 | """Get the weather forecast for a location.""" 22 | return f"Sunny skies for {location} today!" 23 | 24 | 25 | @weather_app.resource(uri="weather://forecast") 26 | async def weather_data(): 27 | """Return current weather data.""" 28 | return {"temperature": 72, "conditions": "sunny", "humidity": 45, "wind_speed": 5} 29 | 30 | 31 | # News sub-application 32 | news_app = FastMCP("News App") 33 | 34 | 35 | @news_app.tool() 36 | def get_news_headlines() -> list[str]: 37 | """Get the latest news headlines.""" 38 | return [ 39 | "Tech company launches new product", 40 | "Local team wins championship", 41 | "Scientists make breakthrough discovery", 42 | ] 43 | 44 | 45 | @news_app.resource(uri="news://headlines") 46 | async def news_data(): 47 | """Return latest news data.""" 48 | return { 49 | "top_story": "Breaking news: Important event happened", 50 | "categories": ["politics", "sports", "technology"], 51 | "sources": ["AP", "Reuters", "Local Sources"], 52 | } 53 | 54 | 55 | # Main application 56 | app = FastMCP( 57 | "Main App", dependencies=["fastmcp@git+https://github.com/jlowin/fastmcp.git"] 58 | ) 59 | 60 | 61 | @app.tool() 62 | def check_app_status() -> dict[str, str]: 63 | """Check the status of the main application.""" 64 | return {"status": "running", "version": "1.0.0", "uptime": "3h 24m"} 65 | 66 | 67 | # Mount sub-applications 68 | app.mount("weather", weather_app) 69 | 70 | app.mount("news", news_app) 71 | 72 | 73 | async def get_server_details(): 74 | """Print information about mounted resources.""" 75 | # Print available tools 76 | tools = app._tool_manager.list_tools() 77 | print(f"\nAvailable tools ({len(tools)}):") 78 | for tool in tools: 79 | print(f" - {tool.name}: {tool.description}") 80 | 81 | # Print available resources 82 | print("\nAvailable resources:") 83 | 84 | # Distinguish between native and imported resources 85 | # Native resources would be those directly in the main app (not prefixed) 86 | native_resources = [ 87 | uri 88 | for uri in app._resource_manager._resources 89 | if not (uri.startswith("weather+") or uri.startswith("news+")) 90 | ] 91 | 92 | # Imported resources - categorized by source app 93 | weather_resources = [ 94 | uri for uri in app._resource_manager._resources if uri.startswith("weather+") 95 | ] 96 | news_resources = [ 97 | uri for uri in app._resource_manager._resources if uri.startswith("news+") 98 | ] 99 | 100 | print(f" - Native app resources: {native_resources}") 101 | print(f" - Imported from weather app: {weather_resources}") 102 | print(f" - Imported from news app: {news_resources}") 103 | 104 | # Let's try to access resources using the prefixed URI 105 | weather_data = await app.read_resource("weather+weather://forecast") 106 | print(f"\nWeather data from prefixed URI: {weather_data}") 107 | 108 | 109 | if __name__ == "__main__": 110 | # First run our async function to display info 111 | asyncio.run(get_server_details()) 112 | 113 | # Then start the server (uncomment to run the server) 114 | app.run() 115 | -------------------------------------------------------------------------------- /examples/readme-quickstart.py: -------------------------------------------------------------------------------- 1 | from fastmcp import FastMCP 2 | 3 | # Create an MCP server 4 | mcp = FastMCP("Demo") 5 | 6 | 7 | # Add an addition tool 8 | @mcp.tool() 9 | def add(a: int, b: int) -> int: 10 | """Add two numbers""" 11 | return a + b 12 | 13 | 14 | # Add a dynamic greeting resource 15 | @mcp.resource("greeting://{name}") 16 | def get_greeting(name: str) -> str: 17 | """Get a personalized greeting""" 18 | return f"Hello, {name}!" 19 | -------------------------------------------------------------------------------- /examples/sampling.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of using sampling to request an LLM completion via Marvin 3 | """ 4 | 5 | import asyncio 6 | 7 | import marvin 8 | from mcp.types import TextContent 9 | 10 | from fastmcp import Client, Context, FastMCP 11 | from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams 12 | 13 | # -- Create a server that sends a sampling request to the LLM 14 | 15 | mcp = FastMCP("Sampling Example") 16 | 17 | 18 | @mcp.tool() 19 | async def example_tool(prompt: str, context: Context) -> str: 20 | """Sample a completion from the LLM.""" 21 | response = await context.sample( 22 | "What is your favorite programming language?", 23 | system_prompt="You love languages named after snakes.", 24 | ) 25 | assert isinstance(response, TextContent) 26 | return response.text 27 | 28 | 29 | # -- Create a client that can handle the sampling request 30 | 31 | 32 | async def sampling_fn( 33 | messages: list[SamplingMessage], 34 | params: SamplingParams, 35 | ctx: RequestContext, 36 | ) -> str: 37 | return await marvin.say_async( 38 | message=[m.content.text for m in messages], 39 | instructions=params.systemPrompt, 40 | ) 41 | 42 | 43 | async def run(): 44 | async with Client(mcp, sampling_handler=sampling_fn) as client: 45 | result = await client.call_tool( 46 | "example_tool", {"prompt": "What is the best programming language?"} 47 | ) 48 | print(result) 49 | 50 | 51 | if __name__ == "__main__": 52 | asyncio.run(run()) 53 | -------------------------------------------------------------------------------- /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 | 9 | from fastmcp import FastMCP, Image 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 | # Create server 8 | mcp = FastMCP("Echo Server") 9 | 10 | 11 | @mcp.tool() 12 | def echo(text: str) -> str: 13 | """Echo the input text""" 14 | return text 15 | -------------------------------------------------------------------------------- /examples/smart_home/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlowin/fastmcp/a4e233711fda1e232e24fd586cfd11c495bd14fe/examples/smart_home/README.md -------------------------------------------------------------------------------- /examples/smart_home/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "smart-home" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | authors = [{ name = "zzstoatzz", email = "thrast36@gmail.com" }] 7 | requires-python = ">=3.12" 8 | dependencies = ["fastmcp@git+https://github.com/jlowin/fastmcp.git", "phue2"] 9 | 10 | [project.scripts] 11 | smart-home = "smart_home.__main__:main" 12 | 13 | [dependency-groups] 14 | dev = ["ruff", "ipython"] 15 | 16 | 17 | [build-system] 18 | requires = ["uv_build"] 19 | build-backend = "uv_build" 20 | -------------------------------------------------------------------------------- /examples/smart_home/src/smart_home/__init__.py: -------------------------------------------------------------------------------- 1 | from smart_home.settings import settings 2 | 3 | __all__ = ["settings"] 4 | -------------------------------------------------------------------------------- /examples/smart_home/src/smart_home/__main__.py: -------------------------------------------------------------------------------- 1 | from smart_home.hub import hub_mcp 2 | 3 | 4 | def main(): 5 | hub_mcp.run() 6 | 7 | 8 | if __name__ == "__main__": 9 | main() 10 | -------------------------------------------------------------------------------- /examples/smart_home/src/smart_home/hub.py: -------------------------------------------------------------------------------- 1 | from phue2 import Bridge 2 | 3 | from fastmcp import FastMCP 4 | from smart_home.lights.server import lights_mcp 5 | from smart_home.settings import settings 6 | 7 | hub_mcp = FastMCP( 8 | "Smart Home Hub (phue2)", 9 | dependencies=[ 10 | "smart_home@git+https://github.com/jlowin/fastmcp.git#subdirectory=examples/smart_home", 11 | ], 12 | ) 13 | 14 | # Mount the lights service under the 'hue' prefix 15 | hub_mcp.mount("hue", lights_mcp) 16 | 17 | 18 | # Add a status check for the hub 19 | @hub_mcp.tool() 20 | def hub_status() -> str: 21 | """Checks the status of the main hub and connections.""" 22 | try: 23 | bridge = Bridge( 24 | ip=str(settings.hue_bridge_ip), 25 | username=settings.hue_bridge_username, 26 | save_config=False, 27 | ) 28 | bridge.connect() 29 | return "Hub OK. Hue Bridge Connected (via phue2)." 30 | except Exception as e: 31 | return f"Hub Warning: Hue Bridge connection failed or not attempted: {e}" 32 | 33 | 34 | # Add mounting points for other services later 35 | # hub_mcp.mount("thermo", thermostat_mcp) 36 | -------------------------------------------------------------------------------- /examples/smart_home/src/smart_home/lights/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlowin/fastmcp/a4e233711fda1e232e24fd586cfd11c495bd14fe/examples/smart_home/src/smart_home/lights/__init__.py -------------------------------------------------------------------------------- /examples/smart_home/src/smart_home/lights/hue_utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from phue2 import Bridge 4 | from phue2.exceptions import PhueException 5 | 6 | from smart_home.settings import settings 7 | 8 | 9 | def _get_bridge() -> Bridge | None: 10 | """Attempts to connect to the Hue bridge using settings.""" 11 | try: 12 | return Bridge( 13 | ip=str(settings.hue_bridge_ip), 14 | username=settings.hue_bridge_username, 15 | save_config=False, 16 | ) 17 | except Exception: 18 | # Broad exception to catch potential connection issues 19 | # TODO: Add more specific logging or error handling 20 | return None 21 | 22 | 23 | def handle_phue_error( 24 | light_or_group: str, operation: str, error: Exception 25 | ) -> dict[str, Any]: 26 | """Creates a standardized error response for phue2 operations.""" 27 | base_info = {"target": light_or_group, "operation": operation, "success": False} 28 | if isinstance(error, KeyError): 29 | base_info["error"] = f"Target '{light_or_group}' not found" 30 | elif isinstance(error, PhueException): 31 | base_info["error"] = f"phue2 error during {operation}: {error}" 32 | else: 33 | base_info["error"] = f"Unexpected error during {operation}: {error}" 34 | return base_info 35 | -------------------------------------------------------------------------------- /examples/smart_home/src/smart_home/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlowin/fastmcp/a4e233711fda1e232e24fd586cfd11c495bd14fe/examples/smart_home/src/smart_home/py.typed -------------------------------------------------------------------------------- /examples/smart_home/src/smart_home/settings.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field, IPvAnyAddress 2 | from pydantic_settings import BaseSettings, SettingsConfigDict 3 | 4 | 5 | class Settings(BaseSettings): 6 | model_config = SettingsConfigDict(env_file=".env", extra="ignore") 7 | 8 | hue_bridge_ip: IPvAnyAddress = Field(default=...) 9 | hue_bridge_username: str = Field(default=...) 10 | 11 | 12 | settings = Settings() 13 | -------------------------------------------------------------------------------- /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 | 23 | import httpx 24 | from pydantic import BeforeValidator 25 | from pydantic_settings import BaseSettings, SettingsConfigDict 26 | 27 | from fastmcp import FastMCP 28 | 29 | 30 | class SurgeSettings(BaseSettings): 31 | model_config: SettingsConfigDict = SettingsConfigDict( 32 | env_prefix="SURGE_", env_file=".env" 33 | ) 34 | 35 | api_key: str 36 | account_id: str 37 | my_phone_number: Annotated[ 38 | str, BeforeValidator(lambda v: "+" + v if not v.startswith("+") else v) 39 | ] 40 | my_first_name: str 41 | my_last_name: str 42 | 43 | 44 | # Create server 45 | mcp = FastMCP("Text me") 46 | surge_settings = SurgeSettings() # type: ignore 47 | 48 | 49 | @mcp.tool(name="textme", description="Send a text message to me") 50 | def text_me(text_content: str) -> str: 51 | """Send a text message to a phone number via https://surgemsg.com/""" 52 | with httpx.Client() as client: 53 | response = client.post( 54 | "https://api.surgemsg.com/messages", 55 | headers={ 56 | "Authorization": f"Bearer {surge_settings.api_key}", 57 | "Surge-Account": surge_settings.account_id, 58 | "Content-Type": "application/json", 59 | }, 60 | json={ 61 | "body": text_content, 62 | "conversation": { 63 | "contact": { 64 | "first_name": surge_settings.my_first_name, 65 | "last_name": surge_settings.my_last_name, 66 | "phone_number": surge_settings.my_phone_number, 67 | } 68 | }, 69 | }, 70 | ) 71 | response.raise_for_status() 72 | return f"Message sent: {text_content}" 73 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | build: 2 | uv sync 3 | 4 | test: build 5 | uv run --frozen pytest -xvs tests 6 | 7 | # Run pyright on all files 8 | typecheck: 9 | uv run pyright -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "fastmcp" 3 | dynamic = ["version"] 4 | description = "The fast, Pythonic way to build MCP servers." 5 | authors = [{ name = "Jeremiah Lowin" }] 6 | dependencies = [ 7 | "python-dotenv>=1.1.0", 8 | "exceptiongroup>=1.2.2", 9 | "httpx>=0.28.1", 10 | "mcp>=1.6.0,<2.0.0", 11 | "openapi-pydantic>=0.5.1", 12 | "rich>=13.9.4", 13 | "typer>=0.15.2", 14 | "websockets>=15.0.1", 15 | ] 16 | requires-python = ">=3.10" 17 | readme = "README.md" 18 | license = "Apache-2.0" 19 | 20 | keywords = [ 21 | "mcp", 22 | "mcp server", 23 | "mcp client", 24 | "model context protocol", 25 | "fastmcp", 26 | "llm", 27 | "agent", 28 | ] 29 | classifiers = [ 30 | "Intended Audience :: Developers", 31 | "License :: OSI Approved :: Apache Software License", 32 | "Topic :: Scientific/Engineering :: Artificial Intelligence", 33 | "Programming Language :: Python :: 3.10", 34 | "Programming Language :: Python :: 3.11", 35 | "Programming Language :: Python :: 3.12", 36 | "Typing :: Typed", 37 | ] 38 | 39 | [dependency-groups] 40 | dev = [ 41 | "copychat>=0.5.2", 42 | "dirty-equals>=0.9.0", 43 | "fastapi>=0.115.12", 44 | "ipython>=8.12.3", 45 | "pdbpp>=0.10.3", 46 | "pre-commit", 47 | "pyright>=1.1.389", 48 | "pytest>=8.3.3", 49 | "pytest-asyncio>=0.23.5", 50 | "pytest-flakefinder", 51 | "pytest-xdist>=3.6.1", 52 | "ruff", 53 | ] 54 | 55 | [project.scripts] 56 | fastmcp = "fastmcp.cli:app" 57 | 58 | [project.urls] 59 | Homepage = "https://gofastmcp.com" 60 | Repository = "https://github.com/jlowin/fastmcp" 61 | Documentation = "https://gofastmcp.com" 62 | 63 | [build-system] 64 | requires = ["hatchling", "uv-dynamic-versioning>=0.7.0"] 65 | build-backend = "hatchling.build" 66 | 67 | [tool.hatch.version] 68 | source = "uv-dynamic-versioning" 69 | 70 | [tool.uv-dynamic-versioning] 71 | vcs = "git" 72 | style = "pep440" 73 | bump = true 74 | fallback-version = "0.0.0" 75 | 76 | 77 | [tool.uv] 78 | # uncomment to omit `dev` default group 79 | # default-groups = [] 80 | 81 | [tool.pytest.ini_options] 82 | asyncio_mode = "auto" 83 | asyncio_default_fixture_loop_scope = "session" 84 | filterwarnings = [] 85 | 86 | 87 | [tool.pyright] 88 | include = ["src", "tests"] 89 | exclude = ["**/node_modules", "**/__pycache__", ".venv", ".git", "dist"] 90 | pythonVersion = "3.10" 91 | pythonPlatform = "Darwin" 92 | typeCheckingMode = "basic" 93 | reportMissingImports = true 94 | reportMissingTypeStubs = false 95 | useLibraryCodeForTypes = true 96 | venvPath = "." 97 | venv = ".venv" 98 | 99 | [tool.ruff.lint] 100 | extend-select = ["I", "UP"] 101 | 102 | [tool.ruff.lint.per-file-ignores] 103 | "__init__.py" = ["F401", "I001", "RUF013"] 104 | -------------------------------------------------------------------------------- /src/fastmcp/__init__.py: -------------------------------------------------------------------------------- 1 | """FastMCP - An ergonomic MCP interface.""" 2 | 3 | from importlib.metadata import version 4 | 5 | 6 | from fastmcp.server.server import FastMCP 7 | from fastmcp.server.context import Context 8 | from fastmcp.client import Client 9 | from fastmcp.utilities.types import Image 10 | from . import client, settings 11 | 12 | __version__ = version("fastmcp") 13 | __all__ = [ 14 | "FastMCP", 15 | "Context", 16 | "client", 17 | "settings", 18 | "Image", 19 | ] 20 | -------------------------------------------------------------------------------- /src/fastmcp/cli/__init__.py: -------------------------------------------------------------------------------- 1 | """FastMCP CLI package.""" 2 | 3 | from .cli import app 4 | 5 | if __name__ == "__main__": 6 | app() 7 | -------------------------------------------------------------------------------- /src/fastmcp/cli/claude.py: -------------------------------------------------------------------------------- 1 | """Claude app integration utilities.""" 2 | 3 | import json 4 | import os 5 | import sys 6 | from pathlib import Path 7 | from typing import Any 8 | 9 | from fastmcp.utilities.logging import get_logger 10 | 11 | logger = get_logger(__name__) 12 | 13 | 14 | def get_claude_config_path() -> Path | None: 15 | """Get the Claude config directory based on platform.""" 16 | if sys.platform == "win32": 17 | path = Path(Path.home(), "AppData", "Roaming", "Claude") 18 | elif sys.platform == "darwin": 19 | path = Path(Path.home(), "Library", "Application Support", "Claude") 20 | elif sys.platform.startswith("linux"): 21 | path = Path( 22 | os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"), "Claude" 23 | ) 24 | else: 25 | return None 26 | 27 | if path.exists(): 28 | return path 29 | return None 30 | 31 | 32 | def update_claude_config( 33 | file_spec: str, 34 | server_name: str, 35 | *, 36 | with_editable: Path | None = None, 37 | with_packages: list[str] | None = None, 38 | env_vars: dict[str, str] | None = None, 39 | ) -> bool: 40 | """Add or update a FastMCP server in Claude's configuration. 41 | 42 | Args: 43 | file_spec: Path to the server file, optionally with :object suffix 44 | server_name: Name for the server in Claude's config 45 | with_editable: Optional directory to install in editable mode 46 | with_packages: Optional list of additional packages to install 47 | env_vars: Optional dictionary of environment variables. These are merged with 48 | any existing variables, with new values taking precedence. 49 | 50 | Raises: 51 | RuntimeError: If Claude Desktop's config directory is not found, indicating 52 | Claude Desktop may not be installed or properly set up. 53 | """ 54 | config_dir = get_claude_config_path() 55 | if not config_dir: 56 | raise RuntimeError( 57 | "Claude Desktop config directory not found. Please ensure Claude Desktop" 58 | " is installed and has been run at least once to initialize its config." 59 | ) 60 | 61 | config_file = config_dir / "claude_desktop_config.json" 62 | if not config_file.exists(): 63 | try: 64 | config_file.write_text("{}") 65 | except Exception as e: 66 | logger.error( 67 | "Failed to create Claude config file", 68 | extra={ 69 | "error": str(e), 70 | "config_file": str(config_file), 71 | }, 72 | ) 73 | return False 74 | 75 | try: 76 | config = json.loads(config_file.read_text()) 77 | if "mcpServers" not in config: 78 | config["mcpServers"] = {} 79 | 80 | # Always preserve existing env vars and merge with new ones 81 | if ( 82 | server_name in config["mcpServers"] 83 | and "env" in config["mcpServers"][server_name] 84 | ): 85 | existing_env = config["mcpServers"][server_name]["env"] 86 | if env_vars: 87 | # New vars take precedence over existing ones 88 | env_vars = {**existing_env, **env_vars} 89 | else: 90 | env_vars = existing_env 91 | 92 | # Build uv run command 93 | args = ["run"] 94 | 95 | # Collect all packages in a set to deduplicate 96 | packages = {"fastmcp"} 97 | if with_packages: 98 | packages.update(pkg for pkg in with_packages if pkg) 99 | 100 | # Add all packages with --with 101 | for pkg in sorted(packages): 102 | args.extend(["--with", pkg]) 103 | 104 | if with_editable: 105 | args.extend(["--with-editable", str(with_editable)]) 106 | 107 | # Convert file path to absolute before adding to command 108 | # Split off any :object suffix first 109 | if ":" in file_spec: 110 | file_path, server_object = file_spec.rsplit(":", 1) 111 | file_spec = f"{Path(file_path).resolve()}:{server_object}" 112 | else: 113 | file_spec = str(Path(file_spec).resolve()) 114 | 115 | # Add fastmcp run command 116 | args.extend(["fastmcp", "run", file_spec]) 117 | 118 | server_config: dict[str, Any] = {"command": "uv", "args": args} 119 | 120 | # Add environment variables if specified 121 | if env_vars: 122 | server_config["env"] = env_vars 123 | 124 | config["mcpServers"][server_name] = server_config 125 | 126 | config_file.write_text(json.dumps(config, indent=2)) 127 | logger.info( 128 | f"Added server '{server_name}' to Claude config", 129 | extra={"config_file": str(config_file)}, 130 | ) 131 | return True 132 | except Exception as e: 133 | logger.error( 134 | "Failed to update Claude config", 135 | extra={ 136 | "error": str(e), 137 | "config_file": str(config_file), 138 | }, 139 | ) 140 | return False 141 | -------------------------------------------------------------------------------- /src/fastmcp/client/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import Client 2 | from .transports import ( 3 | ClientTransport, 4 | WSTransport, 5 | SSETransport, 6 | StdioTransport, 7 | PythonStdioTransport, 8 | NodeStdioTransport, 9 | UvxStdioTransport, 10 | NpxStdioTransport, 11 | FastMCPTransport, 12 | ) 13 | 14 | __all__ = [ 15 | "Client", 16 | "ClientTransport", 17 | "WSTransport", 18 | "SSETransport", 19 | "StdioTransport", 20 | "PythonStdioTransport", 21 | "NodeStdioTransport", 22 | "UvxStdioTransport", 23 | "NpxStdioTransport", 24 | "FastMCPTransport", 25 | ] 26 | -------------------------------------------------------------------------------- /src/fastmcp/client/base.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/fastmcp/client/client.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from contextlib import AbstractAsyncContextManager 3 | from pathlib import Path 4 | from typing import Any, Literal, cast, overload 5 | 6 | import mcp.types 7 | from mcp import ClientSession 8 | from mcp.client.session import ( 9 | LoggingFnT, 10 | MessageHandlerFnT, 11 | ) 12 | from pydantic import AnyUrl 13 | 14 | from fastmcp.client.roots import ( 15 | RootsHandler, 16 | RootsList, 17 | create_roots_callback, 18 | ) 19 | from fastmcp.client.sampling import SamplingHandler, create_sampling_callback 20 | from fastmcp.exceptions import ClientError 21 | from fastmcp.server import FastMCP 22 | 23 | from .transports import ClientTransport, SessionKwargs, infer_transport 24 | 25 | __all__ = ["Client", "RootsHandler", "RootsList"] 26 | 27 | 28 | class Client: 29 | """ 30 | MCP client that delegates connection management to a Transport instance. 31 | 32 | The Client class is primarily concerned with MCP protocol logic, 33 | while the Transport handles connection establishment and management. 34 | """ 35 | 36 | def __init__( 37 | self, 38 | transport: ClientTransport | FastMCP | AnyUrl | Path | str, 39 | # Common args 40 | roots: RootsList | RootsHandler | None = None, 41 | sampling_handler: SamplingHandler | None = None, 42 | log_handler: LoggingFnT | None = None, 43 | message_handler: MessageHandlerFnT | None = None, 44 | read_timeout_seconds: datetime.timedelta | None = None, 45 | ): 46 | self.transport = infer_transport(transport) 47 | self._session: ClientSession | None = None 48 | self._session_cm: AbstractAsyncContextManager[ClientSession] | None = None 49 | self._nesting_counter: int = 0 50 | 51 | self._session_kwargs: SessionKwargs = { 52 | "sampling_callback": None, 53 | "list_roots_callback": None, 54 | "logging_callback": log_handler, 55 | "message_handler": message_handler, 56 | "read_timeout_seconds": read_timeout_seconds, 57 | } 58 | 59 | if roots is not None: 60 | self.set_roots(roots) 61 | 62 | if sampling_handler is not None: 63 | self.set_sampling_callback(sampling_handler) 64 | 65 | @property 66 | def session(self) -> ClientSession: 67 | """Get the current active session. Raises RuntimeError if not connected.""" 68 | if self._session is None: 69 | raise RuntimeError( 70 | "Client is not connected. Use 'async with client:' context manager first." 71 | ) 72 | return self._session 73 | 74 | def set_roots(self, roots: RootsList | RootsHandler) -> None: 75 | """Set the roots for the client. This does not automatically call `send_roots_list_changed`.""" 76 | self._session_kwargs["list_roots_callback"] = create_roots_callback(roots) 77 | 78 | def set_sampling_callback(self, sampling_callback: SamplingHandler) -> None: 79 | """Set the sampling callback for the client.""" 80 | self._session_kwargs["sampling_callback"] = create_sampling_callback( 81 | sampling_callback 82 | ) 83 | 84 | def is_connected(self) -> bool: 85 | """Check if the client is currently connected.""" 86 | return self._session is not None 87 | 88 | async def __aenter__(self): 89 | if self._nesting_counter == 0: 90 | # create new session 91 | self._session_cm = self.transport.connect_session(**self._session_kwargs) 92 | self._session = await self._session_cm.__aenter__() 93 | 94 | self._nesting_counter += 1 95 | return self 96 | 97 | async def __aexit__(self, exc_type, exc_val, exc_tb): 98 | self._nesting_counter -= 1 99 | 100 | if self._nesting_counter == 0 and self._session_cm is not None: 101 | await self._session_cm.__aexit__(exc_type, exc_val, exc_tb) 102 | self._session_cm = None 103 | self._session = None 104 | 105 | # --- MCP Client Methods --- 106 | async def ping(self) -> None: 107 | """Send a ping request.""" 108 | await self.session.send_ping() 109 | 110 | async def progress( 111 | self, 112 | progress_token: str | int, 113 | progress: float, 114 | total: float | None = None, 115 | ) -> None: 116 | """Send a progress notification.""" 117 | await self.session.send_progress_notification(progress_token, progress, total) 118 | 119 | async def set_logging_level(self, level: mcp.types.LoggingLevel) -> None: 120 | """Send a logging/setLevel request.""" 121 | await self.session.set_logging_level(level) 122 | 123 | async def send_roots_list_changed(self) -> None: 124 | """Send a roots/list_changed notification.""" 125 | await self.session.send_roots_list_changed() 126 | 127 | async def list_resources(self) -> list[mcp.types.Resource]: 128 | """Send a resources/list request.""" 129 | result = await self.session.list_resources() 130 | return result.resources 131 | 132 | async def list_resource_templates(self) -> list[mcp.types.ResourceTemplate]: 133 | """Send a resources/listResourceTemplates request.""" 134 | result = await self.session.list_resource_templates() 135 | return result.resourceTemplates 136 | 137 | async def read_resource( 138 | self, uri: AnyUrl | str 139 | ) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]: 140 | """Send a resources/read request.""" 141 | if isinstance(uri, str): 142 | uri = AnyUrl(uri) # Ensure AnyUrl 143 | result = await self.session.read_resource(uri) 144 | return result.contents 145 | 146 | # async def subscribe_resource(self, uri: AnyUrl | str) -> None: 147 | # """Send a resources/subscribe request.""" 148 | # if isinstance(uri, str): 149 | # uri = AnyUrl(uri) 150 | # await self.session.subscribe_resource(uri) 151 | 152 | # async def unsubscribe_resource(self, uri: AnyUrl | str) -> None: 153 | # """Send a resources/unsubscribe request.""" 154 | # if isinstance(uri, str): 155 | # uri = AnyUrl(uri) 156 | # await self.session.unsubscribe_resource(uri) 157 | 158 | async def list_prompts(self) -> list[mcp.types.Prompt]: 159 | """Send a prompts/list request.""" 160 | result = await self.session.list_prompts() 161 | return result.prompts 162 | 163 | async def get_prompt( 164 | self, name: str, arguments: dict[str, str] | None = None 165 | ) -> list[mcp.types.PromptMessage]: 166 | """Send a prompts/get request.""" 167 | result = await self.session.get_prompt(name, arguments) 168 | return result.messages 169 | 170 | async def complete( 171 | self, 172 | ref: mcp.types.ResourceReference | mcp.types.PromptReference, 173 | argument: dict[str, str], 174 | ) -> mcp.types.Completion: 175 | """Send a completion request.""" 176 | result = await self.session.complete(ref, argument) 177 | return result.completion 178 | 179 | async def list_tools(self) -> list[mcp.types.Tool]: 180 | """Send a tools/list request.""" 181 | result = await self.session.list_tools() 182 | return result.tools 183 | 184 | @overload 185 | async def call_tool( 186 | self, 187 | name: str, 188 | arguments: dict[str, Any] | None = None, 189 | _return_raw_result: Literal[False] = False, 190 | ) -> list[ 191 | mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource 192 | ]: ... 193 | 194 | @overload 195 | async def call_tool( 196 | self, 197 | name: str, 198 | arguments: dict[str, Any] | None = None, 199 | _return_raw_result: Literal[True] = True, 200 | ) -> mcp.types.CallToolResult: ... 201 | 202 | async def call_tool( 203 | self, 204 | name: str, 205 | arguments: dict[str, Any] | None = None, 206 | _return_raw_result: bool = False, 207 | ) -> ( 208 | list[ 209 | mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource 210 | ] 211 | | mcp.types.CallToolResult 212 | ): 213 | """Send a tools/call request.""" 214 | result = await self.session.call_tool(name, arguments) 215 | if _return_raw_result: 216 | return result 217 | elif result.isError: 218 | msg = cast(mcp.types.TextContent, result.content[0]).text 219 | raise ClientError(msg) 220 | return result.content 221 | -------------------------------------------------------------------------------- /src/fastmcp/client/roots.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from collections.abc import Awaitable, Callable 3 | from typing import TypeAlias 4 | 5 | import mcp.types 6 | import pydantic 7 | from mcp import ClientSession 8 | from mcp.client.session import ListRootsFnT 9 | from mcp.shared.context import LifespanContextT, RequestContext 10 | 11 | RootsList: TypeAlias = list[str] | list[mcp.types.Root] | list[str | mcp.types.Root] 12 | 13 | RootsHandler: TypeAlias = ( 14 | Callable[[RequestContext[ClientSession, LifespanContextT]], RootsList] 15 | | Callable[[RequestContext[ClientSession, LifespanContextT]], Awaitable[RootsList]] 16 | ) 17 | 18 | 19 | def convert_roots_list(roots: RootsList) -> list[mcp.types.Root]: 20 | roots_list = [] 21 | for r in roots: 22 | if isinstance(r, mcp.types.Root): 23 | roots_list.append(r) 24 | elif isinstance(r, pydantic.FileUrl): 25 | roots_list.append(mcp.types.Root(uri=r)) 26 | elif isinstance(r, str): 27 | roots_list.append(mcp.types.Root(uri=pydantic.FileUrl(r))) 28 | else: 29 | raise ValueError(f"Invalid root: {r}") 30 | return roots_list 31 | 32 | 33 | def create_roots_callback( 34 | handler: RootsList | RootsHandler, 35 | ) -> ListRootsFnT: 36 | if isinstance(handler, list): 37 | return _create_roots_callback_from_roots(handler) 38 | elif inspect.isfunction(handler): 39 | return _create_roots_callback_from_fn(handler) 40 | else: 41 | raise ValueError(f"Invalid roots handler: {handler}") 42 | 43 | 44 | def _create_roots_callback_from_roots( 45 | roots: RootsList, 46 | ) -> ListRootsFnT: 47 | roots = convert_roots_list(roots) 48 | 49 | async def _roots_callback( 50 | context: RequestContext[ClientSession, LifespanContextT], 51 | ) -> mcp.types.ListRootsResult: 52 | return mcp.types.ListRootsResult(roots=roots) 53 | 54 | return _roots_callback 55 | 56 | 57 | def _create_roots_callback_from_fn( 58 | fn: Callable[[RequestContext[ClientSession, LifespanContextT]], RootsList] 59 | | Callable[[RequestContext[ClientSession, LifespanContextT]], Awaitable[RootsList]], 60 | ) -> ListRootsFnT: 61 | async def _roots_callback( 62 | context: RequestContext[ClientSession, LifespanContextT], 63 | ) -> mcp.types.ListRootsResult | mcp.types.ErrorData: 64 | try: 65 | roots = fn(context) 66 | if inspect.isawaitable(roots): 67 | roots = await roots 68 | return mcp.types.ListRootsResult(roots=convert_roots_list(roots)) 69 | except Exception as e: 70 | return mcp.types.ErrorData( 71 | code=mcp.types.INTERNAL_ERROR, 72 | message=str(e), 73 | ) 74 | 75 | return _roots_callback 76 | -------------------------------------------------------------------------------- /src/fastmcp/client/sampling.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from collections.abc import Awaitable, Callable 3 | from typing import TypeAlias 4 | 5 | import mcp.types 6 | from mcp import ClientSession, CreateMessageResult 7 | from mcp.client.session import SamplingFnT 8 | from mcp.shared.context import LifespanContextT, RequestContext 9 | from mcp.types import CreateMessageRequestParams as SamplingParams 10 | from mcp.types import SamplingMessage 11 | 12 | 13 | class MessageResult(CreateMessageResult): 14 | role: mcp.types.Role = "assistant" 15 | content: mcp.types.TextContent | mcp.types.ImageContent 16 | model: str = "client-model" 17 | 18 | 19 | SamplingHandler: TypeAlias = Callable[ 20 | [ 21 | list[SamplingMessage], 22 | SamplingParams, 23 | RequestContext[ClientSession, LifespanContextT], 24 | ], 25 | str | CreateMessageResult | Awaitable[str | CreateMessageResult], 26 | ] 27 | 28 | 29 | def create_sampling_callback(sampling_handler: SamplingHandler) -> SamplingFnT: 30 | async def _sampling_handler( 31 | context: RequestContext[ClientSession, LifespanContextT], 32 | params: SamplingParams, 33 | ) -> CreateMessageResult | mcp.types.ErrorData: 34 | try: 35 | result = sampling_handler(params.messages, params, context) 36 | if inspect.isawaitable(result): 37 | result = await result 38 | 39 | if isinstance(result, str): 40 | result = MessageResult( 41 | content=mcp.types.TextContent(type="text", text=result) 42 | ) 43 | return result 44 | except Exception as e: 45 | return mcp.types.ErrorData( 46 | code=mcp.types.INTERNAL_ERROR, 47 | message=str(e), 48 | ) 49 | 50 | return _sampling_handler 51 | -------------------------------------------------------------------------------- /src/fastmcp/contrib/README.md: -------------------------------------------------------------------------------- 1 | # FastMCP Contrib Modules 2 | 3 | This directory holds community-contributed modules for FastMCP. These modules extend FastMCP's functionality but are not officially maintained by the core team. 4 | 5 | **Guarantees:** 6 | * Modules in `contrib` may have different testing requirements or stability guarantees compared to the core library. 7 | * Changes to the core FastMCP library might break modules in `contrib` without explicit warnings in the main changelog. 8 | 9 | Use these modules at your own discretion. Contributions are welcome, but please include tests and documentation. 10 | 11 | ## Usage 12 | 13 | To use a contrib module, import it from the `fastmcp.contrib` package. 14 | 15 | ```python 16 | from fastmcp.contrib import my_module 17 | ``` 18 | 19 | Note that the contrib modules may have different dependencies than the core library, which can be noted in their respective README's or even separate requirements / dependency files. -------------------------------------------------------------------------------- /src/fastmcp/contrib/bulk_tool_caller/README.md: -------------------------------------------------------------------------------- 1 | # Bulk Tool Caller 2 | 3 | This module provides the `BulkToolCaller` class, which extends the `MCPMixin` to offer tools for performing multiple tool calls in a single request to a FastMCP server. This can be useful for optimizing interactions with the server by reducing the overhead of individual tool calls. 4 | 5 | ## Usage 6 | 7 | To use the `BulkToolCaller`, see the example [example.py](./example.py) file. The `BulkToolCaller` can be instantiated and then registered with a FastMCP server URL. It provides methods to call multiple tools in bulk, either different tools or the same tool with different arguments. 8 | 9 | 10 | ## Provided Tools 11 | 12 | The `BulkToolCaller` provides the following tools: 13 | 14 | ### `call_tools_bulk` 15 | 16 | Calls multiple different tools registered on the MCP server in a single request. 17 | 18 | - **Arguments:** 19 | - `tool_calls` (list of `CallToolRequest`): A list of objects, where each object specifies the `tool` name and `arguments` for an individual tool call. 20 | - `continue_on_error` (bool, optional): If `True`, continue executing subsequent tool calls even if a previous one resulted in an error. Defaults to `True`. 21 | 22 | - **Returns:** 23 | A list of `CallToolRequestResult` objects, each containing the result (`isError`, `content`) and the original `tool` name and `arguments` for each call. 24 | 25 | ### `call_tool_bulk` 26 | 27 | Calls a single tool registered on the MCP server multiple times with different arguments in a single request. 28 | 29 | - **Arguments:** 30 | - `tool` (str): The name of the tool to call. 31 | - `tool_arguments` (list of dict): A list of dictionaries, where each dictionary contains the arguments for an individual run of the tool. 32 | - `continue_on_error` (bool, optional): If `True`, continue executing subsequent tool calls even if a previous one resulted in an error. Defaults to `True`. 33 | 34 | - **Returns:** 35 | A list of `CallToolRequestResult` objects, each containing the result (`isError`, `content`) and the original `tool` name and `arguments` for each call. -------------------------------------------------------------------------------- /src/fastmcp/contrib/bulk_tool_caller/__init__.py: -------------------------------------------------------------------------------- 1 | from .bulk_tool_caller import BulkToolCaller 2 | 3 | __all__ = ["BulkToolCaller"] 4 | -------------------------------------------------------------------------------- /src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from mcp.types import CallToolResult 4 | from pydantic import BaseModel, Field 5 | 6 | from fastmcp import FastMCP 7 | from fastmcp.client import Client 8 | from fastmcp.client.transports import FastMCPTransport 9 | from fastmcp.contrib.mcp_mixin.mcp_mixin import ( 10 | _DEFAULT_SEPARATOR_TOOL, 11 | MCPMixin, 12 | mcp_tool, 13 | ) 14 | 15 | 16 | class CallToolRequest(BaseModel): 17 | """A class to represent a request to call a tool with specific arguments.""" 18 | 19 | tool: str = Field(description="The name of the tool to call.") 20 | arguments: dict[str, Any] = Field( 21 | description="A dictionary containing the arguments for the tool call." 22 | ) 23 | 24 | 25 | class CallToolRequestResult(CallToolResult): 26 | """ 27 | A class to represent the result of a bulk tool call. 28 | It extends CallToolResult to include information about the requested tool call. 29 | """ 30 | 31 | tool: str = Field(description="The name of the tool that was called.") 32 | arguments: dict[str, Any] = Field( 33 | description="The arguments used for the tool call." 34 | ) 35 | 36 | @classmethod 37 | def from_call_tool_result( 38 | cls, result: CallToolResult, tool: str, arguments: dict[str, Any] 39 | ) -> "CallToolRequestResult": 40 | """ 41 | Create a CallToolRequestResult from a CallToolResult. 42 | """ 43 | return cls( 44 | tool=tool, 45 | arguments=arguments, 46 | isError=result.isError, 47 | content=result.content, 48 | ) 49 | 50 | 51 | class BulkToolCaller(MCPMixin): 52 | """ 53 | A class to provide a "bulk tool call" tool for a FastMCP server 54 | """ 55 | 56 | def register_tools( 57 | self, 58 | mcp_server: "FastMCP", 59 | prefix: str | None = None, 60 | separator: str = _DEFAULT_SEPARATOR_TOOL, 61 | ) -> None: 62 | """ 63 | Register the tools provided by this class with the given MCP server. 64 | """ 65 | self.connection = FastMCPTransport(mcp_server) 66 | 67 | super().register_tools(mcp_server=mcp_server) 68 | 69 | @mcp_tool() 70 | async def call_tools_bulk( 71 | self, tool_calls: list[CallToolRequest], continue_on_error: bool = True 72 | ) -> list[CallToolRequestResult]: 73 | """ 74 | Call multiple tools registered on this MCP server in a single request. Each call can 75 | be for a different tool and can include different arguments. Useful for speeding up 76 | what would otherwise take several individual tool calls. 77 | """ 78 | results = [] 79 | 80 | for tool_call in tool_calls: 81 | result = await self._call_tool(tool_call.tool, tool_call.arguments) 82 | 83 | results.append(result) 84 | 85 | if result.isError and not continue_on_error: 86 | return results 87 | 88 | return results 89 | 90 | @mcp_tool() 91 | async def call_tool_bulk( 92 | self, 93 | tool: str, 94 | tool_arguments: list[dict[str, str | int | float | bool | None]], 95 | continue_on_error: bool = True, 96 | ) -> list[CallToolRequestResult]: 97 | """ 98 | Call a single tool registered on this MCP server multiple times with a single request. 99 | Each call can include different arguments. Useful for speeding up what would otherwise 100 | take several individual tool calls. 101 | 102 | Args: 103 | tool: The name of the tool to call. 104 | tool_arguments: A list of dictionaries, where each dictionary contains the arguments for an individual run of the tool. 105 | """ 106 | results = [] 107 | 108 | for tool_call_arguments in tool_arguments: 109 | result = await self._call_tool(tool, tool_call_arguments) 110 | 111 | results.append(result) 112 | 113 | if result.isError and not continue_on_error: 114 | return results 115 | 116 | return results 117 | 118 | async def _call_tool( 119 | self, tool: str, arguments: dict[str, Any] 120 | ) -> CallToolRequestResult: 121 | """ 122 | Helper method to call a tool with the provided arguments. 123 | """ 124 | 125 | async with Client(self.connection) as client: 126 | result = await client.call_tool( 127 | name=tool, arguments=arguments, _return_raw_result=True 128 | ) 129 | 130 | return CallToolRequestResult( 131 | tool=tool, 132 | arguments=arguments, 133 | isError=result.isError, 134 | content=result.content, 135 | ) 136 | -------------------------------------------------------------------------------- /src/fastmcp/contrib/bulk_tool_caller/example.py: -------------------------------------------------------------------------------- 1 | """Sample code for FastMCP using MCPMixin.""" 2 | 3 | from fastmcp import FastMCP 4 | from fastmcp.contrib.bulk_tool_caller import BulkToolCaller 5 | 6 | mcp = FastMCP() 7 | 8 | 9 | @mcp.tool() 10 | def echo_tool(text: str) -> str: 11 | """Echo the input text""" 12 | return text 13 | 14 | 15 | bulk_tool_caller = BulkToolCaller() 16 | 17 | bulk_tool_caller.register_tools(mcp) 18 | -------------------------------------------------------------------------------- /src/fastmcp/contrib/mcp_mixin/README.md: -------------------------------------------------------------------------------- 1 | # MCP Mixin 2 | 3 | This module provides the `MCPMixin` base class and associated decorators (`@mcp_tool`, `@mcp_resource`, `@mcp_prompt`). 4 | 5 | It allows developers to easily define classes whose methods can be registered as tools, resources, or prompts with a `FastMCP` server instance using the `register_all()`, `register_tools()`, `register_resources()`, or `register_prompts()` methods provided by the mixin. 6 | 7 | ## Usage 8 | 9 | Inherit from `MCPMixin` and use the decorators on the methods you want to register. 10 | 11 | ```python 12 | from fastmcp import FastMCP 13 | from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool, mcp_resource 14 | 15 | class MyComponent(MCPMixin): 16 | @mcp_tool(name="my_tool", description="Does something cool.") 17 | def tool_method(self): 18 | return "Tool executed!" 19 | 20 | @mcp_resource(uri="component://data") 21 | def resource_method(self): 22 | return {"data": "some data"} 23 | 24 | mcp_server = FastMCP() 25 | component = MyComponent() 26 | 27 | # Register all decorated methods with a prefix 28 | # Useful if you will have multiple instantiated objects of the same class 29 | # and want to avoid name collisions. 30 | component.register_all(mcp_server, prefix="my_comp") 31 | 32 | # Register without a prefix 33 | # component.register_all(mcp_server) 34 | 35 | # Now 'my_comp_my_tool' tool and 'my_comp+component://data' resource are registered (if prefix used) 36 | # Or 'my_tool' and 'component://data' are registered (if no prefix used) 37 | ``` 38 | 39 | The `prefix` argument in registration methods is optional. If omitted, methods are registered with their original decorated names/URIs. Individual separators (`tools_separator`, `resources_separator`, `prompts_separator`) can also be provided to `register_all` to change the separator for specific types. -------------------------------------------------------------------------------- /src/fastmcp/contrib/mcp_mixin/__init__.py: -------------------------------------------------------------------------------- 1 | from .mcp_mixin import MCPMixin, mcp_tool, mcp_resource, mcp_prompt 2 | 3 | __all__ = [ 4 | "MCPMixin", 5 | "mcp_tool", 6 | "mcp_resource", 7 | "mcp_prompt", 8 | ] 9 | -------------------------------------------------------------------------------- /src/fastmcp/contrib/mcp_mixin/example.py: -------------------------------------------------------------------------------- 1 | """Sample code for FastMCP using MCPMixin.""" 2 | 3 | import asyncio 4 | 5 | from fastmcp import FastMCP 6 | from fastmcp.contrib.mcp_mixin import ( 7 | MCPMixin, 8 | mcp_prompt, 9 | mcp_resource, 10 | mcp_tool, 11 | ) 12 | 13 | mcp = FastMCP() 14 | 15 | 16 | class Sample(MCPMixin): 17 | def __init__(self, name): 18 | self.name = name 19 | 20 | @mcp_tool() 21 | def first_tool(self): 22 | """First tool description.""" 23 | return f"Executed tool {self.name}." 24 | 25 | @mcp_resource(uri="test://test") 26 | def first_resource(self): 27 | """First resource description.""" 28 | return f"Executed resource {self.name}." 29 | 30 | @mcp_prompt() 31 | def first_prompt(self): 32 | """First prompt description.""" 33 | return f"here's a prompt! {self.name}." 34 | 35 | 36 | first_sample = Sample("First") 37 | second_sample = Sample("Second") 38 | 39 | first_sample.register_all(mcp_server=mcp, prefix="first") 40 | second_sample.register_all(mcp_server=mcp, prefix="second") 41 | 42 | 43 | async def list_components(): 44 | print("MCP Server running with registered components...") 45 | print("Tools:", list(await mcp.get_tools())) 46 | print("Resources:", list(await mcp.get_resources())) 47 | print("Prompts:", list(await mcp.get_prompts())) 48 | 49 | 50 | if __name__ == "__main__": 51 | asyncio.run(list_components()) 52 | mcp.run() 53 | -------------------------------------------------------------------------------- /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 PromptError(FastMCPError): 21 | """Error in prompt operations.""" 22 | 23 | 24 | class InvalidSignature(Exception): 25 | """Invalid signature for use with FastMCP.""" 26 | 27 | 28 | class ClientError(Exception): 29 | """Error in client operations.""" 30 | 31 | 32 | class NotFoundError(Exception): 33 | """Object not found.""" 34 | -------------------------------------------------------------------------------- /src/fastmcp/prompts/__init__.py: -------------------------------------------------------------------------------- 1 | from .prompt import Prompt, Message, UserMessage, AssistantMessage 2 | from .prompt_manager import PromptManager 3 | 4 | __all__ = ["Prompt", "PromptManager", "Message", "UserMessage", "AssistantMessage"] 5 | -------------------------------------------------------------------------------- /src/fastmcp/prompts/prompt.py: -------------------------------------------------------------------------------- 1 | """Base classes for FastMCP prompts.""" 2 | 3 | import inspect 4 | import json 5 | from collections.abc import Awaitable, Callable, Sequence 6 | from typing import Annotated, Any, Literal 7 | 8 | import pydantic_core 9 | from mcp.types import EmbeddedResource, ImageContent, TextContent 10 | from mcp.types import Prompt as MCPPrompt 11 | from mcp.types import PromptArgument as MCPPromptArgument 12 | from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call 13 | 14 | from fastmcp.utilities.types import _convert_set_defaults 15 | 16 | CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource 17 | 18 | 19 | class Message(BaseModel): 20 | """Base class for all prompt messages.""" 21 | 22 | role: Literal["user", "assistant"] 23 | content: CONTENT_TYPES 24 | 25 | def __init__(self, content: str | CONTENT_TYPES, **kwargs: Any): 26 | if isinstance(content, str): 27 | content = TextContent(type="text", text=content) 28 | super().__init__(content=content, **kwargs) 29 | 30 | 31 | def UserMessage(content: str | CONTENT_TYPES, **kwargs: Any) -> Message: 32 | """A message from the user.""" 33 | return Message(content=content, role="user", **kwargs) 34 | 35 | 36 | def AssistantMessage(content: str | CONTENT_TYPES, **kwargs: Any) -> Message: 37 | """A message from the assistant.""" 38 | return Message(content=content, role="assistant", **kwargs) 39 | 40 | 41 | message_validator = TypeAdapter[Message](Message) 42 | 43 | SyncPromptResult = ( 44 | str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]] 45 | ) 46 | PromptResult = SyncPromptResult | Awaitable[SyncPromptResult] 47 | 48 | 49 | class PromptArgument(BaseModel): 50 | """An argument that can be passed to a prompt.""" 51 | 52 | name: str = Field(description="Name of the argument") 53 | description: str | None = Field( 54 | None, description="Description of what the argument does" 55 | ) 56 | required: bool = Field( 57 | default=False, description="Whether the argument is required" 58 | ) 59 | 60 | 61 | class Prompt(BaseModel): 62 | """A prompt template that can be rendered with parameters.""" 63 | 64 | name: str = Field(description="Name of the prompt") 65 | description: str | None = Field( 66 | None, description="Description of what the prompt does" 67 | ) 68 | tags: Annotated[set[str], BeforeValidator(_convert_set_defaults)] = Field( 69 | default_factory=set, description="Tags for the prompt" 70 | ) 71 | arguments: list[PromptArgument] | None = Field( 72 | None, description="Arguments that can be passed to the prompt" 73 | ) 74 | fn: Callable[..., PromptResult | Awaitable[PromptResult]] 75 | 76 | @classmethod 77 | def from_function( 78 | cls, 79 | fn: Callable[..., PromptResult | Awaitable[PromptResult]], 80 | name: str | None = None, 81 | description: str | None = None, 82 | tags: set[str] | None = None, 83 | ) -> "Prompt": 84 | """Create a Prompt from a function. 85 | 86 | The function can return: 87 | - A string (converted to a message) 88 | - A Message object 89 | - A dict (converted to a message) 90 | - A sequence of any of the above 91 | """ 92 | func_name = name or fn.__name__ 93 | 94 | if func_name == "": 95 | raise ValueError("You must provide a name for lambda functions") 96 | 97 | # Get schema from TypeAdapter - will fail if function isn't properly typed 98 | parameters = TypeAdapter(fn).json_schema() 99 | 100 | # Convert parameters to PromptArguments 101 | arguments: list[PromptArgument] = [] 102 | if "properties" in parameters: 103 | for param_name, param in parameters["properties"].items(): 104 | required = param_name in parameters.get("required", []) 105 | arguments.append( 106 | PromptArgument( 107 | name=param_name, 108 | description=param.get("description"), 109 | required=required, 110 | ) 111 | ) 112 | 113 | # ensure the arguments are properly cast 114 | fn = validate_call(fn) 115 | 116 | return cls( 117 | name=func_name, 118 | description=description or fn.__doc__, 119 | arguments=arguments, 120 | fn=fn, 121 | tags=tags or set(), 122 | ) 123 | 124 | async def render(self, arguments: dict[str, Any] | None = None) -> list[Message]: 125 | """Render the prompt with arguments.""" 126 | # Validate required arguments 127 | if self.arguments: 128 | required = {arg.name for arg in self.arguments if arg.required} 129 | provided = set(arguments or {}) 130 | missing = required - provided 131 | if missing: 132 | raise ValueError(f"Missing required arguments: {missing}") 133 | 134 | try: 135 | # Call function and check if result is a coroutine 136 | result = self.fn(**(arguments or {})) 137 | if inspect.iscoroutine(result): 138 | result = await result 139 | 140 | # Validate messages 141 | if not isinstance(result, list | tuple): 142 | result = [result] 143 | 144 | # Convert result to messages 145 | messages: list[Message] = [] 146 | for msg in result: # type: ignore[reportUnknownVariableType] 147 | try: 148 | if isinstance(msg, Message): 149 | messages.append(msg) 150 | elif isinstance(msg, dict): 151 | messages.append(message_validator.validate_python(msg)) 152 | elif isinstance(msg, str): 153 | content = TextContent(type="text", text=msg) 154 | messages.append(Message(role="user", content=content)) 155 | else: 156 | content = json.dumps(pydantic_core.to_jsonable_python(msg)) 157 | messages.append(Message(role="user", content=content)) 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 | 167 | def __eq__(self, other: object) -> bool: 168 | if not isinstance(other, Prompt): 169 | return False 170 | return self.model_dump() == other.model_dump() 171 | 172 | def to_mcp_prompt(self, **overrides: Any) -> MCPPrompt: 173 | """Convert the prompt to an MCP prompt.""" 174 | arguments = [ 175 | MCPPromptArgument( 176 | name=arg.name, 177 | description=arg.description, 178 | required=arg.required, 179 | ) 180 | for arg in self.arguments or [] 181 | ] 182 | kwargs = { 183 | "name": self.name, 184 | "description": self.description, 185 | "arguments": arguments, 186 | } 187 | return MCPPrompt(**kwargs | overrides) 188 | -------------------------------------------------------------------------------- /src/fastmcp/prompts/prompt_manager.py: -------------------------------------------------------------------------------- 1 | """Prompt management functionality.""" 2 | 3 | from collections.abc import Awaitable, Callable 4 | from typing import Any 5 | 6 | from fastmcp.exceptions import NotFoundError 7 | from fastmcp.prompts.prompt import Message, Prompt, PromptResult 8 | from fastmcp.settings import DuplicateBehavior 9 | from fastmcp.utilities.logging import get_logger 10 | 11 | logger = get_logger(__name__) 12 | 13 | 14 | class PromptManager: 15 | """Manages FastMCP prompts.""" 16 | 17 | def __init__(self, duplicate_behavior: DuplicateBehavior | None = None): 18 | self._prompts: dict[str, Prompt] = {} 19 | 20 | # Default to "warn" if None is provided 21 | if duplicate_behavior is None: 22 | duplicate_behavior = "warn" 23 | 24 | if duplicate_behavior not in DuplicateBehavior.__args__: 25 | raise ValueError( 26 | f"Invalid duplicate_behavior: {duplicate_behavior}. " 27 | f"Must be one of: {', '.join(DuplicateBehavior.__args__)}" 28 | ) 29 | 30 | self.duplicate_behavior = duplicate_behavior 31 | 32 | def get_prompt(self, key: str) -> Prompt | None: 33 | """Get prompt by key.""" 34 | return self._prompts.get(key) 35 | 36 | def get_prompts(self) -> dict[str, Prompt]: 37 | """Get all registered prompts, indexed by registered key.""" 38 | return self._prompts 39 | 40 | def add_prompt_from_fn( 41 | self, 42 | fn: Callable[..., PromptResult | Awaitable[PromptResult]], 43 | name: str | None = None, 44 | description: str | None = None, 45 | tags: set[str] | None = None, 46 | ) -> Prompt: 47 | """Create a prompt from a function.""" 48 | prompt = Prompt.from_function(fn, name=name, description=description, tags=tags) 49 | return self.add_prompt(prompt) 50 | 51 | def add_prompt(self, prompt: Prompt, key: str | None = None) -> Prompt: 52 | """Add a prompt to the manager.""" 53 | key = key or prompt.name 54 | 55 | # Check for duplicates 56 | existing = self._prompts.get(key) 57 | if existing: 58 | if self.duplicate_behavior == "warn": 59 | logger.warning(f"Prompt already exists: {key}") 60 | self._prompts[key] = prompt 61 | elif self.duplicate_behavior == "replace": 62 | self._prompts[key] = prompt 63 | elif self.duplicate_behavior == "error": 64 | raise ValueError(f"Prompt already exists: {key}") 65 | elif self.duplicate_behavior == "ignore": 66 | return existing 67 | else: 68 | self._prompts[key] = prompt 69 | return prompt 70 | 71 | async def render_prompt( 72 | self, name: str, arguments: dict[str, Any] | None = None 73 | ) -> list[Message]: 74 | """Render a prompt by name with arguments.""" 75 | prompt = self.get_prompt(name) 76 | if not prompt: 77 | raise NotFoundError(f"Unknown prompt: {name}") 78 | 79 | return await prompt.render(arguments) 80 | 81 | def has_prompt(self, key: str) -> bool: 82 | """Check if a prompt exists.""" 83 | return key in self._prompts 84 | -------------------------------------------------------------------------------- /src/fastmcp/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlowin/fastmcp/a4e233711fda1e232e24fd586cfd11c495bd14fe/src/fastmcp/py.typed -------------------------------------------------------------------------------- /src/fastmcp/resources/__init__.py: -------------------------------------------------------------------------------- 1 | from .resource import Resource 2 | from .template import ResourceTemplate 3 | from .types import ( 4 | BinaryResource, 5 | DirectoryResource, 6 | FileResource, 7 | FunctionResource, 8 | HttpResource, 9 | TextResource, 10 | ) 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/resource.py: -------------------------------------------------------------------------------- 1 | """Base classes and interfaces for FastMCP resources.""" 2 | 3 | import abc 4 | from typing import Annotated, Any 5 | 6 | from mcp.types import Resource as MCPResource 7 | from pydantic import ( 8 | AnyUrl, 9 | BaseModel, 10 | BeforeValidator, 11 | ConfigDict, 12 | Field, 13 | UrlConstraints, 14 | ValidationInfo, 15 | field_validator, 16 | ) 17 | 18 | from fastmcp.utilities.types import _convert_set_defaults 19 | 20 | 21 | class Resource(BaseModel, abc.ABC): 22 | """Base class for all resources.""" 23 | 24 | model_config = ConfigDict(validate_default=True) 25 | 26 | uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field( 27 | default=..., description="URI of the resource" 28 | ) 29 | name: str | None = Field(description="Name of the resource", default=None) 30 | description: str | None = Field( 31 | description="Description of the resource", default=None 32 | ) 33 | tags: Annotated[set[str], BeforeValidator(_convert_set_defaults)] = Field( 34 | default_factory=set, description="Tags for the resource" 35 | ) 36 | mime_type: str = Field( 37 | default="text/plain", 38 | description="MIME type of the resource content", 39 | pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$", 40 | ) 41 | 42 | @field_validator("mime_type", mode="before") 43 | @classmethod 44 | def set_default_mime_type(cls, mime_type: str | None) -> str: 45 | """Set default MIME type if not provided.""" 46 | if mime_type: 47 | return mime_type 48 | return "text/plain" 49 | 50 | @field_validator("name", mode="before") 51 | @classmethod 52 | def set_default_name(cls, name: str | None, info: ValidationInfo) -> str: 53 | """Set default name from URI if not provided.""" 54 | if name: 55 | return name 56 | if uri := info.data.get("uri"): 57 | return str(uri) 58 | raise ValueError("Either name or uri must be provided") 59 | 60 | @abc.abstractmethod 61 | async def read(self) -> str | bytes: 62 | """Read the resource content.""" 63 | pass 64 | 65 | def __eq__(self, other: object) -> bool: 66 | if not isinstance(other, Resource): 67 | return False 68 | return self.model_dump() == other.model_dump() 69 | 70 | def to_mcp_resource(self, **overrides: Any) -> MCPResource: 71 | """Convert the resource to an MCPResource.""" 72 | kwargs = { 73 | "uri": self.uri, 74 | "name": self.name, 75 | "description": self.description, 76 | "mimeType": self.mime_type, 77 | } 78 | return MCPResource(**kwargs | overrides) 79 | -------------------------------------------------------------------------------- /src/fastmcp/resources/template.py: -------------------------------------------------------------------------------- 1 | """Resource template functionality.""" 2 | 3 | from __future__ import annotations 4 | 5 | import inspect 6 | import re 7 | from collections.abc import Callable 8 | from typing import Annotated, Any 9 | from urllib.parse import unquote 10 | 11 | from mcp.types import ResourceTemplate as MCPResourceTemplate 12 | from pydantic import ( 13 | AnyUrl, 14 | BaseModel, 15 | BeforeValidator, 16 | Field, 17 | TypeAdapter, 18 | field_validator, 19 | validate_call, 20 | ) 21 | 22 | from fastmcp.resources.types import FunctionResource, Resource 23 | from fastmcp.utilities.types import _convert_set_defaults 24 | 25 | 26 | def build_regex(template: str) -> re.Pattern: 27 | # Escape all non-brace characters, then restore {var} placeholders 28 | parts = re.split(r"(\{[^}]+\})", template) 29 | pattern = "" 30 | for part in parts: 31 | if part.startswith("{") and part.endswith("}"): 32 | name = part[1:-1] 33 | pattern += f"(?P<{name}>[^/]+)" 34 | else: 35 | pattern += re.escape(part) 36 | return re.compile(f"^{pattern}$") 37 | 38 | 39 | def match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None: 40 | regex = build_regex(uri_template) 41 | match = regex.match(uri) 42 | if match: 43 | return {k: unquote(v) for k, v in match.groupdict().items()} 44 | return None 45 | 46 | 47 | class MyModel(BaseModel): 48 | key: str 49 | value: int 50 | 51 | 52 | class ResourceTemplate(BaseModel): 53 | """A template for dynamically creating resources.""" 54 | 55 | uri_template: str = Field( 56 | description="URI template with parameters (e.g. weather://{city}/current)" 57 | ) 58 | name: str = Field(description="Name of the resource") 59 | description: str | None = Field(description="Description of what the resource does") 60 | tags: Annotated[set[str], BeforeValidator(_convert_set_defaults)] = Field( 61 | default_factory=set, description="Tags for the resource" 62 | ) 63 | mime_type: str = Field( 64 | default="text/plain", description="MIME type of the resource content" 65 | ) 66 | fn: Callable[..., Any] 67 | parameters: dict[str, Any] = Field( 68 | description="JSON schema for function parameters" 69 | ) 70 | 71 | @field_validator("mime_type", mode="before") 72 | @classmethod 73 | def set_default_mime_type(cls, mime_type: str | None) -> str: 74 | """Set default MIME type if not provided.""" 75 | if mime_type: 76 | return mime_type 77 | return "text/plain" 78 | 79 | @classmethod 80 | def from_function( 81 | cls, 82 | fn: Callable[..., Any], 83 | uri_template: str, 84 | name: str | None = None, 85 | description: str | None = None, 86 | mime_type: str | None = None, 87 | tags: set[str] | None = None, 88 | ) -> ResourceTemplate: 89 | """Create a template from a function.""" 90 | func_name = name or fn.__name__ 91 | if func_name == "": 92 | raise ValueError("You must provide a name for lambda functions") 93 | 94 | # Validate that URI params match function params 95 | uri_params = set(re.findall(r"{(\w+)}", uri_template)) 96 | if not uri_params: 97 | raise ValueError("URI template must contain at least one parameter") 98 | 99 | func_params = set(inspect.signature(fn).parameters.keys()) 100 | 101 | # get the parameters that are required 102 | required_params = { 103 | p 104 | for p in func_params 105 | if inspect.signature(fn).parameters[p].default is inspect.Parameter.empty 106 | } 107 | 108 | if not required_params.issubset(uri_params): 109 | raise ValueError( 110 | f"URI parameters {uri_params} must be a subset of the required function arguments: {required_params}" 111 | ) 112 | 113 | if not uri_params.issubset(func_params): 114 | raise ValueError( 115 | f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}" 116 | ) 117 | 118 | # Get schema from TypeAdapter - will fail if function isn't properly typed 119 | parameters = TypeAdapter(fn).json_schema() 120 | 121 | # ensure the arguments are properly cast 122 | fn = validate_call(fn) 123 | 124 | return cls( 125 | uri_template=uri_template, 126 | name=func_name, 127 | description=description or fn.__doc__ or "", 128 | mime_type=mime_type or "text/plain", 129 | fn=fn, 130 | parameters=parameters, 131 | tags=tags or set(), 132 | ) 133 | 134 | def matches(self, uri: str) -> dict[str, Any] | None: 135 | """Check if URI matches template and extract parameters.""" 136 | return match_uri_template(uri, self.uri_template) 137 | 138 | async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: 139 | """Create a resource from the template with the given parameters.""" 140 | try: 141 | # Call function and check if result is a coroutine 142 | result = self.fn(**params) 143 | if inspect.iscoroutine(result): 144 | result = await result 145 | 146 | return FunctionResource( 147 | uri=AnyUrl(uri), # Explicitly convert to AnyUrl 148 | name=self.name, 149 | description=self.description, 150 | mime_type=self.mime_type, 151 | fn=lambda: result, # Capture result in closure 152 | tags=self.tags, 153 | ) 154 | except Exception as e: 155 | raise ValueError(f"Error creating resource from template: {e}") 156 | 157 | def __eq__(self, other: object) -> bool: 158 | if not isinstance(other, ResourceTemplate): 159 | return False 160 | return self.model_dump() == other.model_dump() 161 | 162 | def to_mcp_template(self, **overrides: Any) -> MCPResourceTemplate: 163 | """Convert the resource template to an MCPResourceTemplate.""" 164 | kwargs = { 165 | "uriTemplate": self.uri_template, 166 | "name": self.name, 167 | "description": self.description, 168 | "mimeType": self.mime_type, 169 | } 170 | return MCPResourceTemplate(**kwargs | overrides) 171 | -------------------------------------------------------------------------------- /src/fastmcp/resources/types.py: -------------------------------------------------------------------------------- 1 | """Concrete resource implementations.""" 2 | 3 | import inspect 4 | import json 5 | from collections.abc import Callable 6 | from pathlib import Path 7 | from typing import Any 8 | 9 | import anyio 10 | import anyio.to_thread 11 | import httpx 12 | import pydantic.json 13 | import pydantic_core 14 | from pydantic import Field, ValidationInfo 15 | 16 | from fastmcp.resources.resource import Resource 17 | 18 | 19 | class TextResource(Resource): 20 | """A resource that reads from a string.""" 21 | 22 | text: str = Field(description="Text content of the resource") 23 | 24 | async def read(self) -> str: 25 | """Read the text content.""" 26 | return self.text 27 | 28 | 29 | class BinaryResource(Resource): 30 | """A resource that reads from bytes.""" 31 | 32 | data: bytes = Field(description="Binary content of the resource") 33 | 34 | async def read(self) -> bytes: 35 | """Read the binary content.""" 36 | return self.data 37 | 38 | 39 | class FunctionResource(Resource): 40 | """A resource that defers data loading by wrapping a function. 41 | 42 | The function is only called when the resource is read, allowing for lazy loading 43 | of potentially expensive data. This is particularly useful when listing resources, 44 | as the function won't be called until the resource is actually accessed. 45 | 46 | The function can return: 47 | - str for text content (default) 48 | - bytes for binary content 49 | - other types will be converted to JSON 50 | """ 51 | 52 | fn: Callable[[], Any] 53 | 54 | async def read(self) -> str | bytes: 55 | """Read the resource by calling the wrapped function.""" 56 | try: 57 | result = ( 58 | await self.fn() if inspect.iscoroutinefunction(self.fn) else self.fn() 59 | ) 60 | if isinstance(result, Resource): 61 | return await result.read() 62 | if isinstance(result, bytes): 63 | return result 64 | if isinstance(result, str): 65 | return result 66 | try: 67 | return json.dumps(pydantic_core.to_jsonable_python(result)) 68 | except (TypeError, pydantic_core.PydanticSerializationError): 69 | # If JSON serialization fails, try str() 70 | return str(result) 71 | except Exception as e: 72 | raise ValueError(f"Error reading resource {self.uri}: {e}") 73 | 74 | 75 | class FileResource(Resource): 76 | """A resource that reads from a file. 77 | 78 | Set is_binary=True to read file as binary data instead of text. 79 | """ 80 | 81 | path: Path = Field(description="Path to the file") 82 | is_binary: bool = Field( 83 | default=False, 84 | description="Whether to read the file as binary data", 85 | ) 86 | mime_type: str = Field( 87 | default="text/plain", 88 | description="MIME type of the resource content", 89 | ) 90 | 91 | @pydantic.field_validator("path") 92 | @classmethod 93 | def validate_absolute_path(cls, path: Path) -> Path: 94 | """Ensure path is absolute.""" 95 | if not path.is_absolute(): 96 | raise ValueError("Path must be absolute") 97 | return path 98 | 99 | @pydantic.field_validator("is_binary") 100 | @classmethod 101 | def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool: 102 | """Set is_binary based on mime_type if not explicitly set.""" 103 | if is_binary: 104 | return True 105 | mime_type = info.data.get("mime_type", "text/plain") 106 | return not mime_type.startswith("text/") 107 | 108 | async def read(self) -> str | bytes: 109 | """Read the file content.""" 110 | try: 111 | if self.is_binary: 112 | return await anyio.to_thread.run_sync(self.path.read_bytes) 113 | return await anyio.to_thread.run_sync(self.path.read_text) 114 | except Exception as e: 115 | raise ValueError(f"Error reading file {self.path}: {e}") 116 | 117 | 118 | class HttpResource(Resource): 119 | """A resource that reads from an HTTP endpoint.""" 120 | 121 | url: str = Field(description="URL to fetch content from") 122 | mime_type: str = Field( 123 | default="application/json", description="MIME type of the resource content" 124 | ) 125 | 126 | async def read(self) -> str | bytes: 127 | """Read the HTTP content.""" 128 | async with httpx.AsyncClient() as client: 129 | response = await client.get(self.url) 130 | response.raise_for_status() 131 | return response.text 132 | 133 | 134 | class DirectoryResource(Resource): 135 | """A resource that lists files in a directory.""" 136 | 137 | path: Path = Field(description="Path to the directory") 138 | recursive: bool = Field( 139 | default=False, description="Whether to list files recursively" 140 | ) 141 | pattern: str | None = Field( 142 | default=None, description="Optional glob pattern to filter files" 143 | ) 144 | mime_type: str = Field( 145 | default="application/json", description="MIME type of the resource content" 146 | ) 147 | 148 | @pydantic.field_validator("path") 149 | @classmethod 150 | def validate_absolute_path(cls, path: Path) -> Path: 151 | """Ensure path is absolute.""" 152 | if not path.is_absolute(): 153 | raise ValueError("Path must be absolute") 154 | return path 155 | 156 | def list_files(self) -> list[Path]: 157 | """List files in the directory.""" 158 | if not self.path.exists(): 159 | raise FileNotFoundError(f"Directory not found: {self.path}") 160 | if not self.path.is_dir(): 161 | raise NotADirectoryError(f"Not a directory: {self.path}") 162 | 163 | try: 164 | if self.pattern: 165 | return ( 166 | list(self.path.glob(self.pattern)) 167 | if not self.recursive 168 | else list(self.path.rglob(self.pattern)) 169 | ) 170 | return ( 171 | list(self.path.glob("*")) 172 | if not self.recursive 173 | else list(self.path.rglob("*")) 174 | ) 175 | except Exception as e: 176 | raise ValueError(f"Error listing directory {self.path}: {e}") 177 | 178 | async def read(self) -> str: # Always returns JSON string 179 | """Read the directory listing.""" 180 | try: 181 | files = await anyio.to_thread.run_sync(self.list_files) 182 | file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()] 183 | return json.dumps({"files": file_list}, indent=2) 184 | except Exception as e: 185 | raise ValueError(f"Error reading directory {self.path}: {e}") 186 | -------------------------------------------------------------------------------- /src/fastmcp/server/__init__.py: -------------------------------------------------------------------------------- 1 | from .server import FastMCP 2 | from .context import Context 3 | 4 | 5 | __all__ = ["FastMCP", "Context"] 6 | -------------------------------------------------------------------------------- /src/fastmcp/server/context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations as _annotations 2 | 3 | from typing import Any, Generic, Literal 4 | 5 | from mcp.server.lowlevel.helper_types import ReadResourceContents 6 | from mcp.server.session import ServerSessionT 7 | from mcp.shared.context import LifespanContextT, RequestContext 8 | from mcp.types import ( 9 | CreateMessageResult, 10 | ImageContent, 11 | Root, 12 | SamplingMessage, 13 | TextContent, 14 | ) 15 | from pydantic import BaseModel 16 | from pydantic.networks import AnyUrl 17 | 18 | from fastmcp.server.server import FastMCP 19 | from fastmcp.utilities.logging import get_logger 20 | 21 | logger = get_logger(__name__) 22 | 23 | 24 | class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]): 25 | """Context object providing access to MCP capabilities. 26 | 27 | This provides a cleaner interface to MCP's RequestContext functionality. 28 | It gets injected into tool and resource functions that request it via type hints. 29 | 30 | To use context in a tool function, add a parameter with the Context type annotation: 31 | 32 | ```python 33 | @server.tool() 34 | def my_tool(x: int, ctx: Context) -> str: 35 | # Log messages to the client 36 | ctx.info(f"Processing {x}") 37 | ctx.debug("Debug info") 38 | ctx.warning("Warning message") 39 | ctx.error("Error message") 40 | 41 | # Report progress 42 | ctx.report_progress(50, 100) 43 | 44 | # Access resources 45 | data = ctx.read_resource("resource://data") 46 | 47 | # Get request info 48 | request_id = ctx.request_id 49 | client_id = ctx.client_id 50 | 51 | return str(x) 52 | ``` 53 | 54 | The context parameter name can be anything as long as it's annotated with Context. 55 | The context is optional - tools that don't need it can omit the parameter. 56 | """ 57 | 58 | _request_context: RequestContext[ServerSessionT, LifespanContextT] | None 59 | _fastmcp: FastMCP | None 60 | 61 | def __init__( 62 | self, 63 | *, 64 | request_context: RequestContext[ServerSessionT, LifespanContextT] | None = None, 65 | fastmcp: FastMCP | None = None, 66 | **kwargs: Any, 67 | ): 68 | super().__init__(**kwargs) 69 | self._request_context = request_context 70 | self._fastmcp = fastmcp 71 | 72 | @property 73 | def fastmcp(self) -> FastMCP: 74 | """Access to the FastMCP server.""" 75 | if self._fastmcp is None: 76 | raise ValueError("Context is not available outside of a request") 77 | return self._fastmcp 78 | 79 | @property 80 | def request_context(self) -> RequestContext[ServerSessionT, LifespanContextT]: 81 | """Access to the underlying request context.""" 82 | if self._request_context is None: 83 | raise ValueError("Context is not available outside of a request") 84 | return self._request_context 85 | 86 | async def report_progress( 87 | self, progress: float, total: float | None = None 88 | ) -> None: 89 | """Report progress for the current operation. 90 | 91 | Args: 92 | progress: Current progress value e.g. 24 93 | total: Optional total value e.g. 100 94 | """ 95 | 96 | progress_token = ( 97 | self.request_context.meta.progressToken 98 | if self.request_context.meta 99 | else None 100 | ) 101 | 102 | if progress_token is None: 103 | return 104 | 105 | await self.request_context.session.send_progress_notification( 106 | progress_token=progress_token, progress=progress, total=total 107 | ) 108 | 109 | async def read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents]: 110 | """Read a resource by URI. 111 | 112 | Args: 113 | uri: Resource URI to read 114 | 115 | Returns: 116 | The resource content as either text or bytes 117 | """ 118 | assert self._fastmcp is not None, ( 119 | "Context is not available outside of a request" 120 | ) 121 | return await self._fastmcp._mcp_read_resource(uri) 122 | 123 | async def log( 124 | self, 125 | level: Literal["debug", "info", "warning", "error"], 126 | message: str, 127 | *, 128 | logger_name: str | None = None, 129 | ) -> None: 130 | """Send a log message to the client. 131 | 132 | Args: 133 | level: Log level (debug, info, warning, error) 134 | message: Log message 135 | logger_name: Optional logger name 136 | **extra: Additional structured data to include 137 | """ 138 | await self.request_context.session.send_log_message( 139 | level=level, data=message, logger=logger_name 140 | ) 141 | 142 | @property 143 | def client_id(self) -> str | None: 144 | """Get the client ID if available.""" 145 | return ( 146 | getattr(self.request_context.meta, "client_id", None) 147 | if self.request_context.meta 148 | else None 149 | ) 150 | 151 | @property 152 | def request_id(self) -> str: 153 | """Get the unique ID for this request.""" 154 | return str(self.request_context.request_id) 155 | 156 | @property 157 | def session(self): 158 | """Access to the underlying session for advanced usage.""" 159 | return self.request_context.session 160 | 161 | # Convenience methods for common log levels 162 | async def debug(self, message: str, **extra: Any) -> None: 163 | """Send a debug log message.""" 164 | await self.log("debug", message, **extra) 165 | 166 | async def info(self, message: str, **extra: Any) -> None: 167 | """Send an info log message.""" 168 | await self.log("info", message, **extra) 169 | 170 | async def warning(self, message: str, **extra: Any) -> None: 171 | """Send a warning log message.""" 172 | await self.log("warning", message, **extra) 173 | 174 | async def error(self, message: str, **extra: Any) -> None: 175 | """Send an error log message.""" 176 | await self.log("error", message, **extra) 177 | 178 | async def list_roots(self) -> list[Root]: 179 | """List the roots available to the server, as indicated by the client.""" 180 | result = await self.request_context.session.list_roots() 181 | return result.roots 182 | 183 | async def sample( 184 | self, 185 | messages: str | list[str | SamplingMessage], 186 | system_prompt: str | None = None, 187 | temperature: float | None = None, 188 | max_tokens: int | None = None, 189 | ) -> TextContent | ImageContent: 190 | """ 191 | Send a sampling request to the client and await the response. 192 | 193 | Call this method at any time to have the server request an LLM 194 | completion from the client. The client must be appropriately configured, 195 | or the request will error. 196 | """ 197 | 198 | if max_tokens is None: 199 | max_tokens = 512 200 | 201 | if isinstance(messages, str): 202 | sampling_messages = [ 203 | SamplingMessage( 204 | content=TextContent(text=messages, type="text"), role="user" 205 | ) 206 | ] 207 | elif isinstance(messages, list): 208 | sampling_messages = [ 209 | SamplingMessage(content=TextContent(text=m, type="text"), role="user") 210 | if isinstance(m, str) 211 | else m 212 | for m in messages 213 | ] 214 | 215 | result: CreateMessageResult = await self.request_context.session.create_message( 216 | messages=sampling_messages, 217 | system_prompt=system_prompt, 218 | temperature=temperature, 219 | max_tokens=max_tokens, 220 | ) 221 | 222 | return result.content 223 | -------------------------------------------------------------------------------- /src/fastmcp/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations as _annotations 2 | 3 | from typing import TYPE_CHECKING, Literal 4 | 5 | from pydantic import Field 6 | from pydantic_settings import BaseSettings, SettingsConfigDict 7 | 8 | if TYPE_CHECKING: 9 | pass 10 | 11 | LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] 12 | 13 | DuplicateBehavior = Literal["warn", "error", "replace", "ignore"] 14 | 15 | 16 | class Settings(BaseSettings): 17 | """FastMCP settings.""" 18 | 19 | model_config = SettingsConfigDict( 20 | env_prefix="FASTMCP_", 21 | env_file=".env", 22 | extra="ignore", 23 | ) 24 | 25 | test_mode: bool = False 26 | log_level: LOG_LEVEL = "INFO" 27 | 28 | 29 | class ServerSettings(BaseSettings): 30 | """FastMCP server settings. 31 | 32 | All settings can be configured via environment variables with the prefix FASTMCP_. 33 | For example, FASTMCP_DEBUG=true will set debug=True. 34 | """ 35 | 36 | model_config = SettingsConfigDict( 37 | env_prefix="FASTMCP_SERVER_", 38 | env_file=".env", 39 | extra="ignore", 40 | ) 41 | 42 | log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level) 43 | 44 | # HTTP settings 45 | host: str = "127.0.0.1" 46 | port: int = 8000 47 | sse_path: str = "/sse" 48 | message_path: str = "/messages/" 49 | debug: bool = False 50 | 51 | # resource settings 52 | on_duplicate_resources: DuplicateBehavior = "warn" 53 | 54 | # tool settings 55 | on_duplicate_tools: DuplicateBehavior = "warn" 56 | 57 | # prompt settings 58 | on_duplicate_prompts: DuplicateBehavior = "warn" 59 | 60 | dependencies: list[str] = Field( 61 | default_factory=list, 62 | description="List of dependencies to install in the server environment", 63 | ) 64 | 65 | # cache settings (for checking mounted servers) 66 | cache_expiration_seconds: float = 0 67 | 68 | 69 | class ClientSettings(BaseSettings): 70 | """FastMCP client settings.""" 71 | 72 | model_config = SettingsConfigDict( 73 | env_prefix="FASTMCP_CLIENT_", 74 | env_file=".env", 75 | extra="ignore", 76 | ) 77 | 78 | log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level) 79 | -------------------------------------------------------------------------------- /src/fastmcp/tools/__init__.py: -------------------------------------------------------------------------------- 1 | from .tool import Tool 2 | from .tool_manager import ToolManager 3 | 4 | __all__ = ["Tool", "ToolManager"] 5 | -------------------------------------------------------------------------------- /src/fastmcp/tools/tool.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | import json 5 | from collections.abc import Callable 6 | from typing import TYPE_CHECKING, Annotated, Any 7 | 8 | import pydantic_core 9 | from mcp.types import EmbeddedResource, ImageContent, TextContent 10 | from mcp.types import Tool as MCPTool 11 | from pydantic import BaseModel, BeforeValidator, Field 12 | 13 | from fastmcp.exceptions import ToolError 14 | from fastmcp.utilities.func_metadata import FuncMetadata, func_metadata 15 | from fastmcp.utilities.types import Image, _convert_set_defaults 16 | 17 | if TYPE_CHECKING: 18 | from mcp.server.session import ServerSessionT 19 | from mcp.shared.context import LifespanContextT 20 | 21 | from fastmcp.server import Context 22 | 23 | 24 | class Tool(BaseModel): 25 | """Internal tool registration info.""" 26 | 27 | fn: Callable[..., Any] 28 | name: str = Field(description="Name of the tool") 29 | description: str = Field(description="Description of what the tool does") 30 | parameters: dict[str, Any] = Field(description="JSON schema for tool parameters") 31 | fn_metadata: FuncMetadata = Field( 32 | description="Metadata about the function including a pydantic model for tool" 33 | " arguments" 34 | ) 35 | is_async: bool = Field(description="Whether the tool is async") 36 | context_kwarg: str | None = Field( 37 | None, description="Name of the kwarg that should receive context" 38 | ) 39 | tags: Annotated[set[str], BeforeValidator(_convert_set_defaults)] = Field( 40 | default_factory=set, description="Tags for the tool" 41 | ) 42 | 43 | @classmethod 44 | def from_function( 45 | cls, 46 | fn: Callable[..., Any], 47 | name: str | None = None, 48 | description: str | None = None, 49 | context_kwarg: str | None = None, 50 | tags: set[str] | None = None, 51 | ) -> Tool: 52 | """Create a Tool from a function.""" 53 | from fastmcp import Context 54 | 55 | func_name = name or fn.__name__ 56 | 57 | if func_name == "": 58 | raise ValueError("You must provide a name for lambda functions") 59 | 60 | func_doc = description or fn.__doc__ or "" 61 | is_async = inspect.iscoroutinefunction(fn) 62 | 63 | if context_kwarg is None: 64 | if inspect.ismethod(fn) and hasattr(fn, "__func__"): 65 | sig = inspect.signature(fn.__func__) 66 | else: 67 | sig = inspect.signature(fn) 68 | for param_name, param in sig.parameters.items(): 69 | if param.annotation is Context: 70 | context_kwarg = param_name 71 | break 72 | 73 | # Use callable typing to ensure fn is treated as a callable despite being a classmethod 74 | fn_callable: Callable[..., Any] = fn 75 | func_arg_metadata = func_metadata( 76 | fn_callable, 77 | skip_names=[context_kwarg] if context_kwarg is not None else [], 78 | ) 79 | parameters = func_arg_metadata.arg_model.model_json_schema() 80 | 81 | return cls( 82 | fn=fn_callable, 83 | name=func_name, 84 | description=func_doc, 85 | parameters=parameters, 86 | fn_metadata=func_arg_metadata, 87 | is_async=is_async, 88 | context_kwarg=context_kwarg, 89 | tags=tags or set(), 90 | ) 91 | 92 | async def run( 93 | self, 94 | arguments: dict[str, Any], 95 | context: Context[ServerSessionT, LifespanContextT] | None = None, 96 | ) -> list[TextContent | ImageContent | EmbeddedResource]: 97 | """Run the tool with arguments.""" 98 | try: 99 | result = await self.fn_metadata.call_fn_with_arg_validation( 100 | self.fn, 101 | self.is_async, 102 | arguments, 103 | {self.context_kwarg: context} 104 | if self.context_kwarg is not None 105 | else None, 106 | ) 107 | return _convert_to_content(result) 108 | except Exception as e: 109 | raise ToolError(f"Error executing tool {self.name}: {e}") from e 110 | 111 | def to_mcp_tool(self, **overrides: Any) -> MCPTool: 112 | kwargs = { 113 | "name": self.name, 114 | "description": self.description, 115 | "inputSchema": self.parameters, 116 | } 117 | return MCPTool(**kwargs | overrides) 118 | 119 | def __eq__(self, other: object) -> bool: 120 | if not isinstance(other, Tool): 121 | return False 122 | return self.model_dump() == other.model_dump() 123 | 124 | 125 | def _convert_to_content( 126 | result: Any, 127 | _process_as_single_item: bool = False, 128 | ) -> list[TextContent | ImageContent | EmbeddedResource]: 129 | """Convert a result to a sequence of content objects.""" 130 | if result is None: 131 | return [] 132 | 133 | if isinstance(result, TextContent | ImageContent | EmbeddedResource): 134 | return [result] 135 | 136 | if isinstance(result, Image): 137 | return [result.to_image_content()] 138 | 139 | if isinstance(result, list | tuple) and not _process_as_single_item: 140 | # if the result is a list, then it could either be a list of MCP types, 141 | # or a "regular" list that the tool is returning, or a mix of both. 142 | # 143 | # so we extract all the MCP types / images and convert them as individual content elements, 144 | # and aggregate the rest as a single content element 145 | 146 | mcp_types = [] 147 | other_content = [] 148 | 149 | for item in result: 150 | if isinstance(item, TextContent | ImageContent | EmbeddedResource | Image): 151 | mcp_types.append(_convert_to_content(item)[0]) 152 | else: 153 | other_content.append(item) 154 | if other_content: 155 | other_content = _convert_to_content( 156 | other_content, _process_as_single_item=True 157 | ) 158 | 159 | return other_content + mcp_types 160 | 161 | if not isinstance(result, str): 162 | try: 163 | result = json.dumps(pydantic_core.to_jsonable_python(result)) 164 | except Exception: 165 | result = str(result) 166 | 167 | return [TextContent(type="text", text=result)] 168 | -------------------------------------------------------------------------------- /src/fastmcp/tools/tool_manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations as _annotations 2 | 3 | from collections.abc import Callable 4 | from typing import TYPE_CHECKING, Any 5 | 6 | from mcp.shared.context import LifespanContextT 7 | from mcp.types import EmbeddedResource, ImageContent, TextContent 8 | 9 | from fastmcp.exceptions import NotFoundError 10 | from fastmcp.settings import DuplicateBehavior 11 | from fastmcp.tools.tool import Tool 12 | from fastmcp.utilities.logging import get_logger 13 | 14 | if TYPE_CHECKING: 15 | from mcp.server.session import ServerSessionT 16 | 17 | from fastmcp.server import Context 18 | 19 | logger = get_logger(__name__) 20 | 21 | 22 | class ToolManager: 23 | """Manages FastMCP tools.""" 24 | 25 | def __init__(self, duplicate_behavior: DuplicateBehavior | None = None): 26 | self._tools: dict[str, Tool] = {} 27 | 28 | # Default to "warn" if None is provided 29 | if duplicate_behavior is None: 30 | duplicate_behavior = "warn" 31 | 32 | if duplicate_behavior not in DuplicateBehavior.__args__: 33 | raise ValueError( 34 | f"Invalid duplicate_behavior: {duplicate_behavior}. " 35 | f"Must be one of: {', '.join(DuplicateBehavior.__args__)}" 36 | ) 37 | 38 | self.duplicate_behavior = duplicate_behavior 39 | 40 | def has_tool(self, key: str) -> bool: 41 | """Check if a tool exists.""" 42 | return key in self._tools 43 | 44 | def get_tool(self, key: str) -> Tool: 45 | """Get tool by key.""" 46 | if key in self._tools: 47 | return self._tools[key] 48 | raise NotFoundError(f"Unknown tool: {key}") 49 | 50 | def get_tools(self) -> dict[str, Tool]: 51 | """Get all registered tools, indexed by registered key.""" 52 | return self._tools 53 | 54 | def list_tools(self) -> list[Tool]: 55 | """List all registered tools.""" 56 | return list(self.get_tools().values()) 57 | 58 | def add_tool_from_fn( 59 | self, 60 | fn: Callable[..., Any], 61 | name: str | None = None, 62 | description: str | None = None, 63 | tags: set[str] | None = None, 64 | ) -> Tool: 65 | """Add a tool to the server.""" 66 | tool = Tool.from_function(fn, name=name, description=description, tags=tags) 67 | return self.add_tool(tool) 68 | 69 | def add_tool(self, tool: Tool, key: str | None = None) -> Tool: 70 | """Register a tool with the server.""" 71 | key = key or tool.name 72 | existing = self._tools.get(key) 73 | if existing: 74 | if self.duplicate_behavior == "warn": 75 | logger.warning(f"Tool already exists: {key}") 76 | self._tools[key] = tool 77 | elif self.duplicate_behavior == "replace": 78 | self._tools[key] = tool 79 | elif self.duplicate_behavior == "error": 80 | raise ValueError(f"Tool already exists: {key}") 81 | elif self.duplicate_behavior == "ignore": 82 | return existing 83 | else: 84 | self._tools[key] = tool 85 | return tool 86 | 87 | async def call_tool( 88 | self, 89 | key: str, 90 | arguments: dict[str, Any], 91 | context: Context[ServerSessionT, LifespanContextT] | None = None, 92 | ) -> list[TextContent | ImageContent | EmbeddedResource]: 93 | """Call a tool by name with arguments.""" 94 | tool = self.get_tool(key) 95 | if not tool: 96 | raise NotFoundError(f"Unknown tool: {key}") 97 | 98 | return await tool.run(arguments, context=context) 99 | -------------------------------------------------------------------------------- /src/fastmcp/utilities/__init__.py: -------------------------------------------------------------------------------- 1 | """FastMCP utility modules.""" 2 | -------------------------------------------------------------------------------- /src/fastmcp/utilities/decorators.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from collections.abc import Callable 3 | from typing import Generic, ParamSpec, TypeVar, cast, overload 4 | 5 | from typing_extensions import Self 6 | 7 | R = TypeVar("R") 8 | P = ParamSpec("P") 9 | 10 | 11 | class DecoratedFunction(Generic[P, R]): 12 | """Descriptor for decorated functions. 13 | 14 | You can return this object from a decorator to ensure that it works across 15 | all types of functions: vanilla, instance methods, class methods, and static 16 | methods; both synchronous and asynchronous. 17 | 18 | This class is used to store the original function and metadata about how to 19 | register it as a tool. 20 | 21 | Example usage: 22 | 23 | ```python 24 | def my_decorator(fn: Callable[P, R]) -> DecoratedFunction[P, R]: 25 | return DecoratedFunction(fn) 26 | ``` 27 | 28 | On a function: 29 | ```python 30 | @my_decorator 31 | def my_function(a: int, b: int) -> int: 32 | return a + b 33 | ``` 34 | 35 | On an instance method: 36 | ```python 37 | class Test: 38 | @my_decorator 39 | def my_function(self, a: int, b: int) -> int: 40 | return a + b 41 | ``` 42 | 43 | On a class method: 44 | ```python 45 | class Test: 46 | @classmethod 47 | @my_decorator 48 | def my_function(cls, a: int, b: int) -> int: 49 | return a + b 50 | ``` 51 | 52 | Note that for classmethods, the decorator must be applied first, then 53 | `@classmethod` on top. 54 | 55 | On a static method: 56 | ```python 57 | class Test: 58 | @staticmethod 59 | @my_decorator 60 | def my_function(a: int, b: int) -> int: 61 | return a + b 62 | ``` 63 | """ 64 | 65 | def __init__(self, fn: Callable[P, R]): 66 | self.fn = fn 67 | 68 | def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: 69 | """Call the original function.""" 70 | try: 71 | return self.fn(*args, **kwargs) 72 | except TypeError as e: 73 | if "'classmethod' object is not callable" in str(e): 74 | raise TypeError( 75 | "To apply this decorator to a classmethod, apply the decorator first, then @classmethod on top." 76 | ) 77 | raise 78 | 79 | @overload 80 | def __get__(self, instance: None, owner: type | None = None) -> Self: ... 81 | 82 | @overload 83 | def __get__( 84 | self, instance: object, owner: type | None = None 85 | ) -> Callable[P, R]: ... 86 | 87 | def __get__( 88 | self, instance: object | None, owner: type | None = None 89 | ) -> Self | Callable[P, R]: 90 | """Return the original function when accessed from an instance, or self when accessed from the class.""" 91 | if instance is None: 92 | return self 93 | # Return the original function bound to the instance 94 | return cast(Callable[P, R], self.fn.__get__(instance, owner)) 95 | 96 | def __repr__(self) -> str: 97 | """Return a representation that matches Python's function representation.""" 98 | module = getattr(self.fn, "__module__", "unknown") 99 | qualname = getattr(self.fn, "__qualname__", str(self.fn)) 100 | sig_str = str(inspect.signature(self.fn)) 101 | return f"" 102 | -------------------------------------------------------------------------------- /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"] | int = "INFO", 24 | ) -> None: 25 | """Configure logging for FastMCP. 26 | 27 | Args: 28 | level: the log level to use 29 | """ 30 | # Only configure the FastMCP logger namespace 31 | handler = RichHandler(console=Console(stderr=True), rich_tracebacks=True) 32 | formatter = logging.Formatter("%(message)s") 33 | handler.setFormatter(formatter) 34 | 35 | fastmcp_logger = logging.getLogger("FastMCP") 36 | fastmcp_logger.setLevel(level) 37 | 38 | # Remove any existing handlers to avoid duplicates on reconfiguration 39 | for hdlr in fastmcp_logger.handlers[:]: 40 | fastmcp_logger.removeHandler(hdlr) 41 | 42 | fastmcp_logger.addHandler(handler) 43 | -------------------------------------------------------------------------------- /src/fastmcp/utilities/types.py: -------------------------------------------------------------------------------- 1 | """Common types used across FastMCP.""" 2 | 3 | import base64 4 | from pathlib import Path 5 | from typing import TypeVar 6 | 7 | from mcp.types import ImageContent 8 | 9 | T = TypeVar("T") 10 | 11 | 12 | def _convert_set_defaults(maybe_set: set[T] | list[T] | None) -> set[T]: 13 | """Convert a set or list to a set, defaulting to an empty set if None.""" 14 | if maybe_set is None: 15 | return set() 16 | if isinstance(maybe_set, set): 17 | return maybe_set 18 | return set(maybe_set) 19 | 20 | 21 | class Image: 22 | """Helper class for returning images from tools.""" 23 | 24 | def __init__( 25 | self, 26 | path: str | Path | None = None, 27 | data: bytes | None = None, 28 | format: str | None = None, 29 | ): 30 | if path is None and data is None: 31 | raise ValueError("Either path or data must be provided") 32 | if path is not None and data is not None: 33 | raise ValueError("Only one of path or data can be provided") 34 | 35 | self.path = Path(path) if path else None 36 | self.data = data 37 | self._format = format 38 | self._mime_type = self._get_mime_type() 39 | 40 | def _get_mime_type(self) -> str: 41 | """Get MIME type from format or guess from file extension.""" 42 | if self._format: 43 | return f"image/{self._format.lower()}" 44 | 45 | if self.path: 46 | suffix = self.path.suffix.lower() 47 | return { 48 | ".png": "image/png", 49 | ".jpg": "image/jpeg", 50 | ".jpeg": "image/jpeg", 51 | ".gif": "image/gif", 52 | ".webp": "image/webp", 53 | }.get(suffix, "application/octet-stream") 54 | return "image/png" # default for raw binary data 55 | 56 | def to_image_content(self) -> ImageContent: 57 | """Convert to MCP ImageContent.""" 58 | if self.path: 59 | with open(self.path, "rb") as f: 60 | data = base64.b64encode(f.read()).decode() 61 | elif self.data is not None: 62 | data = base64.b64encode(self.data).decode() 63 | else: 64 | raise ValueError("No image data available") 65 | 66 | return ImageContent(type="image", data=data, mimeType=self._mime_type) 67 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlowin/fastmcp/a4e233711fda1e232e24fd586cfd11c495bd14fe/tests/__init__.py -------------------------------------------------------------------------------- /tests/cli/test_run.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture 5 | def server_file(tmp_path): 6 | """Create a simple server file for testing""" 7 | server_path = tmp_path / "test_server.py" 8 | server_path.write_text( 9 | """ 10 | from fastmcp import FastMCP 11 | 12 | mcp = FastMCP(name="TestServer") 13 | 14 | @mcp.tool() 15 | def hello(name: str) -> str: 16 | return f"Hello, {name}!" 17 | 18 | if __name__ == "__main__": 19 | mcp.run() 20 | """ 21 | ) 22 | return server_path 23 | -------------------------------------------------------------------------------- /tests/client/__init__.py: -------------------------------------------------------------------------------- 1 | """Client tests package.""" 2 | -------------------------------------------------------------------------------- /tests/client/test_roots.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from mcp.types import TextContent 5 | 6 | from fastmcp import Client, Context, FastMCP 7 | 8 | 9 | @pytest.fixture 10 | def fastmcp_server(): 11 | mcp = FastMCP() 12 | 13 | @mcp.tool() 14 | async def list_roots(context: Context) -> list[str]: 15 | roots = await context.list_roots() 16 | return [str(r.uri) for r in roots] 17 | 18 | return mcp 19 | 20 | 21 | class TestClientRoots: 22 | @pytest.mark.parametrize("roots", [["x"], ["x", "y"]]) 23 | async def test_invalid_roots(self, fastmcp_server: FastMCP, roots: list[str]): 24 | """ 25 | Roots must be URIs 26 | """ 27 | with pytest.raises(ValueError, match="Input should be a valid URL"): 28 | async with Client(fastmcp_server, roots=roots): 29 | pass 30 | 31 | @pytest.mark.parametrize("roots", [["https://x.com"]]) 32 | async def test_invalid_urls(self, fastmcp_server: FastMCP, roots: list[str]): 33 | """ 34 | At this time, root URIs must start with file:// 35 | """ 36 | with pytest.raises(ValueError, match="URL scheme should be 'file'"): 37 | async with Client(fastmcp_server, roots=roots): 38 | pass 39 | 40 | @pytest.mark.parametrize("roots", [["file://x/y/z", "file://x/y/z"]]) 41 | async def test_valid_roots(self, fastmcp_server: FastMCP, roots: list[str]): 42 | async with Client(fastmcp_server, roots=roots) as client: 43 | result = await client.call_tool("list_roots", {}) 44 | assert isinstance(result[0], TextContent) 45 | assert json.loads(result[0].text) == [ 46 | "file://x/y/z", 47 | "file://x/y/z", 48 | ] 49 | -------------------------------------------------------------------------------- /tests/client/test_sampling.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | import pytest 4 | from mcp.types import TextContent 5 | 6 | from fastmcp import Client, Context, FastMCP 7 | from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams 8 | 9 | 10 | @pytest.fixture 11 | def fastmcp_server(): 12 | mcp = FastMCP() 13 | 14 | @mcp.tool() 15 | async def simple_sample(message: str, context: Context) -> str: 16 | result = await context.sample("Hello, world!") 17 | return cast(TextContent, result).text 18 | 19 | @mcp.tool() 20 | async def sample_with_system_prompt(message: str, context: Context) -> str: 21 | result = await context.sample("Hello, world!", system_prompt="You love FastMCP") 22 | return cast(TextContent, result).text 23 | 24 | @mcp.tool() 25 | async def sample_with_messages(message: str, context: Context) -> str: 26 | result = await context.sample( 27 | [ 28 | "Hello!", 29 | SamplingMessage( 30 | content=TextContent( 31 | type="text", text="How can I assist you today?" 32 | ), 33 | role="assistant", 34 | ), 35 | ] 36 | ) 37 | return cast(TextContent, result).text 38 | 39 | return mcp 40 | 41 | 42 | async def test_simple_sampling(fastmcp_server: FastMCP): 43 | def sampling_handler( 44 | messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext 45 | ) -> str: 46 | return "This is the sample message!" 47 | 48 | async with Client(fastmcp_server, sampling_handler=sampling_handler) as client: 49 | result = await client.call_tool("simple_sample", {"message": "Hello, world!"}) 50 | reply = cast(TextContent, result[0]) 51 | assert reply.text == "This is the sample message!" 52 | 53 | 54 | async def test_sampling_with_system_prompt(fastmcp_server: FastMCP): 55 | def sampling_handler( 56 | messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext 57 | ) -> str: 58 | assert params.systemPrompt is not None 59 | return params.systemPrompt 60 | 61 | async with Client(fastmcp_server, sampling_handler=sampling_handler) as client: 62 | result = await client.call_tool( 63 | "sample_with_system_prompt", {"message": "Hello, world!"} 64 | ) 65 | reply = cast(TextContent, result[0]) 66 | assert reply.text == "You love FastMCP" 67 | 68 | 69 | async def test_sampling_with_messages(fastmcp_server: FastMCP): 70 | def sampling_handler( 71 | messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext 72 | ) -> str: 73 | assert len(messages) == 2 74 | assert messages[0].content.type == "text" 75 | assert messages[0].content.text == "Hello!" 76 | assert messages[1].content.type == "text" 77 | assert messages[1].content.text == "How can I assist you today?" 78 | return "I need to think." 79 | 80 | async with Client(fastmcp_server, sampling_handler=sampling_handler) as client: 81 | result = await client.call_tool( 82 | "sample_with_messages", {"message": "Hello, world!"} 83 | ) 84 | reply = cast(TextContent, result[0]) 85 | assert reply.text == "I need to think." 86 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlowin/fastmcp/a4e233711fda1e232e24fd586cfd11c495bd14fe/tests/conftest.py -------------------------------------------------------------------------------- /tests/contrib/__init__.py: -------------------------------------------------------------------------------- 1 | # This file makes Python treat the directory as a package. 2 | -------------------------------------------------------------------------------- /tests/contrib/test_bulk_tool_caller.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | from mcp.types import EmbeddedResource, ImageContent, TextContent 5 | 6 | from fastmcp import FastMCP 7 | from fastmcp.contrib.bulk_tool_caller.bulk_tool_caller import ( 8 | BulkToolCaller, 9 | CallToolRequest, 10 | CallToolRequestResult, 11 | ) 12 | 13 | ContentType = TextContent | ImageContent | EmbeddedResource 14 | 15 | 16 | class ToolException(Exception): 17 | """Custom exception for tool errors.""" 18 | 19 | pass 20 | 21 | 22 | async def error_tool(arg1: str) -> dict[str, Any]: 23 | """A tool that raises an error for testing purposes.""" 24 | raise ToolException(f"Error in tool with arg1: {arg1}") 25 | 26 | 27 | def error_tool_result_factory(arg1: str) -> CallToolRequestResult: 28 | """Generates the expected error result for error_tool.""" 29 | # Mimic the error message format generated by BulkToolCaller when catching ToolException 30 | exception_message = f"Error in tool with arg1: {arg1}" 31 | formatted_error_text = f"Error executing tool error_tool: {exception_message}" 32 | return CallToolRequestResult( 33 | isError=True, 34 | content=[TextContent(text=formatted_error_text, type="text")], 35 | tool="error_tool", 36 | arguments={"arg1": arg1}, 37 | ) 38 | 39 | 40 | async def echo_tool(arg1: str) -> str: 41 | """A simple tool that echoes arguments or raises an error.""" 42 | return arg1 43 | 44 | 45 | def echo_tool_result_factory(arg1: str) -> CallToolRequestResult: 46 | """A tool that returns a result based on the input arguments.""" 47 | return CallToolRequestResult( 48 | isError=False, 49 | content=[TextContent(text=f"{arg1}", type="text")], 50 | tool="echo_tool", 51 | arguments={"arg1": arg1}, 52 | ) 53 | 54 | 55 | async def no_return_tool(arg1: str) -> None: 56 | """A simple tool that echoes arguments or raises an error.""" 57 | 58 | 59 | def no_return_tool_result_factory(arg1: str) -> CallToolRequestResult: 60 | """A tool that returns a result based on the input arguments.""" 61 | return CallToolRequestResult( 62 | isError=False, content=[], tool="no_return_tool", arguments={"arg1": arg1} 63 | ) 64 | 65 | 66 | @pytest.fixture(scope="module") 67 | def live_server_with_tool() -> FastMCP: 68 | """Fixture to create a FastMCP server instance with the echo_tool registered.""" 69 | server = FastMCP() 70 | server.add_tool(echo_tool) 71 | server.add_tool(error_tool) 72 | server.add_tool(no_return_tool) 73 | return server 74 | 75 | 76 | @pytest.fixture 77 | def bulk_caller_live(live_server_with_tool: FastMCP) -> BulkToolCaller: 78 | """Fixture to create a BulkToolCaller instance connected to the live server.""" 79 | bulk_tool_caller = BulkToolCaller() 80 | bulk_tool_caller.register_tools(live_server_with_tool) 81 | return bulk_tool_caller 82 | 83 | 84 | ECHO_TOOL_NAME = "echo_tool" 85 | ERROR_TOOL_NAME = "error_tool" 86 | NO_RETURN_TOOL_NAME = "no_return_tool" 87 | 88 | 89 | @pytest.mark.asyncio 90 | async def test_call_tool_bulk_single_success(bulk_caller_live: BulkToolCaller): 91 | """Test single successful call via call_tool_bulk using echo_tool.""" 92 | tool_arguments = [{"arg1": "value1"}] 93 | expected_result = echo_tool_result_factory(**tool_arguments[0]) 94 | 95 | results = await bulk_caller_live.call_tool_bulk(ECHO_TOOL_NAME, tool_arguments) 96 | 97 | assert len(results) == 1 98 | result = results[0] 99 | assert result == expected_result 100 | 101 | 102 | @pytest.mark.asyncio 103 | async def test_call_tool_bulk_multiple_success(bulk_caller_live: BulkToolCaller): 104 | """Test multiple successful calls via call_tool_bulk using echo_tool.""" 105 | tool_arguments = [{"arg1": "value1"}, {"arg1": "value2"}] 106 | expected_results = [echo_tool_result_factory(**args) for args in tool_arguments] 107 | 108 | results = await bulk_caller_live.call_tool_bulk(ECHO_TOOL_NAME, tool_arguments) 109 | 110 | assert len(results) == 2 111 | assert results == expected_results 112 | 113 | 114 | @pytest.mark.asyncio 115 | async def test_call_tool_bulk_error_stops(bulk_caller_live: BulkToolCaller): 116 | """Test call_tool_bulk stops on first error using error_tool.""" 117 | tool_arguments = [{"arg1": "error_value"}, {"arg1": "value2"}] 118 | expected_result = error_tool_result_factory(**tool_arguments[0]) 119 | 120 | results = await bulk_caller_live.call_tool_bulk( 121 | ERROR_TOOL_NAME, tool_arguments, continue_on_error=False 122 | ) 123 | 124 | assert len(results) == 1 125 | result = results[0] 126 | assert result == expected_result 127 | 128 | 129 | @pytest.mark.asyncio 130 | async def test_call_tool_bulk_error_continues(bulk_caller_live: BulkToolCaller): 131 | """Test call_tool_bulk continues on error using error_tool and echo_tool.""" 132 | tool_arguments = [{"arg1": "error_value"}, {"arg1": "success_value"}] 133 | expected_error_result = error_tool_result_factory(**tool_arguments[0]) 134 | expected_success_result = echo_tool_result_factory(**tool_arguments[1]) 135 | 136 | tool_calls = [ 137 | CallToolRequest(tool=ERROR_TOOL_NAME, arguments=tool_arguments[0]), 138 | CallToolRequest(tool=ECHO_TOOL_NAME, arguments=tool_arguments[1]), 139 | ] 140 | 141 | results = await bulk_caller_live.call_tools_bulk(tool_calls, continue_on_error=True) 142 | 143 | assert len(results) == 2 144 | 145 | error_result = results[0] 146 | assert error_result == expected_error_result 147 | 148 | success_result = results[1] 149 | assert success_result == expected_success_result 150 | 151 | 152 | @pytest.mark.asyncio 153 | async def test_call_tools_bulk_single_success(bulk_caller_live: BulkToolCaller): 154 | """Test single successful call via call_tools_bulk using echo_tool.""" 155 | tool_calls = [CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "value1"})] 156 | expected_result = echo_tool_result_factory(**tool_calls[0].arguments) 157 | 158 | results = await bulk_caller_live.call_tools_bulk(tool_calls) 159 | 160 | assert len(results) == 1 161 | result = results[0] 162 | assert result == expected_result 163 | 164 | 165 | @pytest.mark.asyncio 166 | async def test_call_tools_bulk_multiple_success(bulk_caller_live: BulkToolCaller): 167 | """Test multiple successful calls via call_tools_bulk with different tools.""" 168 | tool_calls = [ 169 | CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "echo_value"}), 170 | CallToolRequest( 171 | tool=NO_RETURN_TOOL_NAME, arguments={"arg1": "no_return_value"} 172 | ), 173 | ] 174 | expected_results = [ 175 | echo_tool_result_factory(**tool_calls[0].arguments), 176 | no_return_tool_result_factory(**tool_calls[1].arguments), 177 | ] 178 | 179 | results = await bulk_caller_live.call_tools_bulk(tool_calls) 180 | 181 | assert len(results) == 2 182 | assert results == expected_results 183 | 184 | 185 | @pytest.mark.asyncio 186 | async def test_call_tools_bulk_error_stops(bulk_caller_live: BulkToolCaller): 187 | """Test call_tools_bulk stops on first error using error_tool.""" 188 | tool_calls = [ 189 | CallToolRequest(tool=ERROR_TOOL_NAME, arguments={"arg1": "error_value"}), 190 | CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "skipped_value"}), 191 | ] 192 | expected_result = error_tool_result_factory(**tool_calls[0].arguments) 193 | 194 | results = await bulk_caller_live.call_tools_bulk( 195 | tool_calls, continue_on_error=False 196 | ) 197 | 198 | assert len(results) == 1 199 | result = results[0] 200 | assert result == expected_result 201 | 202 | 203 | @pytest.mark.asyncio 204 | async def test_call_tools_bulk_error_continues(bulk_caller_live: BulkToolCaller): 205 | """Test call_tools_bulk continues on error using error_tool and echo_tool.""" 206 | tool_calls = [ 207 | CallToolRequest(tool=ERROR_TOOL_NAME, arguments={"arg1": "error_value"}), 208 | CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "success_value"}), 209 | ] 210 | expected_error_result = error_tool_result_factory(**tool_calls[0].arguments) 211 | expected_success_result = echo_tool_result_factory(**tool_calls[1].arguments) 212 | 213 | results = await bulk_caller_live.call_tools_bulk(tool_calls, continue_on_error=True) 214 | 215 | assert len(results) == 2 216 | 217 | error_result = results[0] 218 | assert error_result == expected_error_result 219 | 220 | success_result = results[1] 221 | assert success_result == expected_success_result 222 | -------------------------------------------------------------------------------- /tests/prompts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlowin/fastmcp/a4e233711fda1e232e24fd586cfd11c495bd14fe/tests/prompts/__init__.py -------------------------------------------------------------------------------- /tests/prompts/test_base.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from mcp.types import EmbeddedResource, TextResourceContents 3 | from pydantic import FileUrl 4 | 5 | from fastmcp.prompts.prompt import ( 6 | AssistantMessage, 7 | Message, 8 | Prompt, 9 | TextContent, 10 | UserMessage, 11 | ) 12 | 13 | 14 | class TestRenderPrompt: 15 | async def test_basic_fn(self): 16 | def fn() -> str: 17 | return "Hello, world!" 18 | 19 | prompt = Prompt.from_function(fn) 20 | assert await prompt.render() == [ 21 | UserMessage(content=TextContent(type="text", text="Hello, world!")) 22 | ] 23 | 24 | async def test_async_fn(self): 25 | async def fn() -> str: 26 | return "Hello, world!" 27 | 28 | prompt = Prompt.from_function(fn) 29 | assert await prompt.render() == [ 30 | UserMessage(content=TextContent(type="text", text="Hello, world!")) 31 | ] 32 | 33 | async def test_fn_with_args(self): 34 | async def fn(name: str, age: int = 30) -> str: 35 | return f"Hello, {name}! You're {age} years old." 36 | 37 | prompt = Prompt.from_function(fn) 38 | assert await prompt.render(arguments=dict(name="World")) == [ 39 | UserMessage( 40 | content=TextContent( 41 | type="text", text="Hello, World! You're 30 years old." 42 | ) 43 | ) 44 | ] 45 | 46 | async def test_fn_with_invalid_kwargs(self): 47 | async def fn(name: str, age: int = 30) -> str: 48 | return f"Hello, {name}! You're {age} years old." 49 | 50 | prompt = Prompt.from_function(fn) 51 | with pytest.raises(ValueError): 52 | await prompt.render(arguments=dict(age=40)) 53 | 54 | async def test_fn_returns_message(self): 55 | async def fn() -> Message: 56 | return UserMessage(content="Hello, world!") 57 | 58 | prompt = Prompt.from_function(fn) 59 | assert await prompt.render() == [ 60 | UserMessage(content=TextContent(type="text", text="Hello, world!")) 61 | ] 62 | 63 | async def test_fn_returns_assistant_message(self): 64 | async def fn() -> Message: 65 | return AssistantMessage( 66 | content=TextContent(type="text", text="Hello, world!") 67 | ) 68 | 69 | prompt = Prompt.from_function(fn) 70 | assert await prompt.render() == [ 71 | AssistantMessage(content=TextContent(type="text", text="Hello, world!")) 72 | ] 73 | 74 | async def test_fn_returns_multiple_messages(self): 75 | expected = [ 76 | UserMessage("Hello, world!"), 77 | AssistantMessage("How can I help you today?"), 78 | UserMessage("I'm looking for a restaurant in the center of town."), 79 | ] 80 | 81 | async def fn() -> list[Message]: 82 | return expected 83 | 84 | prompt = Prompt.from_function(fn) 85 | assert await prompt.render() == expected 86 | 87 | async def test_fn_returns_list_of_strings(self): 88 | expected = [ 89 | "Hello, world!", 90 | "I'm looking for a restaurant in the center of town.", 91 | ] 92 | 93 | async def fn() -> list[str]: 94 | return expected 95 | 96 | prompt = Prompt.from_function(fn) 97 | assert await prompt.render() == [UserMessage(t) for t in expected] 98 | 99 | async def test_fn_returns_resource_content(self): 100 | """Test returning a message with resource content.""" 101 | 102 | async def fn() -> Message: 103 | return UserMessage( 104 | content=EmbeddedResource( 105 | type="resource", 106 | resource=TextResourceContents( 107 | uri=FileUrl("file://file.txt"), 108 | text="File contents", 109 | mimeType="text/plain", 110 | ), 111 | ) 112 | ) 113 | 114 | prompt = Prompt.from_function(fn) 115 | assert await prompt.render() == [ 116 | UserMessage( 117 | content=EmbeddedResource( 118 | type="resource", 119 | resource=TextResourceContents( 120 | uri=FileUrl("file://file.txt"), 121 | text="File contents", 122 | mimeType="text/plain", 123 | ), 124 | ) 125 | ) 126 | ] 127 | 128 | async def test_fn_returns_mixed_content(self): 129 | """Test returning messages with mixed content types.""" 130 | 131 | async def fn() -> list[Message]: 132 | return [ 133 | UserMessage(content="Please analyze this file:"), 134 | UserMessage( 135 | content=EmbeddedResource( 136 | type="resource", 137 | resource=TextResourceContents( 138 | uri=FileUrl("file://file.txt"), 139 | text="File contents", 140 | mimeType="text/plain", 141 | ), 142 | ) 143 | ), 144 | AssistantMessage(content="I'll help analyze that file."), 145 | ] 146 | 147 | prompt = Prompt.from_function(fn) 148 | assert await prompt.render() == [ 149 | UserMessage( 150 | content=TextContent(type="text", text="Please analyze this file:") 151 | ), 152 | UserMessage( 153 | content=EmbeddedResource( 154 | type="resource", 155 | resource=TextResourceContents( 156 | uri=FileUrl("file://file.txt"), 157 | text="File contents", 158 | mimeType="text/plain", 159 | ), 160 | ) 161 | ), 162 | AssistantMessage( 163 | content=TextContent(type="text", text="I'll help analyze that file.") 164 | ), 165 | ] 166 | 167 | async def test_fn_returns_dict_with_resource(self): 168 | """Test returning a dict with resource content.""" 169 | 170 | async def fn() -> dict: 171 | return { 172 | "role": "user", 173 | "content": { 174 | "type": "resource", 175 | "resource": { 176 | "uri": FileUrl("file://file.txt"), 177 | "text": "File contents", 178 | "mimeType": "text/plain", 179 | }, 180 | }, 181 | } 182 | 183 | prompt = Prompt.from_function(fn) 184 | assert await prompt.render() == [ 185 | UserMessage( 186 | content=EmbeddedResource( 187 | type="resource", 188 | resource=TextResourceContents( 189 | uri=FileUrl("file://file.txt"), 190 | text="File contents", 191 | mimeType="text/plain", 192 | ), 193 | ) 194 | ) 195 | ] 196 | -------------------------------------------------------------------------------- /tests/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlowin/fastmcp/a4e233711fda1e232e24fd586cfd11c495bd14fe/tests/resources/__init__.py -------------------------------------------------------------------------------- /tests/resources/test_file_resources.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from tempfile import NamedTemporaryFile 4 | 5 | import pytest 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 | import pytest 2 | from pydantic import AnyUrl, BaseModel 3 | 4 | from fastmcp.resources import FunctionResource 5 | 6 | 7 | class TestFunctionResource: 8 | """Test FunctionResource functionality.""" 9 | 10 | def test_function_resource_creation(self): 11 | """Test creating a FunctionResource.""" 12 | 13 | def my_func() -> str: 14 | return "test content" 15 | 16 | resource = FunctionResource( 17 | uri=AnyUrl("fn://test"), 18 | name="test", 19 | description="test function", 20 | fn=my_func, 21 | ) 22 | assert str(resource.uri) == "fn://test" 23 | assert resource.name == "test" 24 | assert resource.description == "test function" 25 | assert resource.mime_type == "text/plain" # default 26 | assert resource.fn == my_func 27 | 28 | async def test_read_text(self): 29 | """Test reading text from a FunctionResource.""" 30 | 31 | def get_data() -> str: 32 | return "Hello, world!" 33 | 34 | resource = FunctionResource( 35 | uri=AnyUrl("function://test"), 36 | name="test", 37 | fn=get_data, 38 | ) 39 | content = await resource.read() 40 | assert content == "Hello, world!" 41 | assert resource.mime_type == "text/plain" 42 | 43 | async def test_read_binary(self): 44 | """Test reading binary data from a FunctionResource.""" 45 | 46 | def get_data() -> bytes: 47 | return b"Hello, world!" 48 | 49 | resource = FunctionResource( 50 | uri=AnyUrl("function://test"), 51 | name="test", 52 | fn=get_data, 53 | ) 54 | content = await resource.read() 55 | assert content == b"Hello, world!" 56 | 57 | async def test_json_conversion(self): 58 | """Test automatic JSON conversion of non-string results.""" 59 | 60 | def get_data() -> dict: 61 | return {"key": "value"} 62 | 63 | resource = FunctionResource( 64 | uri=AnyUrl("function://test"), 65 | name="test", 66 | fn=get_data, 67 | ) 68 | content = await resource.read() 69 | assert isinstance(content, str) 70 | assert '"key": "value"' in content 71 | 72 | async def test_error_handling(self): 73 | """Test error handling in FunctionResource.""" 74 | 75 | def failing_func() -> str: 76 | raise ValueError("Test error") 77 | 78 | resource = FunctionResource( 79 | uri=AnyUrl("function://test"), 80 | name="test", 81 | fn=failing_func, 82 | ) 83 | with pytest.raises(ValueError, match="Error reading resource function://test"): 84 | await resource.read() 85 | 86 | async def test_basemodel_conversion(self): 87 | """Test handling of BaseModel types.""" 88 | 89 | class MyModel(BaseModel): 90 | name: str 91 | 92 | resource = FunctionResource( 93 | uri=AnyUrl("function://test"), 94 | name="test", 95 | fn=lambda: MyModel(name="test"), 96 | ) 97 | content = await resource.read() 98 | assert content == '{"name": "test"}' 99 | 100 | async def test_custom_type_conversion(self): 101 | """Test handling of custom types.""" 102 | 103 | class CustomData: 104 | def __str__(self) -> str: 105 | return "custom data" 106 | 107 | def get_data() -> CustomData: 108 | return CustomData() 109 | 110 | resource = FunctionResource( 111 | uri=AnyUrl("function://test"), 112 | name="test", 113 | fn=get_data, 114 | ) 115 | content = await resource.read() 116 | assert isinstance(content, str) 117 | 118 | async def test_async_read_text(self): 119 | """Test reading text from async FunctionResource.""" 120 | 121 | async def get_data() -> str: 122 | return "Hello, world!" 123 | 124 | resource = FunctionResource( 125 | uri=AnyUrl("function://test"), 126 | name="test", 127 | fn=get_data, 128 | ) 129 | content = await resource.read() 130 | assert content == "Hello, world!" 131 | assert resource.mime_type == "text/plain" 132 | -------------------------------------------------------------------------------- /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/server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlowin/fastmcp/a4e233711fda1e232e24fd586cfd11c495bd14fe/tests/server/__init__.py -------------------------------------------------------------------------------- /tests/server/test_file_server.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | from fastmcp import FastMCP 7 | 8 | 9 | @pytest.fixture() 10 | def test_dir(tmp_path_factory) -> Path: 11 | """Create a temporary directory with test files.""" 12 | tmp = tmp_path_factory.mktemp("test_files") 13 | 14 | # Create test files 15 | (tmp / "example.py").write_text("print('hello world')") 16 | (tmp / "readme.md").write_text("# Test Directory\nThis is a test.") 17 | (tmp / "config.json").write_text('{"test": true}') 18 | 19 | return tmp 20 | 21 | 22 | @pytest.fixture 23 | def mcp() -> FastMCP: 24 | mcp = FastMCP() 25 | 26 | return mcp 27 | 28 | 29 | @pytest.fixture(autouse=True) 30 | def resources(mcp: FastMCP, test_dir: Path) -> FastMCP: 31 | @mcp.resource("dir://test_dir") 32 | def list_test_dir() -> list[str]: 33 | """List the files in the test directory""" 34 | return [str(f) for f in test_dir.iterdir()] 35 | 36 | @mcp.resource("file://test_dir/example.py") 37 | def read_example_py() -> str: 38 | """Read the example.py file""" 39 | try: 40 | return (test_dir / "example.py").read_text() 41 | except FileNotFoundError: 42 | return "File not found" 43 | 44 | @mcp.resource("file://test_dir/readme.md") 45 | def read_readme_md() -> str: 46 | """Read the readme.md file""" 47 | try: 48 | return (test_dir / "readme.md").read_text() 49 | except FileNotFoundError: 50 | return "File not found" 51 | 52 | @mcp.resource("file://test_dir/config.json") 53 | def read_config_json() -> str: 54 | """Read the config.json file""" 55 | try: 56 | return (test_dir / "config.json").read_text() 57 | except FileNotFoundError: 58 | return "File not found" 59 | 60 | return mcp 61 | 62 | 63 | @pytest.fixture(autouse=True) 64 | def tools(mcp: FastMCP, test_dir: Path) -> FastMCP: 65 | @mcp.tool() 66 | def delete_file(path: str) -> bool: 67 | # ensure path is in test_dir 68 | if Path(path).resolve().parent != test_dir: 69 | raise ValueError(f"Path must be in test_dir: {path}") 70 | Path(path).unlink() 71 | return True 72 | 73 | return mcp 74 | 75 | 76 | async def test_list_resources(mcp: FastMCP): 77 | resources = await mcp._mcp_list_resources() 78 | assert len(resources) == 4 79 | 80 | assert [str(r.uri) for r in resources] == [ 81 | "dir://test_dir", 82 | "file://test_dir/example.py", 83 | "file://test_dir/readme.md", 84 | "file://test_dir/config.json", 85 | ] 86 | 87 | 88 | async def test_read_resource_dir(mcp: FastMCP): 89 | res_iter = await mcp._mcp_read_resource("dir://test_dir") 90 | res_list = list(res_iter) 91 | assert len(res_list) == 1 92 | res = res_list[0] 93 | assert res.mime_type == "text/plain" 94 | 95 | files = json.loads(res.content) 96 | 97 | assert sorted([Path(f).name for f in files]) == [ 98 | "config.json", 99 | "example.py", 100 | "readme.md", 101 | ] 102 | 103 | 104 | async def test_read_resource_file(mcp: FastMCP): 105 | res_iter = await mcp._mcp_read_resource("file://test_dir/example.py") 106 | res_list = list(res_iter) 107 | assert len(res_list) == 1 108 | res = res_list[0] 109 | assert res.content == "print('hello world')" 110 | 111 | 112 | async def test_delete_file(mcp: FastMCP, test_dir: Path): 113 | await mcp._mcp_call_tool( 114 | "delete_file", arguments=dict(path=str(test_dir / "example.py")) 115 | ) 116 | assert not (test_dir / "example.py").exists() 117 | 118 | 119 | async def test_delete_file_and_check_resources(mcp: FastMCP, test_dir: Path): 120 | await mcp._mcp_call_tool( 121 | "delete_file", arguments=dict(path=str(test_dir / "example.py")) 122 | ) 123 | res_iter = await mcp._mcp_read_resource("file://test_dir/example.py") 124 | res_list = list(res_iter) 125 | assert len(res_list) == 1 126 | res = res_list[0] 127 | assert res.content == "File not found" 128 | -------------------------------------------------------------------------------- /tests/server/test_lifespan.py: -------------------------------------------------------------------------------- 1 | """Tests for lifespan functionality in both low-level and FastMCP servers.""" 2 | 3 | from collections.abc import AsyncIterator 4 | from contextlib import asynccontextmanager 5 | 6 | import anyio 7 | from mcp.types import ( 8 | ClientCapabilities, 9 | Implementation, 10 | InitializeRequestParams, 11 | JSONRPCMessage, 12 | JSONRPCNotification, 13 | JSONRPCRequest, 14 | ) 15 | from pydantic import TypeAdapter 16 | 17 | from fastmcp import Context, FastMCP 18 | 19 | 20 | async def test_fastmcp_server_lifespan(): 21 | """Test that lifespan works in FastMCP server.""" 22 | 23 | @asynccontextmanager 24 | async def test_lifespan(server: FastMCP) -> AsyncIterator[dict]: 25 | """Test lifespan context that tracks startup/shutdown.""" 26 | context = {"started": False, "shutdown": False} 27 | try: 28 | context["started"] = True 29 | yield context 30 | finally: 31 | context["shutdown"] = True 32 | 33 | server = FastMCP("test", lifespan=test_lifespan) 34 | 35 | # Create memory streams for testing 36 | send_stream1, receive_stream1 = anyio.create_memory_object_stream(100) 37 | send_stream2, receive_stream2 = anyio.create_memory_object_stream(100) 38 | 39 | # Add a tool that checks lifespan context 40 | @server.tool() 41 | def check_lifespan(ctx: Context) -> bool: 42 | """Tool that checks lifespan context.""" 43 | assert isinstance(ctx.request_context.lifespan_context, dict) 44 | assert ctx.request_context.lifespan_context["started"] 45 | assert not ctx.request_context.lifespan_context["shutdown"] 46 | return True 47 | 48 | # Run server in background task 49 | async with ( 50 | anyio.create_task_group() as tg, 51 | send_stream1, 52 | receive_stream1, 53 | send_stream2, 54 | receive_stream2, 55 | ): 56 | 57 | async def run_server(): 58 | await server._mcp_server.run( 59 | receive_stream1, 60 | send_stream2, 61 | server._mcp_server.create_initialization_options(), 62 | raise_exceptions=True, 63 | ) 64 | 65 | tg.start_soon(run_server) 66 | 67 | # Initialize the server 68 | params = InitializeRequestParams( 69 | protocolVersion="2024-11-05", 70 | capabilities=ClientCapabilities(), 71 | clientInfo=Implementation(name="test-client", version="0.1.0"), 72 | ) 73 | await send_stream1.send( 74 | JSONRPCMessage( 75 | root=JSONRPCRequest( 76 | jsonrpc="2.0", 77 | id=1, 78 | method="initialize", 79 | params=TypeAdapter(InitializeRequestParams).dump_python(params), 80 | ) 81 | ) 82 | ) 83 | response = await receive_stream2.receive() 84 | 85 | # Send initialized notification 86 | await send_stream1.send( 87 | JSONRPCMessage( 88 | root=JSONRPCNotification( 89 | jsonrpc="2.0", 90 | method="notifications/initialized", 91 | ) 92 | ) 93 | ) 94 | 95 | # Call the tool to verify lifespan context 96 | await send_stream1.send( 97 | JSONRPCMessage( 98 | root=JSONRPCRequest( 99 | jsonrpc="2.0", 100 | id=2, 101 | method="tools/call", 102 | params={"name": "check_lifespan", "arguments": {}}, 103 | ) 104 | ) 105 | ) 106 | 107 | # Get response and verify 108 | response = await receive_stream2.receive() 109 | assert response.root.result["content"][0]["text"] == "true" 110 | 111 | # Cancel server task 112 | tg.cancel_scope.cancel() 113 | -------------------------------------------------------------------------------- /tests/server/test_run_server.py: -------------------------------------------------------------------------------- 1 | # from pathlib import Path 2 | # from typing import TYPE_CHECKING, Any 3 | 4 | # import pytest 5 | 6 | # import fastmcp 7 | # from fastmcp import FastMCP 8 | 9 | # if TYPE_CHECKING: 10 | # pass 11 | 12 | # USERS = [ 13 | # {"id": "1", "name": "Alice", "active": True}, 14 | # {"id": "2", "name": "Bob", "active": True}, 15 | # {"id": "3", "name": "Charlie", "active": False}, 16 | # ] 17 | 18 | 19 | # @pytest.fixture 20 | # def fastmcp_server(): 21 | # server = FastMCP("TestServer") 22 | 23 | # # --- Tools --- 24 | 25 | # @server.tool() 26 | # def greet(name: str) -> str: 27 | # """Greet someone by name.""" 28 | # return f"Hello, {name}!" 29 | 30 | # @server.tool() 31 | # def add(a: int, b: int) -> int: 32 | # """Add two numbers together.""" 33 | # return a + b 34 | 35 | # @server.tool() 36 | # def error_tool(): 37 | # """This tool always raises an error.""" 38 | # raise ValueError("This is a test error") 39 | 40 | # # --- Resources --- 41 | 42 | # @server.resource(uri="resource://wave") 43 | # def wave() -> str: 44 | # return "👋" 45 | 46 | # @server.resource(uri="data://users") 47 | # async def get_users() -> list[dict[str, Any]]: 48 | # return USERS 49 | 50 | # @server.resource(uri="data://user/{user_id}") 51 | # async def get_user(user_id: str) -> dict[str, Any] | None: 52 | # return next((user for user in USERS if user["id"] == user_id), None) 53 | 54 | # # --- Prompts --- 55 | 56 | # @server.prompt() 57 | # def welcome(name: str) -> str: 58 | # return f"Welcome to FastMCP, {name}!" 59 | 60 | # return server 61 | 62 | 63 | # @pytest.fixture 64 | # async def stdio_client(): 65 | # # Find the stdio.py script path 66 | # base_dir = Path(__file__).parent 67 | # stdio_script = base_dir / "test_servers" / "stdio.py" 68 | 69 | # if not stdio_script.exists(): 70 | # raise FileNotFoundError(f"Could not find stdio.py script at {stdio_script}") 71 | 72 | # client = fastmcp.Client( 73 | # transport=fastmcp.client.transports.StdioTransport( 74 | # command="python", 75 | # args=[str(stdio_script)], 76 | # ) 77 | # ) 78 | 79 | # async with client: 80 | # print("READY") 81 | # yield client 82 | # print("DONE") 83 | 84 | 85 | # class TestRunServerStdio: 86 | # async def test_run_server_stdio( 87 | # self, fastmcp_server: FastMCP, stdio_client: fastmcp.Client 88 | # ): 89 | # print("TEST") 90 | # tools = await stdio_client.list_tools() 91 | # print("TEST 2") 92 | # assert tools == 1 93 | 94 | 95 | # class TestRunServerSSE: 96 | # 97 | # async def test_run_server_sse(self, fastmcp_server: FastMCP): 98 | # pass 99 | -------------------------------------------------------------------------------- /tests/test_servers/fastmcp_server.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastmcp import FastMCP 4 | 5 | USERS = [ 6 | {"id": "1", "name": "Alice", "active": True}, 7 | {"id": "2", "name": "Bob", "active": True}, 8 | {"id": "3", "name": "Charlie", "active": False}, 9 | ] 10 | 11 | 12 | server = FastMCP("TestServer") 13 | 14 | # --- Tools --- 15 | 16 | 17 | @server.tool() 18 | def greet(name: str) -> str: 19 | """Greet someone by name.""" 20 | return f"Hello, {name}!" 21 | 22 | 23 | @server.tool() 24 | def add(a: int, b: int) -> int: 25 | """Add two numbers together.""" 26 | return a + b 27 | 28 | 29 | @server.tool() 30 | def error_tool(): 31 | """This tool always raises an error.""" 32 | raise ValueError("This is a test error") 33 | 34 | 35 | # --- Resources --- 36 | 37 | 38 | @server.resource(uri="resource://wave") 39 | def wave() -> str: 40 | return "👋" 41 | 42 | 43 | @server.resource(uri="data://users") 44 | async def get_users() -> list[dict[str, Any]]: 45 | return USERS 46 | 47 | 48 | @server.resource(uri="data://user/{user_id}") 49 | async def get_user(user_id: str) -> dict[str, Any] | None: 50 | return next((user for user in USERS if user["id"] == user_id), None) 51 | 52 | 53 | # --- Prompts --- 54 | 55 | 56 | @server.prompt() 57 | def welcome(name: str) -> str: 58 | return f"Welcome to FastMCP, {name}!" 59 | -------------------------------------------------------------------------------- /tests/test_servers/sse.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import fastmcp_server 4 | 5 | if __name__ == "__main__": 6 | asyncio.run(fastmcp_server.server.run_sse_async()) 7 | -------------------------------------------------------------------------------- /tests/test_servers/stdio.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import fastmcp_server 4 | 5 | if __name__ == "__main__": 6 | asyncio.run(fastmcp_server.server.run_stdio_async()) 7 | -------------------------------------------------------------------------------- /tests/tools/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlowin/fastmcp/a4e233711fda1e232e24fd586cfd11c495bd14fe/tests/tools/__init__.py -------------------------------------------------------------------------------- /tests/utilities/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for utilities in the fastmcp package.""" 2 | -------------------------------------------------------------------------------- /tests/utilities/openapi/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the OpenAPI utilities.""" 2 | -------------------------------------------------------------------------------- /tests/utilities/openapi/conftest.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/utilities/test_decorated_function.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from collections.abc import Callable 3 | from typing import Any 4 | 5 | import pytest 6 | 7 | from fastmcp.utilities.decorators import DecoratedFunction 8 | 9 | DECORATOR_CALLED = [] 10 | 11 | 12 | def decorator(fn: Callable[..., Any]) -> DecoratedFunction[..., Any]: 13 | @functools.wraps(fn) 14 | def wrapper(*args: Any, **kwargs: Any) -> Any: 15 | DECORATOR_CALLED.append((args, kwargs)) 16 | return fn(*args, **kwargs) 17 | 18 | return DecoratedFunction(wrapper) 19 | 20 | 21 | @pytest.fixture(autouse=True) 22 | def reset_decorator_called(): 23 | DECORATOR_CALLED.clear() 24 | yield 25 | DECORATOR_CALLED.clear() 26 | 27 | 28 | @decorator 29 | def add(a: int, b: int) -> int: 30 | return a + b 31 | 32 | 33 | @decorator 34 | async def add_async(a: int, b: int) -> int: 35 | return a + b 36 | 37 | 38 | class DecoratedClass: 39 | def __init__(self, x: int): 40 | self.x = x 41 | 42 | @decorator 43 | def add(self, a: int, b: int) -> int: 44 | return a + b + self.x 45 | 46 | @decorator 47 | async def add_async(self, a: int, b: int) -> int: 48 | return a + b + self.x 49 | 50 | @classmethod 51 | @decorator 52 | def add_classmethod(cls, a: int, b: int) -> int: 53 | return a + b 54 | 55 | @staticmethod 56 | @decorator 57 | def add_staticmethod(a: int, b: int) -> int: 58 | return a + b 59 | 60 | @classmethod 61 | @decorator 62 | async def add_classmethod_async(cls, a: int, b: int) -> int: 63 | return a + b 64 | 65 | @staticmethod 66 | @decorator 67 | async def add_staticmethod_async(a: int, b: int) -> int: 68 | return a + b 69 | 70 | @decorator 71 | @classmethod 72 | def add_classmethod_reverse_decorator_order(cls, a: int, b: int) -> int: 73 | return a + b 74 | 75 | @decorator 76 | @staticmethod 77 | def add_staticmethod_reverse_decorator_order(a: int, b: int) -> int: 78 | return a + b 79 | 80 | @decorator 81 | @classmethod 82 | async def add_classmethod_async_reverse_decorator_order(cls, a: int, b: int) -> int: 83 | return a + b 84 | 85 | @decorator 86 | @staticmethod 87 | async def add_staticmethod_async_reverse_decorator_order(a: int, b: int) -> int: 88 | return a + b 89 | 90 | 91 | def test_add(): 92 | assert add(1, 2) == 3 93 | assert DECORATOR_CALLED == [((1, 2), {})] 94 | DECORATOR_CALLED.clear() 95 | 96 | # Test with keyword arguments 97 | assert add(a=3, b=4) == 7 98 | assert DECORATOR_CALLED == [((), {"a": 3, "b": 4})] 99 | 100 | 101 | async def test_add_async(): 102 | assert await add_async(1, 2) == 3 103 | assert DECORATOR_CALLED == [((1, 2), {})] 104 | DECORATOR_CALLED.clear() 105 | 106 | # Test with keyword arguments 107 | assert await add_async(a=3, b=4) == 7 108 | assert DECORATOR_CALLED == [((), {"a": 3, "b": 4})] 109 | 110 | 111 | def test_instance_method(): 112 | obj = DecoratedClass(10) 113 | assert obj.add(2, 3) == 15 114 | assert DECORATOR_CALLED == [((obj, 2, 3), {})] 115 | DECORATOR_CALLED.clear() 116 | 117 | # Test with keyword arguments 118 | assert obj.add(a=4, b=5) == 19 119 | assert DECORATOR_CALLED == [((obj,), {"a": 4, "b": 5})] 120 | 121 | 122 | async def test_instance_method_async(): 123 | obj = DecoratedClass(10) 124 | assert await obj.add_async(2, 3) == 15 125 | assert DECORATOR_CALLED == [((obj, 2, 3), {})] 126 | DECORATOR_CALLED.clear() 127 | 128 | # Test with keyword arguments 129 | assert await obj.add_async(a=4, b=5) == 19 130 | assert DECORATOR_CALLED == [((obj,), {"a": 4, "b": 5})] 131 | 132 | 133 | def test_classmethod(): 134 | assert DecoratedClass.add_classmethod(1, 2) == 3 135 | assert DECORATOR_CALLED == [((DecoratedClass, 1, 2), {})] 136 | DECORATOR_CALLED.clear() 137 | 138 | # Test with keyword arguments 139 | assert DecoratedClass.add_classmethod(a=3, b=4) == 7 140 | assert DECORATOR_CALLED == [((DecoratedClass,), {"a": 3, "b": 4})] 141 | DECORATOR_CALLED.clear() 142 | 143 | # Test via instance 144 | obj = DecoratedClass(10) 145 | assert obj.add_classmethod(5, 6) == 11 146 | assert DECORATOR_CALLED == [((DecoratedClass, 5, 6), {})] 147 | 148 | 149 | async def test_classmethod_async(): 150 | assert await DecoratedClass.add_classmethod_async(1, 2) == 3 151 | assert DECORATOR_CALLED == [((DecoratedClass, 1, 2), {})] 152 | DECORATOR_CALLED.clear() 153 | 154 | # Test with keyword arguments 155 | assert await DecoratedClass.add_classmethod_async(a=3, b=4) == 7 156 | assert DECORATOR_CALLED == [((DecoratedClass,), {"a": 3, "b": 4})] 157 | DECORATOR_CALLED.clear() 158 | 159 | # Test via instance 160 | obj = DecoratedClass(10) 161 | assert await obj.add_classmethod_async(5, 6) == 11 162 | assert DECORATOR_CALLED == [((DecoratedClass, 5, 6), {})] 163 | 164 | 165 | def test_classmethod_wrong_order(): 166 | with pytest.raises( 167 | TypeError, 168 | match="To apply this decorator to a classmethod, apply the decorator first, then @classmethod on top.", 169 | ): 170 | DecoratedClass.add_classmethod_reverse_decorator_order(1, 2) 171 | 172 | 173 | async def test_classmethod_async_wrong_order(): 174 | with pytest.raises( 175 | TypeError, 176 | match="To apply this decorator to a classmethod, apply the decorator first, then @classmethod on top.", 177 | ): 178 | await DecoratedClass.add_classmethod_async_reverse_decorator_order(1, 2) 179 | 180 | 181 | def test_staticmethod(): 182 | assert DecoratedClass.add_staticmethod(1, 2) == 3 183 | assert DECORATOR_CALLED == [((1, 2), {})] 184 | DECORATOR_CALLED.clear() 185 | 186 | # Test with keyword arguments 187 | assert DecoratedClass.add_staticmethod(a=3, b=4) == 7 188 | assert DECORATOR_CALLED == [((), {"a": 3, "b": 4})] 189 | DECORATOR_CALLED.clear() 190 | 191 | # Test via instance 192 | obj = DecoratedClass(10) 193 | assert obj.add_staticmethod(5, 6) == 11 194 | assert DECORATOR_CALLED == [((5, 6), {})] 195 | 196 | 197 | async def test_staticmethod_async(): 198 | assert await DecoratedClass.add_staticmethod_async(1, 2) == 3 199 | assert DECORATOR_CALLED == [((1, 2), {})] 200 | DECORATOR_CALLED.clear() 201 | 202 | # Test with keyword arguments 203 | assert await DecoratedClass.add_staticmethod_async(a=3, b=4) == 7 204 | assert DECORATOR_CALLED == [((), {"a": 3, "b": 4})] 205 | DECORATOR_CALLED.clear() 206 | 207 | # Test via instance 208 | obj = DecoratedClass(10) 209 | assert await obj.add_staticmethod_async(5, 6) == 11 210 | assert DECORATOR_CALLED == [((5, 6), {})] 211 | 212 | 213 | def test_staticmethod_wrong_order(): 214 | assert DecoratedClass.add_staticmethod_reverse_decorator_order(1, 2) == 3 215 | assert DECORATOR_CALLED == [((1, 2), {})] 216 | 217 | 218 | async def test_staticmethod_async_wrong_order(): 219 | assert ( 220 | await DecoratedClass.add_staticmethod_async_reverse_decorator_order(1, 2) == 3 221 | ) 222 | assert DECORATOR_CALLED == [((1, 2), {})] 223 | -------------------------------------------------------------------------------- /tests/utilities/test_logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastmcp.utilities.logging import get_logger 4 | 5 | 6 | def test_logging_doesnt_affect_other_loggers(caplog): 7 | # set FastMCP loggers to CRITICAL and ensure other loggers still emit messages 8 | original_level = logging.getLogger("FastMCP").getEffectiveLevel() 9 | 10 | try: 11 | logging.getLogger("FastMCP").setLevel(logging.CRITICAL) 12 | 13 | root_logger = logging.getLogger() 14 | app_logger = logging.getLogger("app") 15 | fastmcp_logger = logging.getLogger("FastMCP") 16 | fastmcp_server_logger = get_logger("server") 17 | 18 | with caplog.at_level(logging.INFO): 19 | root_logger.info("--ROOT--") 20 | app_logger.info("--APP--") 21 | fastmcp_logger.info("--FASTMCP--") 22 | fastmcp_server_logger.info("--FASTMCP SERVER--") 23 | 24 | assert "--ROOT--" in caplog.text 25 | assert "--APP--" in caplog.text 26 | assert "--FASTMCP--" not in caplog.text 27 | assert "--FASTMCP SERVER--" not in caplog.text 28 | 29 | finally: 30 | logging.getLogger("FastMCP").setLevel(original_level) 31 | --------------------------------------------------------------------------------