├── .coveragerc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── documentation.md │ └── feature_request.md ├── codecov.yml ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── README_zh-CN.md ├── docs ├── advanced │ ├── auth.mdx │ ├── deploy.mdx │ ├── refresh.mdx │ └── transport.mdx ├── configurations │ ├── customization.mdx │ └── tool-naming.mdx ├── docs.json ├── getting-started │ ├── FAQ.mdx │ ├── best-practices.mdx │ ├── installation.mdx │ ├── quickstart.mdx │ └── welcome.mdx └── media │ ├── dark_logo.png │ ├── favicon.png │ └── light_logo.png ├── examples ├── 01_basic_usage_example.py ├── 02_full_schema_description_example.py ├── 03_custom_exposed_endpoints_example.py ├── 04_separate_server_example.py ├── 05_reregister_tools_example.py ├── 06_custom_mcp_router_example.py ├── 07_configure_http_timeout_example.py ├── 08_auth_example_token_passthrough.py ├── 09_auth_example_auth0.py ├── README.md ├── __init__.py └── shared │ ├── __init__.py │ ├── apps │ ├── __init__.py │ └── items.py │ ├── auth.py │ └── setup.py ├── fastapi_mcp ├── __init__.py ├── auth │ ├── __init__.py │ └── proxy.py ├── openapi │ ├── __init__.py │ ├── convert.py │ └── utils.py ├── server.py ├── transport │ ├── __init__.py │ └── sse.py ├── types.py └── utils │ └── __init__.py ├── mypy.ini ├── pyproject.toml ├── pytest.ini ├── tests ├── __init__.py ├── conftest.py ├── fixtures │ ├── complex_app.py │ ├── example_data.py │ ├── simple_app.py │ └── types.py ├── test_basic_functionality.py ├── test_configuration.py ├── test_mcp_complex_app.py ├── test_mcp_execute_api_tool.py ├── test_mcp_simple_app.py ├── test_openapi_conversion.py ├── test_sse_mock_transport.py ├── test_sse_real_transport.py └── test_types_validation.py └── uv.lock /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | examples/* 4 | tests/* 5 | concurrency = multiprocessing 6 | parallel = true 7 | sigterm = true 8 | data_file = .coverage 9 | source = fastapi_mcp 10 | 11 | [report] 12 | show_missing = true 13 | 14 | [paths] 15 | source = 16 | fastapi_mcp/ -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior, including example code. 15 | 16 | **System Info** 17 | Please specify the relevant information of your work environment. 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation 3 | about: Report an issue related to the fastapi-mcp documentation/examples 4 | title: '' 5 | labels: documentation 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | base: pr 6 | target: auto 7 | threshold: 0.5% 8 | informational: false 9 | only_pulls: true 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | open-pull-requests-limit: 10 8 | labels: 9 | - "dependencies" 10 | - "github-actions" 11 | 12 | - package-ecosystem: "pip" 13 | directory: "/" 14 | schedule: 15 | interval: "weekly" 16 | open-pull-requests-limit: 10 17 | labels: 18 | - "dependencies" 19 | - "python" -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Describe your changes 2 | 3 | ## Issue ticket number and link (if applicable) 4 | 5 | ## Screenshots of the feature / bugfix 6 | 7 | ## Checklist before requesting a review 8 | - [ ] Added relevant tests 9 | - [ ] Run ruff & mypy 10 | - [ ] All tests pass 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | ruff: 11 | name: Ruff 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Install uv 17 | uses: astral-sh/setup-uv@v5 18 | with: 19 | version: "0.6.12" 20 | enable-cache: true 21 | cache-dependency-glob: "uv.lock" 22 | 23 | - name: Set up Python 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version-file: ".python-version" 27 | 28 | - name: Install dependencies 29 | run: uv sync --all-extras --dev 30 | 31 | - name: Lint with Ruff 32 | run: uv run ruff check . 33 | 34 | mypy: 35 | name: MyPy 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v4 39 | 40 | - name: Install uv 41 | uses: astral-sh/setup-uv@v5 42 | with: 43 | version: "0.6.12" 44 | enable-cache: true 45 | cache-dependency-glob: "uv.lock" 46 | 47 | - name: Set up Python 48 | uses: actions/setup-python@v5 49 | with: 50 | python-version-file: ".python-version" 51 | 52 | - name: Install dependencies 53 | run: uv sync --all-extras --dev 54 | 55 | - name: Type check with MyPy 56 | run: uv run mypy . 57 | 58 | test: 59 | name: Test Python ${{ matrix.python-version }} 60 | runs-on: ubuntu-latest 61 | strategy: 62 | fail-fast: false 63 | matrix: 64 | python-version: ["3.10", "3.11", "3.12"] 65 | 66 | steps: 67 | - uses: actions/checkout@v4 68 | 69 | - name: Install uv 70 | uses: astral-sh/setup-uv@v5 71 | with: 72 | version: "0.6.12" 73 | python-version: ${{ matrix.python-version }} 74 | enable-cache: true 75 | cache-dependency-glob: "uv.lock" 76 | 77 | - name: Install dependencies 78 | run: uv sync --all-extras --dev 79 | 80 | - name: Run tests 81 | run: uv run pytest --cov=fastapi_mcp --cov-report=xml 82 | 83 | - name: Upload coverage to Codecov 84 | uses: codecov/codecov-action@v5 85 | with: 86 | token: ${{ secrets.CODECOV_TOKEN }} 87 | fail_ci_if_error: false 88 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 0 14 | 15 | - name: Install uv 16 | uses: astral-sh/setup-uv@v5 17 | with: 18 | version: "0.6.12" 19 | enable-cache: true 20 | 21 | - name: Set up Python 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version-file: ".python-version" 25 | 26 | - name: Install build dependencies 27 | run: | 28 | uv sync --all-extras --dev 29 | uv pip install build twine 30 | 31 | - name: Build and publish 32 | env: 33 | TWINE_USERNAME: __token__ 34 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 35 | run: | 36 | uv run python -m build 37 | uv run twine check dist/* 38 | uv run twine upload dist/* 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 86 | __pypackages__/ 87 | 88 | # Celery stuff 89 | celerybeat-schedule 90 | celerybeat.pid 91 | 92 | # SageMath parsed files 93 | *.sage.py 94 | 95 | # Environments 96 | .env 97 | .venv 98 | env/ 99 | venv/ 100 | ENV/ 101 | env.bak/ 102 | venv.bak/ 103 | 104 | # Spyder project settings 105 | .spyderproject 106 | .spyproject 107 | 108 | # Rope project settings 109 | .ropeproject 110 | 111 | # mkdocs documentation 112 | /site 113 | 114 | # mypy 115 | .mypy_cache/ 116 | .dmypy.json 117 | dmypy.json 118 | 119 | # Pyre type checker 120 | .pyre/ 121 | 122 | # pytype static type analyzer 123 | .pytype/ 124 | 125 | # Cython debug symbols 126 | cython_debug/ 127 | 128 | # PyCharm 129 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 130 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 131 | # and can be added to the global gitignore or merged into this file. 132 | .idea/ 133 | *.iml 134 | *.iws 135 | *.ipr 136 | *.iws 137 | .idea_modules/ 138 | 139 | # VSCode 140 | .vscode/ 141 | 142 | # Ruff linter 143 | .ruff_cache/ 144 | 145 | # Mac/OSX 146 | .DS_Store 147 | 148 | # Windows 149 | Thumbs.db 150 | ehthumbs.db 151 | Desktop.ini 152 | 153 | # Repomix output 154 | repomix-output.txt 155 | repomix-output.xml -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | exclude: \.md$ 7 | - id: check-yaml 8 | - id: check-added-large-files 9 | 10 | - repo: https://github.com/astral-sh/ruff-pre-commit 11 | rev: v0.9.10 12 | hooks: 13 | - id: ruff 14 | args: [--fix] 15 | - id: ruff-format 16 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.3.3] 9 | 10 | Fixes the broken release from 0.3.2. 11 | 12 | ### Fixed 13 | - 🐛 Fix critical bug in openapi conversion (missing `param_desc` definition) (#107, #99) 14 | - 🐛 Fix non-ascii support (#66) 15 | 16 | ## [0.3.2] - Broken 17 | 18 | This is a broken release and should not be used. 19 | 20 | ### Fixed 21 | - 🐛 Fix a bug preventing simple setup of [basic token passthrough](docs/03_authentication_and_authorization.md#basic-token-passthrough) 22 | 23 | ## [0.3.1] 24 | 25 | 🚀 FastApiMCP now supports MCP Authorization! 26 | 27 | You can now add MCP-compliant OAuth configuration in a FastAPI-native way, using your existing FastAPI `Depends()` that we all know and love. 28 | 29 | ### Added 30 | - 🎉 Support for Authentication / Authorization compliant to [MCP 2025-03-26 Specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization), using OAuth 2.1. (#10) 31 | - 🎉 Support passing http headers to tool calls (#82) 32 | 33 | ## [0.3.0] 34 | 35 | 🚀 FastApiMCP now works with ASGI-transport by default. 36 | 37 | This means the `base_url` argument is redundant, and thus has been removed. 38 | 39 | You can still set up an explicit base URL using the `http_client` argument, and injecting your own `httpx.AsyncClient` if necessary. 40 | 41 | ### Removed 42 | - ⚠️ Breaking Change: Removed `base_url` argument since it's not used anymore by the MCP transport. 43 | 44 | ### Fixed 45 | - 🐛 Fix short timeout issue (#71), increasing the default timeout to 10 46 | 47 | 48 | ## [0.2.0] 49 | 50 | ### Changed 51 | - Complete refactor from function-based API to a new class-based API with `FastApiMCP` 52 | - Explicit separation between MCP instance creation and mounting with `mcp = FastApiMCP(app)` followed by `mcp.mount()` 53 | - FastAPI-native approach for transport providing more flexible routing options 54 | - Updated minimum MCP dependency to v1.6.0 55 | 56 | ### Added 57 | - Support for deploying MCP servers separately from API service 58 | - Support for "refreshing" with `setup_server()` when dynamically adding FastAPI routes. Fixes [Issue #19](https://github.com/tadata-org/fastapi_mcp/issues/19) 59 | - Endpoint filtering capabilities through new parameters: 60 | - `include_operations`: Expose only specific operations by their operation IDs 61 | - `exclude_operations`: Expose all operations except those with specified operation IDs 62 | - `include_tags`: Expose only operations with specific tags 63 | - `exclude_tags`: Expose all operations except those with specific tags 64 | 65 | ### Fixed 66 | - FastAPI-native approach for transport. Fixes [Issue #28](https://github.com/tadata-org/fastapi_mcp/issues/28) 67 | - Numerous bugs in OpenAPI schema to tool conversion, addressing [Issue #40](https://github.com/tadata-org/fastapi_mcp/issues/40) and [Issue #45](https://github.com/tadata-org/fastapi_mcp/issues/45) 68 | 69 | ### Removed 70 | - Function-based API (`add_mcp_server`, `create_mcp_server`, etc.) 71 | - Custom tool support via `@mcp.tool()` decorator 72 | 73 | ## [0.1.8] 74 | 75 | ### Fixed 76 | - Remove unneeded dependency. 77 | 78 | ## [0.1.7] 79 | 80 | ### Fixed 81 | - [Issue #34](https://github.com/tadata-org/fastapi_mcp/issues/34): Fix syntax error. 82 | 83 | ## [0.1.6] 84 | 85 | ### Fixed 86 | - [Issue #23](https://github.com/tadata-org/fastapi_mcp/issues/23): Hide handle_mcp_connection tool. 87 | 88 | ## [0.1.5] 89 | 90 | ### Fixed 91 | - [Issue #25](https://github.com/tadata-org/fastapi_mcp/issues/25): Dynamically creating tools function so tools are useable. 92 | 93 | ## [0.1.4] 94 | 95 | ### Fixed 96 | - [Issue #8](https://github.com/tadata-org/fastapi_mcp/issues/8): Converted tools unuseable due to wrong passing of arguments. 97 | 98 | ## [0.1.3] 99 | 100 | ### Fixed 101 | - Dependency resolution issue with `mcp` package and `pydantic-settings` 102 | 103 | ## [0.1.2] 104 | 105 | ### Changed 106 | - Complete refactor: transformed from a code generator to a direct integration library 107 | - Replaced the CLI-based approach with a direct API for adding MCP servers to FastAPI applications 108 | - Integrated MCP servers now mount directly to FastAPI apps at runtime instead of generating separate code 109 | - Simplified the API with a single `add_mcp_server` function for quick integration 110 | - Removed code generation entirely in favor of runtime integration 111 | 112 | ### Added 113 | - Main `add_mcp_server` function for simple MCP server integration 114 | - Support for adding custom MCP tools alongside API-derived tools 115 | - Improved test suite 116 | - Manage with uv 117 | 118 | ### Removed 119 | - CLI interface and all associated commands (generate, run, install, etc.) 120 | - Code generation functionality 121 | 122 | ## [0.1.1] - 2024-07-03 123 | 124 | ### Fixed 125 | - Added support for PEP 604 union type syntax (e.g., `str | None`) in FastAPI endpoints 126 | - Improved type handling in model field generation for newer Python versions (3.10+) 127 | - Fixed compatibility issues with modern type annotations in path parameters, query parameters, and Pydantic models 128 | 129 | ## [0.1.0] - 2024-03-08 130 | 131 | ### Added 132 | - Initial release of FastAPI-MCP 133 | - Core functionality for converting FastAPI applications to MCP servers 134 | - CLI tool for generating, running, and installing MCP servers 135 | - Automatic discovery of FastAPI endpoints 136 | - Type-safe conversion from FastAPI endpoints to MCP tools 137 | - Documentation preservation from FastAPI to MCP 138 | - Claude integration for easy installation and use 139 | - API integration that automatically makes HTTP requests to FastAPI endpoints 140 | - Examples directory with sample FastAPI application 141 | - Basic test suite -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to FastAPI-MCP 2 | 3 | First off, thank you for considering contributing to FastAPI-MCP! 4 | 5 | ## Development Setup 6 | 7 | 1. Make sure you have Python 3.10+ installed 8 | 2. Install [uv](https://docs.astral.sh/uv/getting-started/installation/) package manager 9 | 3. Fork the repository 10 | 4. Clone your fork 11 | 12 | ```bash 13 | git clone https://github.com/YOUR-USERNAME/fastapi_mcp.git 14 | cd fastapi-mcp 15 | 16 | # Add the upstream remote 17 | git remote add upstream https://github.com/tadata-org/fastapi_mcp.git 18 | ``` 19 | 20 | 5. Set up the development environment: 21 | 22 | ```bash 23 | uv sync 24 | ``` 25 | 26 | That's it! The `uv sync` command will automatically create and use a virtual environment. 27 | 28 | 6. Install pre-commit hooks: 29 | 30 | ```bash 31 | uv run pre-commit install 32 | uv run pre-commit run 33 | ``` 34 | 35 | Pre-commit hooks will automatically run checks (like ruff, formatting, etc.) when you make a commit, ensuring your code follows our style guidelines. 36 | 37 | ### Running Commands 38 | 39 | You have two options for running commands: 40 | 41 | 1. **With the virtual environment activated**: 42 | ```bash 43 | source .venv/bin/activate # On Windows: .venv\Scripts\activate 44 | 45 | # Then run commands directly 46 | pytest 47 | mypy . 48 | ruff check . 49 | ``` 50 | 51 | 2. **Without activating the virtual environment**: 52 | ```bash 53 | # Use uv run prefix for all commands 54 | uv run pytest 55 | uv run mypy . 56 | uv run ruff check . 57 | ``` 58 | 59 | Both approaches work - use whichever is more convenient for you. 60 | 61 | > **Note:** For simplicity, commands in this guide are mostly written **without** the `uv run` prefix. If you haven't activated your virtual environment, remember to prepend `uv run` to all python-related commands and tools. 62 | 63 | ### Adding Dependencies 64 | 65 | When adding new dependencies to the library: 66 | 67 | 1. **Runtime dependencies** - packages needed to run the application: 68 | ```bash 69 | uv add new-package 70 | ``` 71 | 72 | 2. **Development dependencies** - packages needed for development, testing, or CI: 73 | ```bash 74 | uv add --group dev new-package 75 | ``` 76 | 77 | After adding dependencies, make sure to: 78 | 1. Test that everything works with the new package 79 | 2. Commit both `pyproject.toml` and `uv.lock` files: 80 | ```bash 81 | git add pyproject.toml uv.lock 82 | git commit -m "Add new-package dependency" 83 | ``` 84 | 85 | ## Development Process 86 | 87 | 1. Fork the repository and set the upstream remote 88 | 2. Create a feature branch (`git checkout -b feature/amazing-feature`) 89 | 3. Make your changes 90 | 4. Run type checking (`mypy .`) 91 | 5. Run the tests (`pytest`) 92 | 6. Format your code (`ruff check .` and `ruff format .`). Not needed if pre-commit is installed, as it will run it for you. 93 | 7. Commit your changes (`git commit -m 'Add some amazing feature'`) 94 | 8. Push to the branch (`git push origin feature/amazing-feature`) 95 | 9. Open a Pull Request. Make sure the Pull Request's base branch is [the original repository's](https://github.com/tadata-org/fastapi_mcp/) `main` branch. 96 | 97 | ## Code Style 98 | 99 | We use the following tools to ensure code quality: 100 | 101 | - **ruff** for linting and formatting 102 | - **mypy** for type checking 103 | 104 | Please make sure your code passes all checks before submitting a pull request: 105 | 106 | ```bash 107 | # Check code formatting and style 108 | ruff check . 109 | ruff format . 110 | 111 | # Check types 112 | mypy . 113 | ``` 114 | 115 | ## Testing 116 | 117 | We use pytest for testing. Please write tests for any new features and ensure all tests pass: 118 | 119 | ```bash 120 | # Run all tests 121 | pytest 122 | ``` 123 | 124 | ## Pull Request Process 125 | 126 | 1. Ensure your code follows the style guidelines of the project 127 | 2. Update the README.md with details of changes if applicable 128 | 3. The versioning scheme we use is [SemVer](http://semver.org/) 129 | 4. Include a descriptive commit message 130 | 5. Your pull request will be merged once it's reviewed and approved 131 | 132 | ## Code of Conduct 133 | 134 | Please note we have a code of conduct, please follow it in all your interactions with the project. 135 | 136 | - Be respectful and inclusive 137 | - Be collaborative 138 | - When disagreeing, try to understand why 139 | - A diverse community is a strong community 140 | 141 | ## Questions? 142 | 143 | Don't hesitate to open an issue if you have any questions about contributing to FastAPI-MCP. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Tadata Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include INSTALL.md 4 | include pyproject.toml 5 | include setup.py 6 | 7 | recursive-include examples *.py *.md 8 | recursive-include tests *.py 9 | 10 | global-exclude *.py[cod] __pycache__ *.so *.dylib .DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

fastapi-to-mcp

2 |

FastAPI-MCP

3 |

Expose your FastAPI endpoints as Model Context Protocol (MCP) tools, with Auth!

4 |
5 | 6 | [![PyPI version](https://img.shields.io/pypi/v/fastapi-mcp?color=%2334D058&label=pypi%20package)](https://pypi.org/project/fastapi-mcp/) 7 | [![Python Versions](https://img.shields.io/pypi/pyversions/fastapi-mcp.svg)](https://pypi.org/project/fastapi-mcp/) 8 | [![FastAPI](https://img.shields.io/badge/FastAPI-009485.svg?logo=fastapi&logoColor=white)](#) 9 | [![CI](https://github.com/tadata-org/fastapi_mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/tadata-org/fastapi_mcp/actions/workflows/ci.yml) 10 | [![Coverage](https://codecov.io/gh/tadata-org/fastapi_mcp/branch/main/graph/badge.svg)](https://codecov.io/gh/tadata-org/fastapi_mcp) 11 | 12 |
13 | 14 | 15 |

fastapi-mcp-usage

16 | 17 | 18 | ## Features 19 | 20 | - **Authentication** built in, using your existing FastAPI dependencies! 21 | 22 | - **FastAPI-native:** Not just another OpenAPI -> MCP converter 23 | 24 | - **Zero/Minimal configuration** required - just point it at your FastAPI app and it works 25 | 26 | - **Preserving schemas** of your request models and response models 27 | 28 | - **Preserve documentation** of all your endpoints, just as it is in Swagger 29 | 30 | - **Flexible deployment** - Mount your MCP server to the same app, or deploy separately 31 | 32 | - **ASGI transport** - Uses FastAPI's ASGI interface directly for efficient communication 33 | 34 | 35 | ## Installation 36 | 37 | We recommend using [uv](https://docs.astral.sh/uv/), a fast Python package installer: 38 | 39 | ```bash 40 | uv add fastapi-mcp 41 | ``` 42 | 43 | Alternatively, you can install with pip: 44 | 45 | ```bash 46 | pip install fastapi-mcp 47 | ``` 48 | 49 | ## Basic Usage 50 | 51 | The simplest way to use FastAPI-MCP is to add an MCP server directly to your FastAPI application: 52 | 53 | ```python 54 | from fastapi import FastAPI 55 | from fastapi_mcp import FastApiMCP 56 | 57 | app = FastAPI() 58 | 59 | mcp = FastApiMCP(app) 60 | 61 | # Mount the MCP server directly to your FastAPI app 62 | mcp.mount() 63 | ``` 64 | 65 | That's it! Your auto-generated MCP server is now available at `https://app.base.url/mcp`. 66 | 67 | ## Documentation, Examples and Advanced Usage 68 | 69 | FastAPI-MCP provides [comprehensive documentation](https://fastapi-mcp.tadata.com/). Additionaly, check out the [examples directory](examples) for code samples demonstrating these features in action. 70 | 71 | ## FastAPI-first Approach 72 | 73 | FastAPI-MCP is designed as a native extension of FastAPI, not just a converter that generates MCP tools from your API. This approach offers several key advantages: 74 | 75 | - **Native dependencies**: Secure your MCP endpoints using familiar FastAPI `Depends()` for authentication and authorization 76 | 77 | - **ASGI transport**: Communicates directly with your FastAPI app using its ASGI interface, eliminating the need for HTTP calls from the MCP to your API 78 | 79 | - **Unified infrastructure**: Your FastAPI app doesn't need to run separately from the MCP server (though [separate deployment](https://fastapi-mcp.tadata.com/advanced/deploy#deploying-separately-from-original-fastapi-app) is also supported) 80 | 81 | This design philosophy ensures minimum friction when adding MCP capabilities to your existing FastAPI services. 82 | 83 | ## Development and Contributing 84 | 85 | Thank you for considering contributing to FastAPI-MCP! We encourage the community to post Issues and create Pull Requests. 86 | 87 | Before you get started, please see our [Contribution Guide](CONTRIBUTING.md). 88 | 89 | ## Community 90 | 91 | Join [MCParty Slack community](https://join.slack.com/t/themcparty/shared_invite/zt-30yxr1zdi-2FG~XjBA0xIgYSYuKe7~Xg) to connect with other MCP enthusiasts, ask questions, and share your experiences with FastAPI-MCP. 92 | 93 | ## Requirements 94 | 95 | - Python 3.10+ (Recommended 3.12) 96 | - uv 97 | 98 | ## License 99 | 100 | MIT License. Copyright (c) 2024 Tadata Inc. 101 | -------------------------------------------------------------------------------- /README_zh-CN.md: -------------------------------------------------------------------------------- 1 |

fastapi-to-mcp

2 |

FastAPI-MCP

3 |

一个零配置工具,用于自动将 FastAPI 端点公开为模型上下文协议(MCP)工具。

4 |
5 | 6 | [![PyPI version](https://badge.fury.io/py/fastapi-mcp.svg)](https://pypi.org/project/fastapi-mcp/) 7 | [![Python Versions](https://img.shields.io/pypi/pyversions/fastapi-mcp.svg)](https://pypi.org/project/fastapi-mcp/) 8 | [![FastAPI](https://img.shields.io/badge/FastAPI-009485.svg?logo=fastapi&logoColor=white)](#) 9 | ![](https://badge.mcpx.dev?type=dev 'MCP Dev') 10 | [![CI](https://github.com/tadata-org/fastapi_mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/tadata-org/fastapi_mcp/actions/workflows/ci.yml) 11 | [![codecov](https://codecov.io/gh/tadata-org/fastapi_mcp/branch/main/graph/badge.svg)](https://codecov.io/gh/tadata-org/fastapi_mcp) 12 | 13 |
14 | 15 |

fastapi-mcp-usage

16 | 17 | > 注意:最新版本请参阅 [README.md](README.md). 18 | 19 | ## 特点 20 | 21 | - **直接集成** - 直接将 MCP 服务器挂载到您的 FastAPI 应用 22 | - **零配置** - 只需指向您的 FastAPI 应用即可工作 23 | - **自动发现** - 所有 FastAPI 端点并转换为 MCP 工具 24 | - **保留模式** - 保留您的请求模型和响应模型的模式 25 | - **保留文档** - 保留所有端点的文档,就像在 Swagger 中一样 26 | - **灵活部署** - 将 MCP 服务器挂载到同一应用,或单独部署 27 | - **ASGI 传输** - 默认使用 FastAPI 的 ASGI 接口直接通信,提高效率 28 | 29 | ## 安装 30 | 31 | 我们推荐使用 [uv](https://docs.astral.sh/uv/),一个快速的 Python 包安装器: 32 | 33 | ```bash 34 | uv add fastapi-mcp 35 | ``` 36 | 37 | 或者,您可以使用 pip 安装: 38 | 39 | ```bash 40 | pip install fastapi-mcp 41 | ``` 42 | 43 | ## 基本用法 44 | 45 | 使用 FastAPI-MCP 的最简单方法是直接将 MCP 服务器添加到您的 FastAPI 应用中: 46 | 47 | ```python 48 | from fastapi import FastAPI 49 | from fastapi_mcp import FastApiMCP 50 | 51 | app = FastAPI() 52 | 53 | mcp = FastApiMCP(app) 54 | 55 | # 直接将 MCP 服务器挂载到您的 FastAPI 应用 56 | mcp.mount() 57 | ``` 58 | 59 | 就是这样!您的自动生成的 MCP 服务器现在可以在 `https://app.base.url/mcp` 访问。 60 | 61 | ## 工具命名 62 | 63 | FastAPI-MCP 使用 FastAPI 路由中的`operation_id`作为 MCP 工具的名称。如果您不指定`operation_id`,FastAPI 会自动生成一个,但这些名称可能比较晦涩。 64 | 65 | 比较以下两个端点定义: 66 | 67 | ```python 68 | # 自动生成的 operation_id(类似于 "read_user_users__user_id__get") 69 | @app.get("/users/{user_id}") 70 | async def read_user(user_id: int): 71 | return {"user_id": user_id} 72 | 73 | # 显式 operation_id(工具将被命名为 "get_user_info") 74 | @app.get("/users/{user_id}", operation_id="get_user_info") 75 | async def read_user(user_id: int): 76 | return {"user_id": user_id} 77 | ``` 78 | 79 | 为了获得更清晰、更直观的工具名称,我们建议在 FastAPI 路由定义中添加显式的`operation_id`参数。 80 | 81 | 要了解更多信息,请阅读 FastAPI 官方文档中关于 [路径操作的高级配置](https://fastapi.tiangolo.com/advanced/path-operation-advanced-configuration/) 的部分。 82 | 83 | ## 高级用法 84 | 85 | FastAPI-MCP 提供了多种方式来自定义和控制 MCP 服务器的创建和配置。以下是一些高级用法模式: 86 | 87 | ### 自定义模式描述 88 | 89 | ```python 90 | from fastapi import FastAPI 91 | from fastapi_mcp import FastApiMCP 92 | 93 | app = FastAPI() 94 | 95 | mcp = FastApiMCP( 96 | app, 97 | name="我的 API MCP", 98 | describe_all_responses=True, # 在工具描述中包含所有可能的响应模式 99 | describe_full_response_schema=True # 在工具描述中包含完整的 JSON 模式 100 | ) 101 | 102 | mcp.mount() 103 | ``` 104 | 105 | ### 自定义公开的端点 106 | 107 | 您可以使用 Open API 操作 ID 或标签来控制哪些 FastAPI 端点暴露为 MCP 工具: 108 | 109 | ```python 110 | from fastapi import FastAPI 111 | from fastapi_mcp import FastApiMCP 112 | 113 | app = FastAPI() 114 | 115 | # 仅包含特定操作 116 | mcp = FastApiMCP( 117 | app, 118 | include_operations=["get_user", "create_user"] 119 | ) 120 | 121 | # 排除特定操作 122 | mcp = FastApiMCP( 123 | app, 124 | exclude_operations=["delete_user"] 125 | ) 126 | 127 | # 仅包含具有特定标签的操作 128 | mcp = FastApiMCP( 129 | app, 130 | include_tags=["users", "public"] 131 | ) 132 | 133 | # 排除具有特定标签的操作 134 | mcp = FastApiMCP( 135 | app, 136 | exclude_tags=["admin", "internal"] 137 | ) 138 | 139 | # 结合操作 ID 和标签(包含模式) 140 | mcp = FastApiMCP( 141 | app, 142 | include_operations=["user_login"], 143 | include_tags=["public"] 144 | ) 145 | 146 | mcp.mount() 147 | ``` 148 | 149 | 关于过滤的注意事项: 150 | - 您不能同时使用`include_operations`和`exclude_operations` 151 | - 您不能同时使用`include_tags`和`exclude_tags` 152 | - 您可以将操作过滤与标签过滤结合使用(例如,使用`include_operations`和`include_tags`) 153 | - 当结合过滤器时,将采取贪婪方法。匹配任一标准的端点都将被包含 154 | 155 | ### 与原始 FastAPI 应用分开部署 156 | 157 | 您不限于在创建 MCP 的同一个 FastAPI 应用上提供 MCP 服务。 158 | 159 | 您可以从一个 FastAPI 应用创建 MCP 服务器,并将其挂载到另一个应用上: 160 | 161 | ```python 162 | from fastapi import FastAPI 163 | from fastapi_mcp import FastApiMCP 164 | 165 | # 您的 API 应用 166 | api_app = FastAPI() 167 | # ... 在 api_app 上定义您的 API 端点 ... 168 | 169 | # 一个单独的 MCP 服务器应用 170 | mcp_app = FastAPI() 171 | 172 | # 从 API 应用创建 MCP 服务器 173 | mcp = FastApiMCP(api_app) 174 | 175 | # 将 MCP 服务器挂载到单独的应用 176 | mcp.mount(mcp_app) 177 | 178 | # 现在您可以分别运行两个应用: 179 | # uvicorn main:api_app --host api-host --port 8001 180 | # uvicorn main:mcp_app --host mcp-host --port 8000 181 | ``` 182 | 183 | ### 在 MCP 服务器创建后添加端点 184 | 185 | 如果您在创建 MCP 服务器后向 FastAPI 应用添加端点,您需要刷新服务器以包含它们: 186 | 187 | ```python 188 | from fastapi import FastAPI 189 | from fastapi_mcp import FastApiMCP 190 | 191 | app = FastAPI() 192 | # ... 定义初始端点 ... 193 | 194 | # 创建 MCP 服务器 195 | mcp = FastApiMCP(app) 196 | mcp.mount() 197 | 198 | # 在 MCP 服务器创建后添加新端点 199 | @app.get("/new/endpoint/", operation_id="new_endpoint") 200 | async def new_endpoint(): 201 | return {"message": "Hello, world!"} 202 | 203 | # 刷新 MCP 服务器以包含新端点 204 | mcp.setup_server() 205 | ``` 206 | 207 | ### 与 FastAPI 应用的通信 208 | 209 | FastAPI-MCP 默认使用 ASGI 传输,这意味着它直接与您的 FastAPI 应用通信,而不需要发送 HTTP 请求。这样更高效,也不需要基础 URL。 210 | 211 | 如果您需要指定自定义基础 URL 或使用不同的传输方法,您可以提供自己的 `httpx.AsyncClient`: 212 | 213 | ```python 214 | import httpx 215 | from fastapi import FastAPI 216 | from fastapi_mcp import FastApiMCP 217 | 218 | app = FastAPI() 219 | 220 | # 使用带有特定基础 URL 的自定义 HTTP 客户端 221 | custom_client = httpx.AsyncClient( 222 | base_url="https://api.example.com", 223 | timeout=30.0 224 | ) 225 | 226 | mcp = FastApiMCP( 227 | app, 228 | http_client=custom_client 229 | ) 230 | 231 | mcp.mount() 232 | ``` 233 | 234 | ## 示例 235 | 236 | 请参阅 [examples](examples) 目录以获取完整示例。 237 | 238 | ## 使用 SSE 连接到 MCP 服务器 239 | 240 | 一旦您的集成了 MCP 的 FastAPI 应用运行,您可以使用任何支持 SSE 的 MCP 客户端连接到它,例如 Cursor: 241 | 242 | 1. 运行您的应用。 243 | 244 | 2. 在 Cursor -> 设置 -> MCP 中,使用您的 MCP 服务器端点的URL(例如,`http://localhost:8000/mcp`)作为 sse。 245 | 246 | 3. Cursor 将自动发现所有可用的工具和资源。 247 | 248 | ## 使用 [mcp-proxy stdio](https://github.com/sparfenyuk/mcp-proxy?tab=readme-ov-file#1-stdio-to-sse) 连接到 MCP 服务器 249 | 250 | 如果您的 MCP 客户端不支持 SSE,例如 Claude Desktop: 251 | 252 | 1. 运行您的应用。 253 | 254 | 2. 安装 [mcp-proxy](https://github.com/sparfenyuk/mcp-proxy?tab=readme-ov-file#installing-via-pypi),例如:`uv tool install mcp-proxy`。 255 | 256 | 3. 在 Claude Desktop 的 MCP 配置文件(`claude_desktop_config.json`)中添加: 257 | 258 | 在 Windows 上: 259 | ```json 260 | { 261 | "mcpServers": { 262 | "my-api-mcp-proxy": { 263 | "command": "mcp-proxy", 264 | "args": ["http://127.0.0.1:8000/mcp"] 265 | } 266 | } 267 | } 268 | ``` 269 | 在 MacOS 上: 270 | ```json 271 | { 272 | "mcpServers": { 273 | "my-api-mcp-proxy": { 274 | "command": "/Full/Path/To/Your/Executable/mcp-proxy", 275 | "args": ["http://127.0.0.1:8000/mcp"] 276 | } 277 | } 278 | } 279 | ``` 280 | 通过在终端运行`which mcp-proxy`来找到 mcp-proxy 的路径。 281 | 282 | 4. Claude Desktop 将自动发现所有可用的工具和资源 283 | 284 | ## 开发和贡献 285 | 286 | 感谢您考虑为 FastAPI-MCP 做出贡献!我们鼓励社区发布问题和拉取请求。 287 | 288 | 在开始之前,请参阅我们的 [贡献指南](CONTRIBUTING.md)。 289 | 290 | ## 社区 291 | 292 | 加入 [MCParty Slack 社区](https://join.slack.com/t/themcparty/shared_invite/zt-30yxr1zdi-2FG~XjBA0xIgYSYuKe7~Xg),与其他 MCP 爱好者联系,提问,并分享您使用 FastAPI-MCP 的经验。 293 | 294 | ## 要求 295 | 296 | - Python 3.10+(推荐3.12) 297 | - uv 298 | 299 | ## 许可证 300 | 301 | MIT License. Copyright (c) 2024 Tadata Inc. 302 | -------------------------------------------------------------------------------- /docs/advanced/auth.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Authentication & Authorization 3 | icon: key 4 | --- 5 | 6 | FastAPI-MCP supports authentication and authorization using your existing FastAPI dependencies. 7 | 8 | It also supports the full OAuth 2 flow, compliant with [MCP Spec 2025-03-26](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization). 9 | 10 | It's worth noting that most MCP clients currently do not support the latest MCP spec, so for our examples we might use a bridge client such as `npx mcp-remote`. We recommend you use it as well, and we'll show our examples using it. 11 | 12 | ## Basic Token Passthrough 13 | 14 | If you just want to be able to pass a valid authorization header, without supporting a full authentication flow, you don't need to do anything special. 15 | 16 | You just need to make sure your MCP client is sending it: 17 | 18 | ```json {8-9, 13} 19 | { 20 | "mcpServers": { 21 | "remote-example": { 22 | "command": "npx", 23 | "args": [ 24 | "mcp-remote", 25 | "http://localhost:8000/mcp", 26 | "--header", 27 | "Authorization:${AUTH_HEADER}" 28 | ] 29 | }, 30 | "env": { 31 | "AUTH_HEADER": "Bearer " 32 | } 33 | } 34 | } 35 | ``` 36 | 37 | This is enough to pass the authorization header to your FastAPI endpoints. 38 | 39 | Optionally, if you want your MCP server to reject requests without an authorization header, you can add a dependency: 40 | 41 | ```python {1-2, 7-9} 42 | from fastapi import Depends 43 | from fastapi_mcp import FastApiMCP, AuthConfig 44 | 45 | mcp = FastApiMCP( 46 | app, 47 | name="Protected MCP", 48 | auth_config=AuthConfig( 49 | dependencies=[Depends(verify_auth)], 50 | ), 51 | ) 52 | mcp.mount() 53 | ``` 54 | 55 | For a complete working example of authorization header, check out the [Token Passthrough Example](https://github.com/tadata-org/fastapi_mcp/blob/main/examples/08_auth_example_token_passthrough.py) in the examples folder. 56 | 57 | ## OAuth Flow 58 | 59 | FastAPI-MCP supports the full OAuth 2 flow, compliant with [MCP Spec 2025-03-26](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization). 60 | 61 | It would look something like this: 62 | 63 | ```python {7-16} 64 | from fastapi import Depends 65 | from fastapi_mcp import FastApiMCP, AuthConfig 66 | 67 | mcp = FastApiMCP( 68 | app, 69 | name="MCP With OAuth", 70 | auth_config=AuthConfig( 71 | issuer=f"https://auth.example.com/", 72 | authorize_url=f"https://auth.example.com/authorize", 73 | oauth_metadata_url=f"https://auth.example.com/.well-known/oauth-authorization-server", 74 | audience="my-audience", 75 | client_id="my-client-id", 76 | client_secret="my-client-secret", 77 | dependencies=[Depends(verify_auth)], 78 | setup_proxies=True, 79 | ), 80 | ) 81 | 82 | mcp.mount() 83 | ``` 84 | 85 | And you can call it like: 86 | 87 | ```json 88 | { 89 | "mcpServers": { 90 | "fastapi-mcp": { 91 | "command": "npx", 92 | "args": [ 93 | "mcp-remote", 94 | "http://localhost:8000/mcp", 95 | "8080" // Optional port number. Necessary if you want your OAuth to work and you don't have dynamic client registration. 96 | ] 97 | } 98 | } 99 | } 100 | ``` 101 | 102 | You can use it with any OAuth provider that supports the OAuth 2 spec. See explanation on [AuthConfig](#authconfig-explained) for more details. 103 | 104 | ## Custom OAuth Metadata 105 | 106 | If you already have a properly configured OAuth server that works with MCP clients, or if you want full control over the metadata, you can provide your own OAuth metadata directly: 107 | 108 | ```python {9, 22} 109 | from fastapi import Depends 110 | from fastapi_mcp import FastApiMCP, AuthConfig 111 | 112 | mcp = FastApiMCP( 113 | app, 114 | name="MCP With Custom OAuth", 115 | auth_config=AuthConfig( 116 | # Provide your own complete OAuth metadata 117 | custom_oauth_metadata={ 118 | "issuer": "https://auth.example.com", 119 | "authorization_endpoint": "https://auth.example.com/authorize", 120 | "token_endpoint": "https://auth.example.com/token", 121 | "registration_endpoint": "https://auth.example.com/register", 122 | "scopes_supported": ["openid", "profile", "email"], 123 | "response_types_supported": ["code"], 124 | "grant_types_supported": ["authorization_code"], 125 | "token_endpoint_auth_methods_supported": ["none"], 126 | "code_challenge_methods_supported": ["S256"] 127 | }, 128 | 129 | # Your auth checking dependency 130 | dependencies=[Depends(verify_auth)], 131 | ), 132 | ) 133 | 134 | mcp.mount() 135 | ``` 136 | 137 | This approach gives you complete control over the OAuth metadata and is useful when: 138 | - You have a fully MCP-compliant OAuth server already configured 139 | - You need to customize the OAuth flow beyond what the proxy approach offers 140 | - You're using a custom or specialized OAuth implementation 141 | 142 | For this to work, you have to make sure mcp-remote is running [on a fixed port](#add-a-fixed-port-to-mcp-remote), for example `8080`, and then configure the callback URL to `http://127.0.0.1:8080/oauth/callback` in your OAuth provider. 143 | 144 | ## Working Example with Auth0 145 | 146 | For a complete working example of OAuth integration with Auth0, check out the [Auth0 Example](https://github.com/tadata-org/fastapi_mcp/blob/main/examples/09_auth_example_auth0.py) in the examples folder. This example demonstrates the simple case of using Auth0 as an OAuth provider, with a working example of the OAuth flow. 147 | 148 | For it to work, you need an .env file in the root of the project with the following variables: 149 | 150 | ``` 151 | AUTH0_DOMAIN=your-tenant.auth0.com 152 | AUTH0_AUDIENCE=https://your-tenant.auth0.com/api/v2/ 153 | AUTH0_CLIENT_ID=your-client-id 154 | AUTH0_CLIENT_SECRET=your-client-secret 155 | ``` 156 | 157 | You also need to make sure to configure callback URLs properly in your Auth0 dashboard. 158 | 159 | ## AuthConfig Explained 160 | 161 | ### `setup_proxies=True` 162 | 163 | Most OAuth providers need some adaptation to work with MCP clients. This is where `setup_proxies=True` comes in - it creates proxy endpoints that make your OAuth provider compatible with MCP clients: 164 | 165 | ```python 166 | mcp = FastApiMCP( 167 | app, 168 | auth_config=AuthConfig( 169 | # Your OAuth provider information 170 | issuer="https://auth.example.com", 171 | authorize_url="https://auth.example.com/authorize", 172 | oauth_metadata_url="https://auth.example.com/.well-known/oauth-authorization-server", 173 | 174 | # Credentials registered with your OAuth provider 175 | client_id="your-client-id", 176 | client_secret="your-client-secret", 177 | 178 | # Recommended, since some clients don't specify them 179 | audience="your-api-audience", 180 | default_scope="openid profile email", 181 | 182 | # Your auth checking dependency 183 | dependencies=[Depends(verify_auth)], 184 | 185 | # Create compatibility proxies - usually needed! 186 | setup_proxies=True, 187 | ), 188 | ) 189 | ``` 190 | 191 | You also need to make sure to configure callback URLs properly in your OAuth provider. With mcp-remote for example, you have to [use a fixed port](#add-a-fixed-port-to-mcp-remote). 192 | 193 | ### Why Use Proxies? 194 | 195 | Proxies solve several problems: 196 | 197 | 1. **Missing registration endpoints**: 198 | The MCP spec expects OAuth providers to support [dynamic client registration (RFC 7591)](https://datatracker.ietf.org/doc/html/rfc7591), but many don't. 199 | Furthermore, dynamic client registration is probably overkill for most use cases. 200 | The `setup_fake_dynamic_registration` option (True by default) creates a compatible endpoint that just returns a static client ID and secret. 201 | 202 | 2. **Scope handling**: 203 | Some MCP clients don't properly request scopes, so our proxy adds the necessary scopes for you. 204 | 205 | 3. **Audience requirements**: 206 | Some OAuth providers require an audience parameter that MCP clients don't always provide. The proxy adds this automatically. 207 | 208 | ### Add a fixed port to mcp-remote 209 | 210 | ```json 211 | { 212 | "mcpServers": { 213 | "example": { 214 | "command": "npx", 215 | "args": [ 216 | "mcp-remote", 217 | "http://localhost:8000/mcp", 218 | "8080" 219 | ] 220 | } 221 | } 222 | } 223 | ``` 224 | 225 | Normally, mcp-remote will start on a random port, making it impossible to configure the OAuth provider's callback URL properly. 226 | 227 | You have to make sure mcp-remote is running on a fixed port, for example `8080`, and then configure the callback URL to `http://127.0.0.1:8080/oauth/callback` in your OAuth provider. 228 | -------------------------------------------------------------------------------- /docs/advanced/deploy.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Deploying the Server 3 | icon: play 4 | --- 5 | 6 | ## Deploying separately from original FastAPI app 7 | 8 | You are not limited to serving the MCP on the same FastAPI app from which it was created. 9 | 10 | You can create an MCP server from one FastAPI app, and mount it to a different app: 11 | 12 | ```python {9, 15, } 13 | from fastapi import FastAPI 14 | from fastapi_mcp import FastApiMCP 15 | 16 | # Your API app 17 | api_app = FastAPI() 18 | # ... define your API endpoints on api_app ... 19 | 20 | # A separate app for the MCP server 21 | mcp_app = FastAPI() 22 | 23 | # Create MCP server from the API app 24 | mcp = FastApiMCP(api_app) 25 | 26 | # Mount the MCP server to the separate app 27 | mcp.mount(mcp_app) 28 | ``` 29 | 30 | Then, you can run both apps separately: 31 | 32 | ```bash 33 | uvicorn main:api_app --host api-host --port 8001 34 | uvicorn main:mcp_app --host mcp-host --port 8000 35 | ``` -------------------------------------------------------------------------------- /docs/advanced/refresh.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Refreshing the Server 3 | description: Adding endpoints after MCP server creation 4 | icon: arrows-rotate 5 | --- 6 | 7 | If you add endpoints to your FastAPI app after creating the MCP server, you'll need to refresh the server to include them: 8 | 9 | ```python {9-12, 15} 10 | from fastapi import FastAPI 11 | from fastapi_mcp import FastApiMCP 12 | 13 | app = FastAPI() 14 | 15 | mcp = FastApiMCP(app) 16 | mcp.mount() 17 | 18 | # Add new endpoints after MCP server creation 19 | @app.get("/new/endpoint/", operation_id="new_endpoint") 20 | async def new_endpoint(): 21 | return {"message": "Hello, world!"} 22 | 23 | # Refresh the MCP server to include the new endpoint 24 | mcp.setup_server() 25 | ``` -------------------------------------------------------------------------------- /docs/advanced/transport.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Transport 3 | description: How to communicate with the FastAPI app 4 | icon: car 5 | --- 6 | 7 | FastAPI-MCP uses ASGI transport by default, which means it communicates directly with your FastAPI app without making HTTP requests. This is more efficient and doesn't require a base URL. 8 | 9 | It's not even necessary that the FastAPI server will run. 10 | 11 | If you need to specify a custom base URL or use a different transport method, you can provide your own `httpx.AsyncClient`: 12 | 13 | ```python {7-10, 14} 14 | import httpx 15 | from fastapi import FastAPI 16 | from fastapi_mcp import FastApiMCP 17 | 18 | app = FastAPI() 19 | 20 | custom_client = httpx.AsyncClient( 21 | base_url="https://api.example.com", 22 | timeout=30.0 23 | ) 24 | 25 | mcp = FastApiMCP( 26 | app, 27 | http_client=custom_client 28 | ) 29 | 30 | mcp.mount() 31 | ``` 32 | -------------------------------------------------------------------------------- /docs/configurations/customization.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Customization 3 | icon: pen 4 | --- 5 | 6 | ## Server metadata 7 | 8 | You can define the MCP server name and description by modifying: 9 | 10 | ```python {8-9} 11 | from fastapi import FastAPI 12 | from fastapi_mcp import FastApiMCP 13 | 14 | app = FastAPI() 15 | 16 | mcp = FastApiMCP( 17 | app, 18 | name="My API MCP", 19 | description="Very cool MCP server", 20 | ) 21 | mcp.mount() 22 | ``` 23 | 24 | ## Tool and schema descriptions 25 | 26 | When creating the MCP server you can include all possible response schemas in tool descriptions by changing the flag `describe_all_responses`, or include full JSON schema in tool descriptions by changing `describe_full_response_schema`: 27 | 28 | ```python {10-11} 29 | from fastapi import FastAPI 30 | from fastapi_mcp import FastApiMCP 31 | 32 | app = FastAPI() 33 | 34 | mcp = FastApiMCP( 35 | app, 36 | name="My API MCP", 37 | description="Very cool MCP server", 38 | describe_all_responses=True, 39 | describe_full_response_schema=True 40 | ) 41 | 42 | mcp.mount() 43 | ``` 44 | 45 | ## Customizing Exposed Endpoints 46 | 47 | You can control which FastAPI endpoints are exposed as MCP tools using Open API operation IDs or tags to: 48 | - Only include specific operations 49 | - Exclude specific operations 50 | - Only include operations with specific tags 51 | - Exclude operations with specific tags 52 | - Combine operation IDs and tags 53 | 54 | ### Code samples 55 | 56 | The relevant arguments for these configurations are `include_operations`, `exclude_operations`, `include_tags`, `exclude_tags` and can be used as follows: 57 | 58 | 59 | ```python Include Operations {8} 60 | from fastapi import FastAPI 61 | from fastapi_mcp import FastApiMCP 62 | 63 | app = FastAPI() 64 | 65 | mcp = FastApiMCP( 66 | app, 67 | include_operations=["get_user", "create_user"] 68 | ) 69 | mcp.mount() 70 | ``` 71 | 72 | ```python Exclude Operations {8} 73 | from fastapi import FastAPI 74 | from fastapi_mcp import FastApiMCP 75 | 76 | app = FastAPI() 77 | 78 | mcp = FastApiMCP( 79 | app, 80 | exclude_operations=["delete_user"] 81 | ) 82 | mcp.mount() 83 | ``` 84 | 85 | ```python Include Tags {8} 86 | from fastapi import FastAPI 87 | from fastapi_mcp import FastApiMCP 88 | 89 | app = FastAPI() 90 | 91 | mcp = FastApiMCP( 92 | app, 93 | include_tags=["users", "public"] 94 | ) 95 | mcp.mount() 96 | ``` 97 | 98 | ```python Exclude Tags {8} 99 | from fastapi import FastAPI 100 | from fastapi_mcp import FastApiMCP 101 | 102 | app = FastAPI() 103 | 104 | mcp = FastApiMCP( 105 | app, 106 | exclude_tags=["admin", "internal"] 107 | ) 108 | mcp.mount() 109 | ``` 110 | 111 | ```python Combined (include mode) {8-9} 112 | from fastapi import FastAPI 113 | from fastapi_mcp import FastApiMCP 114 | 115 | app = FastAPI() 116 | 117 | mcp = FastApiMCP( 118 | app, 119 | include_operations=["user_login"], 120 | include_tags=["public"] 121 | ) 122 | mcp.mount() 123 | ``` 124 | 125 | 126 | ### Notes on filtering 127 | 128 | - You cannot use both `include_operations` and `exclude_operations` at the same time 129 | - You cannot use both `include_tags` and `exclude_tags` at the same time 130 | - You can combine operation filtering with tag filtering (e.g., use `include_operations` with `include_tags`) 131 | - When combining filters, a greedy approach will be taken. Endpoints matching either criteria will be included -------------------------------------------------------------------------------- /docs/configurations/tool-naming.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Tool Naming 3 | icon: input-text 4 | --- 5 | 6 | FastAPI-MCP uses the `operation_id` from your FastAPI routes as the MCP tool names. When you don't specify an `operation_id`, FastAPI auto-generates one, but these can be cryptic. 7 | 8 | Compare these two endpoint definitions: 9 | 10 | ```python {2, 7} 11 | # Auto-generated operation_id (something like "read_user_users__user_id__get") 12 | @app.get("/users/{user_id}") 13 | async def read_user(user_id: int): 14 | return {"user_id": user_id} 15 | 16 | # Explicit operation_id (tool will be named "get_user_info") 17 | @app.get("/users/{user_id}", operation_id="get_user_info") 18 | async def read_user(user_id: int): 19 | return {"user_id": user_id} 20 | ``` 21 | 22 | For clearer, more intuitive tool names, we recommend adding explicit `operation_id` parameters to your FastAPI route definitions. 23 | 24 | To find out more, read FastAPI's official docs about [advanced config of path operations.](https://fastapi.tiangolo.com/advanced/path-operation-advanced-configuration/) 25 | -------------------------------------------------------------------------------- /docs/docs.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://mintlify.com/docs.json", 3 | "name": "FastAPI MCP", 4 | "background": { 5 | "color": { 6 | "dark": "#222831", 7 | "light": "#EEEEEE" 8 | }, 9 | "decoration": "windows" 10 | }, 11 | "colors": { 12 | "primary": "#6d45dc", 13 | "light": "#9f8ded", 14 | "dark": "#6a42d7" 15 | }, 16 | "description": "Convert your FastAPI app into a MCP server with zero configuration", 17 | "favicon": "media/favicon.png", 18 | "navigation": { 19 | "groups": [ 20 | { 21 | "group": "Getting Started", 22 | "pages": [ 23 | "getting-started/welcome", 24 | "getting-started/installation", 25 | "getting-started/quickstart", 26 | "getting-started/FAQ", 27 | "getting-started/best-practices" 28 | ] 29 | }, 30 | { 31 | "group": "Configurations", 32 | "pages": [ 33 | "configurations/tool-naming", 34 | "configurations/customization" 35 | ] 36 | }, 37 | { 38 | "group": "Advanced Usage", 39 | "pages": [ 40 | "advanced/auth", 41 | "advanced/deploy", 42 | "advanced/refresh", 43 | "advanced/transport" 44 | ] 45 | } 46 | ], 47 | "global": { 48 | "anchors": [ 49 | { 50 | "anchor": "Documentation", 51 | "href": "https://fastapi-mcp.tadata.com/", 52 | "icon": "book-open-cover" 53 | }, 54 | { 55 | "anchor": "Community", 56 | "href": "https://join.slack.com/t/themcparty/shared_invite/zt-30yxr1zdi-2FG~XjBA0xIgYSYuKe7~Xg", 57 | "icon": "slack" 58 | }, 59 | { 60 | "anchor": "Blog", 61 | "href": "https://medium.com/@miki_45906", 62 | "icon": "newspaper" 63 | } 64 | ] 65 | } 66 | }, 67 | "logo": { 68 | "light": "/media/dark_logo.png", 69 | "dark": "/media/light_logo.png" 70 | }, 71 | "navbar": { 72 | "primary": { 73 | "href": "https://github.com/tadata-org/fastapi_mcp", 74 | "type": "github" 75 | } 76 | }, 77 | "footer": { 78 | "socials": { 79 | "x": "https://x.com/makhlevich", 80 | "github": "https://github.com/tadata-org/fastapi_mcp", 81 | "website": "https://tadata.com/" 82 | } 83 | }, 84 | "theme": "mint" 85 | } -------------------------------------------------------------------------------- /docs/getting-started/FAQ.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: FAQ 3 | description: Frequently Asked Questions 4 | icon: question 5 | --- 6 | 7 | ## Usage 8 | ### How do I configure HTTP request timeouts? 9 | By default, HTTP requests timeout after 5 seconds. If you have API endpoints that take longer to respond, you can configure a custom timeout by injecting your own httpx client. 10 | 11 | For a working example, see [Configure HTTP Timeout Example](https://github.com/tadata-org/fastapi_mcp/blob/main/examples/07_configure_http_timeout_example.py). 12 | 13 | ### Why are my tools not showing up in the MCP inspector? 14 | If you add endpoints after creating and mounting the MCP server, they won't be automatically registered as tools. You need to either: 15 | 1. Move the MCP creation after all your endpoint definitions 16 | 2. Call `mcp.setup_server()` after adding new endpoints to re-register all tools 17 | 18 | For a working example, see [Reregister Tools Example](https://github.com/tadata-org/fastapi_mcp/blob/main/examples/05_reregister_tools_example.py). 19 | 20 | ### Can I add custom tools other than FastAPI endpoints? 21 | Currently, FastApiMCP only supports tools that are derived from FastAPI endpoints. If you need to add custom tools that don't correspond to API endpoints, you can: 22 | 1. Create a FastAPI endpoint that wraps your custom functionality 23 | 2. Contribute to the project by implementing custom tool support 24 | 25 | Follow the discussion in [issue #75](https://github.com/tadata-org/fastapi_mcp/issues/75) for updates on this feature request. 26 | If you have specific use cases for custom tools, please share them in the issue to help guide the implementation. 27 | 28 | ### How do I test my FastApiMCP server is working? 29 | To verify your FastApiMCP server is working properly, you can use the MCP Inspector tool. Here's how: 30 | 31 | 1. Start your FastAPI application 32 | 2. Open a new terminal and run the MCP Inspector: 33 | ```bash 34 | npx @modelcontextprotocol/inspector node build/index.js 35 | ``` 36 | 3. Connect to your MCP server by entering the mount path URL (default: `http://127.0.0.1:8000/mcp`) 37 | 4. Navigate to the `Tools` section and click `List Tools` to see all available endpoints 38 | 5. Test an endpoint by: 39 | - Selecting a tool from the list 40 | - Filling in any required parameters 41 | - Clicking `Run Tool` to execute 42 | 6. Check your server logs for additional debugging information if needed 43 | 44 | This will help confirm that your MCP server is properly configured and your endpoints are accessible. 45 | 46 | ## Development 47 | 48 | ### Can I contribute to the project? 49 | Yes! Please read our [CONTRIBUTING.md](https://github.com/tadata-org/fastapi_mcp/blob/main/CONTRIBUTING.md) file for detailed guidelines on how to contribute to the project and where to start. 50 | 51 | ## Support 52 | 53 | ### Where can I get help? 54 | - Check the documentation 55 | - Open an issue on GitHub 56 | - Join our community chat [MCParty Slack community](https://join.slack.com/t/themcparty/shared_invite/zt-30yxr1zdi-2FG~XjBA0xIgYSYuKe7~Xg) 57 | -------------------------------------------------------------------------------- /docs/getting-started/best-practices.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Best Practices 3 | icon: thumbs-up 4 | --- 5 | 6 | This guide outlines best practices for converting standard APIs into Model Context Protocol (MCP) tools for use with AI agents. Proper tool design helps ensure LLMs can understand and safely use your APIs. 7 | By following these best practices, you can build safer, more intuitive MCP tools that enhance the capabilities of LLM agents. 8 | 9 | 10 | ## Tool Selection 11 | 12 | - **Be selective:** 13 | Avoid exposing every endpoint as a tool. LLM clients perform better with a limited number of well-defined tools, and providers often impose tool limits. 14 | 15 | - **Prioritize safety:** 16 | Do **not** expose `PUT` or `DELETE` endpoints unless absolutely necessary. LLMs are non-deterministic and could unintentionally alter or damage systems or databases. 17 | 18 | - **Focus on data retrieval:** 19 | Prefer `GET` endpoints that return data safely and predictably. 20 | 21 | - **Emphasize meaningful workflows:** 22 | Expose endpoints that reflect clear, goal-oriented tasks. Tools with focused actions are easier for agents to understand and use effectively. 23 | 24 | ## Tool Naming 25 | 26 | - **Use short, descriptive names:** 27 | Helps LLMs select and use the right tool. Know that some MCP clients restrict tool name length. 28 | 29 | - **Follow naming constraints:** 30 | - Must start with a letter 31 | - Can include only letters, numbers, and underscores 32 | - Avoid hyphens (e.g., AWS Nova does **not** support them) 33 | - Use either `camelCase` or `snake_case` consistently across all tools 34 | 35 | - **Ensure uniqueness:** 36 | Each tool name should be unique and clearly indicate its function. 37 | 38 | ## Documentation 39 | 40 | - **Describe every tool meaningfully:** 41 | Provide a clear and concise summary of what each tool does. 42 | 43 | - **Include usage examples and parameter descriptions:** 44 | These help LLMs understand how to use the tool correctly. 45 | 46 | - **Standardize documentation across tools:** 47 | Keep formatting and structure consistent to maintain quality and readability. 48 | 49 | 50 | -------------------------------------------------------------------------------- /docs/getting-started/installation.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Installation 3 | icon: arrow-down-to-line 4 | --- 5 | 6 | ## Install FastAPI-MCP 7 | 8 | We recommend using [uv](https://docs.astral.sh/uv/), a fast Python package installer: 9 | 10 | ```bash 11 | uv add fastapi-mcp 12 | ``` 13 | 14 | Alternatively, you can install with `pip` or `uv pip`: 15 | 16 | 17 | ```bash uv 18 | uv pip install fastapi-mcp 19 | ``` 20 | 21 | ```bash pip 22 | pip install fastapi-mcp 23 | ``` 24 | 25 | -------------------------------------------------------------------------------- /docs/getting-started/quickstart.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Quickstart 3 | icon: rocket 4 | --- 5 | 6 | This guide will help you quickly run your first MCP server using FastAPI-MCP. 7 | 8 | If you haven't already installed FastAPI-MCP, follow the [installation instructions](/getting-started/installation). 9 | 10 | ## Creating a basic MCP server 11 | 12 | To create a basic MCP server, import or create a FastAPI app, wrap it with the `FastApiMCP` class and mount the MCP to your existing application: 13 | 14 | ```python {2, 8, 11} 15 | from fastapi import FastAPI 16 | from fastapi_mcp import FastApiMCP 17 | 18 | # Create (or import) a FastAPI app 19 | app = FastAPI() 20 | 21 | # Create an MCP server based on this app 22 | mcp = FastApiMCP(app) 23 | 24 | # Mount the MCP server directly to your app 25 | mcp.mount() 26 | ``` 27 | 28 | For more usage examples, see [Examples](https://github.com/tadata-org/fastapi_mcp/tree/main/examples) section in the project. 29 | 30 | ## Running the server 31 | 32 | By running your FastAPI, your MCP will run at `https://app.base.url/mcp`. 33 | 34 | For example, by using uvicorn, add to your code: 35 | ```python {9-11} 36 | from fastapi import FastAPI 37 | from fastapi_mcp import FastApiMCP 38 | 39 | app = FastAPI() 40 | 41 | mcp = FastApiMCP(app) 42 | mcp.mount() 43 | 44 | if __name__ == "__main__": 45 | import uvicorn 46 | uvicorn.run(app, host="0.0.0.0", port=8000) 47 | ``` 48 | and run the server using `python fastapi_mcp_server.py`, which will serve you the MCP at `http://localhost:8000/mcp`. 49 | 50 | ## Connecting a client to the MCP server 51 | 52 | Once your FastAPI app with MCP integration is running, you would want to connect it to an MCP client. 53 | 54 | ### Connecting to the MCP Server using SSE 55 | 56 | For any MCP client supporting SSE, you will simply need to provide the MCP url. 57 | 58 | All the most popular MCP clients (Claude Desktop, Cursor & Windsurf) use the following config format: 59 | 60 | ```json 61 | { 62 | "mcpServers": { 63 | "fastapi-mcp": { 64 | "url": "http://localhost:8000/mcp" 65 | } 66 | } 67 | } 68 | ``` 69 | 70 | ### Connecting to the MCP Server using [mcp-remote](https://www.npmjs.com/package/mcp-remote) 71 | 72 | If you want to support authentication, or your MCP client does not support SSE, we recommend using `mcp-remote` as a bridge. 73 | 74 | ```json 75 | { 76 | "mcpServers": { 77 | "fastapi-mcp": { 78 | "command": "npx", 79 | "args": [ 80 | "mcp-remote", 81 | "http://localhost:8000/mcp", 82 | "8080" // Optional port number. Necessary if you want your OAuth to work and you don't have dynamic client registration. 83 | ] 84 | } 85 | } 86 | } 87 | ``` 88 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /docs/getting-started/welcome.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Welcome to FastAPI-MCP!" 3 | sidebarTitle: "Welcome!" 4 | description: Expose your FastAPI endpoints as Model Context Protocol (MCP) tools, with Auth! 5 | 6 | icon: hand-wave 7 | --- 8 | 9 | MCP (Model Context Protocol) is the emerging standard to define how AI agents communicate with applications. Using FastAPI-MCP, creating a secured MCP server to your application takes only 3 lines of code: 10 | 11 | ```python {2, 6, 7} 12 | from fastapi import FastAPI 13 | from fastapi_mcp import FastApiMCP 14 | 15 | app = FastAPI() 16 | 17 | mcp = FastApiMCP(app) 18 | mcp.mount() 19 | ``` 20 | That's it! Your auto-generated MCP server is now available at `https://app.base.url/mcp` 21 | 22 | ## Features 23 | 24 | - [**Authentication**](/advanced/auth) built in, using your existing FastAPI dependencies! 25 | 26 | - **FastAPI-native:** Not just another OpenAPI -> MCP converter 27 | 28 | - **Zero configuration** required - just point it at your FastAPI app and it works 29 | 30 | - **Preserving schemas** of your request models and response models 31 | 32 | - **Preserve documentation** of all your endpoints, just as it is in Swagger 33 | 34 | - [**Flexible deployment**](/advanced/deploy) - Mount your MCP server to the same app, or deploy separately 35 | 36 | - [**ASGI transport**](/advanced/transport) - Uses FastAPI's ASGI interface directly for efficient communication 37 | -------------------------------------------------------------------------------- /docs/media/dark_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tadata-org/fastapi_mcp/f180bda8e66c7e8d3a767efb1aad6f21f26ab9c0/docs/media/dark_logo.png -------------------------------------------------------------------------------- /docs/media/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tadata-org/fastapi_mcp/f180bda8e66c7e8d3a767efb1aad6f21f26ab9c0/docs/media/favicon.png -------------------------------------------------------------------------------- /docs/media/light_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tadata-org/fastapi_mcp/f180bda8e66c7e8d3a767efb1aad6f21f26ab9c0/docs/media/light_logo.png -------------------------------------------------------------------------------- /examples/01_basic_usage_example.py: -------------------------------------------------------------------------------- 1 | from examples.shared.apps.items import app # The FastAPI app 2 | from examples.shared.setup import setup_logging 3 | 4 | from fastapi_mcp import FastApiMCP 5 | 6 | setup_logging() 7 | 8 | # Add MCP server to the FastAPI app 9 | mcp = FastApiMCP(app) 10 | 11 | # Mount the MCP server to the FastAPI app 12 | mcp.mount() 13 | 14 | 15 | if __name__ == "__main__": 16 | import uvicorn 17 | 18 | uvicorn.run(app, host="0.0.0.0", port=8000) -------------------------------------------------------------------------------- /examples/02_full_schema_description_example.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | This example shows how to describe the full response schema instead of just a response example. 4 | """ 5 | from examples.shared.apps.items import app # The FastAPI app 6 | from examples.shared.setup import setup_logging 7 | 8 | from fastapi_mcp import FastApiMCP 9 | 10 | setup_logging() 11 | 12 | # Add MCP server to the FastAPI app 13 | mcp = FastApiMCP( 14 | app, 15 | name="Item API MCP", 16 | description="MCP server for the Item API", 17 | describe_full_response_schema=True, # Describe the full response JSON-schema instead of just a response example 18 | describe_all_responses=True, # Describe all the possible responses instead of just the success (2XX) response 19 | ) 20 | 21 | mcp.mount() 22 | 23 | if __name__ == "__main__": 24 | import uvicorn 25 | 26 | uvicorn.run(app, host="0.0.0.0", port=8000) 27 | -------------------------------------------------------------------------------- /examples/03_custom_exposed_endpoints_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example shows how to customize exposing endpoints by filtering operation IDs and tags. 3 | Notes on filtering: 4 | - You cannot use both `include_operations` and `exclude_operations` at the same time 5 | - You cannot use both `include_tags` and `exclude_tags` at the same time 6 | - You can combine operation filtering with tag filtering (e.g., use `include_operations` with `include_tags`) 7 | - When combining filters, a greedy approach will be taken. Endpoints matching either criteria will be included 8 | """ 9 | from examples.shared.apps.items import app # The FastAPI app 10 | from examples.shared.setup import setup_logging 11 | 12 | from fastapi_mcp import FastApiMCP 13 | 14 | setup_logging() 15 | 16 | # Examples demonstrating how to filter MCP tools by operation IDs and tags 17 | 18 | # Filter by including specific operation IDs 19 | include_operations_mcp = FastApiMCP( 20 | app, 21 | name="Item API MCP - Included Operations", 22 | include_operations=["get_item", "list_items"], 23 | ) 24 | 25 | # Filter by excluding specific operation IDs 26 | exclude_operations_mcp = FastApiMCP( 27 | app, 28 | name="Item API MCP - Excluded Operations", 29 | exclude_operations=["create_item", "update_item", "delete_item"], 30 | ) 31 | 32 | # Filter by including specific tags 33 | include_tags_mcp = FastApiMCP( 34 | app, 35 | name="Item API MCP - Included Tags", 36 | include_tags=["items"], 37 | ) 38 | 39 | # Filter by excluding specific tags 40 | exclude_tags_mcp = FastApiMCP( 41 | app, 42 | name="Item API MCP - Excluded Tags", 43 | exclude_tags=["search"], 44 | ) 45 | 46 | # Combine operation IDs and tags (include mode) 47 | combined_include_mcp = FastApiMCP( 48 | app, 49 | name="Item API MCP - Combined Include", 50 | include_operations=["delete_item"], 51 | include_tags=["search"], 52 | ) 53 | 54 | # Mount all MCP servers with different paths 55 | include_operations_mcp.mount(mount_path="/include-operations-mcp") 56 | exclude_operations_mcp.mount(mount_path="/exclude-operations-mcp") 57 | include_tags_mcp.mount(mount_path="/include-tags-mcp") 58 | exclude_tags_mcp.mount(mount_path="/exclude-tags-mcp") 59 | combined_include_mcp.mount(mount_path="/combined-include-mcp") 60 | 61 | if __name__ == "__main__": 62 | import uvicorn 63 | 64 | print("Server is running with multiple MCP endpoints:") 65 | print(" - /include-operations-mcp: Only get_item and list_items operations") 66 | print(" - /exclude-operations-mcp: All operations except create_item, update_item, and delete_item") 67 | print(" - /include-tags-mcp: Only operations with the 'items' tag") 68 | print(" - /exclude-tags-mcp: All operations except those with the 'search' tag") 69 | print(" - /combined-include-mcp: Operations with 'search' tag or delete_item operation") 70 | uvicorn.run(app, host="0.0.0.0", port=8000) 71 | -------------------------------------------------------------------------------- /examples/04_separate_server_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example shows how to run the MCP server and the FastAPI app separately. 3 | You can create an MCP server from one FastAPI app, and mount it to a different app. 4 | """ 5 | from fastapi import FastAPI 6 | 7 | from examples.shared.apps.items import app 8 | from examples.shared.setup import setup_logging 9 | 10 | from fastapi_mcp import FastApiMCP 11 | 12 | setup_logging() 13 | 14 | MCP_SERVER_HOST = "localhost" 15 | MCP_SERVER_PORT = 8000 16 | ITEMS_API_HOST = "localhost" 17 | ITEMS_API_PORT = 8001 18 | 19 | 20 | # Take the FastAPI app only as a source for MCP server generation 21 | mcp = FastApiMCP(app) 22 | 23 | # Mount the MCP server to a separate FastAPI app 24 | mcp_app = FastAPI() 25 | mcp.mount(mcp_app) 26 | 27 | # Run the MCP server separately from the original FastAPI app. 28 | # It still works 🚀 29 | # Your original API is **not exposed**, only via the MCP server. 30 | if __name__ == "__main__": 31 | import uvicorn 32 | 33 | uvicorn.run(mcp_app, host="0.0.0.0", port=8000) -------------------------------------------------------------------------------- /examples/05_reregister_tools_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example shows how to re-register tools if you add endpoints after the MCP server was created. 3 | """ 4 | from examples.shared.apps.items import app # The FastAPI app 5 | from examples.shared.setup import setup_logging 6 | 7 | from fastapi_mcp import FastApiMCP 8 | 9 | setup_logging() 10 | 11 | mcp = FastApiMCP(app) # Add MCP server to the FastAPI app 12 | mcp.mount() # MCP server 13 | 14 | 15 | # This endpoint will not be registered as a tool, since it was added after the MCP instance was created 16 | @app.get("/new/endpoint/", operation_id="new_endpoint", response_model=dict[str, str]) 17 | async def new_endpoint(): 18 | return {"message": "Hello, world!"} 19 | 20 | 21 | # But if you re-run the setup, the new endpoints will now be exposed. 22 | mcp.setup_server() 23 | 24 | 25 | if __name__ == "__main__": 26 | import uvicorn 27 | 28 | uvicorn.run(app, host="0.0.0.0", port=8000) 29 | -------------------------------------------------------------------------------- /examples/06_custom_mcp_router_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example shows how to mount the MCP server to a specific APIRouter, giving a custom mount path. 3 | """ 4 | from examples.shared.apps.items import app # The FastAPI app 5 | from examples.shared.setup import setup_logging 6 | 7 | from fastapi import APIRouter 8 | from fastapi_mcp import FastApiMCP 9 | 10 | setup_logging() 11 | 12 | other_router = APIRouter(prefix="/other/route") 13 | app.include_router(other_router) 14 | 15 | mcp = FastApiMCP(app) 16 | 17 | # Mount the MCP server to a specific router. 18 | # It will now only be available at `/other/route/mcp` 19 | mcp.mount(other_router) 20 | 21 | 22 | if __name__ == "__main__": 23 | import uvicorn 24 | 25 | uvicorn.run(app, host="0.0.0.0", port=8000) 26 | -------------------------------------------------------------------------------- /examples/07_configure_http_timeout_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example shows how to configure the HTTP client timeout for the MCP server. 3 | In case you have API endpoints that take longer than 5 seconds to respond, you can increase the timeout. 4 | """ 5 | from examples.shared.apps.items import app # The FastAPI app 6 | from examples.shared.setup import setup_logging 7 | 8 | import httpx 9 | 10 | from fastapi_mcp import FastApiMCP 11 | 12 | setup_logging() 13 | 14 | 15 | mcp = FastApiMCP( 16 | app, 17 | http_client=httpx.AsyncClient(timeout=20) 18 | ) 19 | mcp.mount() 20 | 21 | 22 | if __name__ == "__main__": 23 | import uvicorn 24 | 25 | uvicorn.run(app, host="0.0.0.0", port=8000) 26 | -------------------------------------------------------------------------------- /examples/08_auth_example_token_passthrough.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example shows how to reject any request without a valid token passed in the Authorization header. 3 | 4 | In order to configure the auth header, the config file for the MCP server should looks like this: 5 | ```json 6 | { 7 | "mcpServers": { 8 | "remote-example": { 9 | "command": "npx", 10 | "args": [ 11 | "mcp-remote", 12 | "http://localhost:8000/mcp", 13 | "--header", 14 | "Authorization:${AUTH_HEADER}" 15 | ] 16 | }, 17 | "env": { 18 | "AUTH_HEADER": "Bearer " 19 | } 20 | } 21 | } 22 | ``` 23 | """ 24 | from examples.shared.apps.items import app # The FastAPI app 25 | from examples.shared.setup import setup_logging 26 | 27 | from fastapi import Depends 28 | from fastapi.security import HTTPBearer 29 | 30 | from fastapi_mcp import FastApiMCP, AuthConfig 31 | 32 | setup_logging() 33 | 34 | # Scheme for the Authorization header 35 | token_auth_scheme = HTTPBearer() 36 | 37 | # Create a private endpoint 38 | @app.get("/private") 39 | async def private(token = Depends(token_auth_scheme)): 40 | return token.credentials 41 | 42 | # Create the MCP server with the token auth scheme 43 | mcp = FastApiMCP( 44 | app, 45 | name="Protected MCP", 46 | auth_config=AuthConfig( 47 | dependencies=[Depends(token_auth_scheme)], 48 | ), 49 | ) 50 | 51 | # Mount the MCP server 52 | mcp.mount() 53 | 54 | 55 | if __name__ == "__main__": 56 | import uvicorn 57 | 58 | uvicorn.run(app, host="0.0.0.0", port=8000) -------------------------------------------------------------------------------- /examples/09_auth_example_auth0.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Depends, HTTPException, Request, status 2 | from pydantic_settings import BaseSettings 3 | from typing import Any 4 | import logging 5 | 6 | from fastapi_mcp import FastApiMCP, AuthConfig 7 | 8 | from examples.shared.auth import fetch_jwks_public_key 9 | from examples.shared.setup import setup_logging 10 | 11 | 12 | setup_logging() 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class Settings(BaseSettings): 17 | """ 18 | For this to work, you need an .env file in the root of the project with the following variables: 19 | AUTH0_DOMAIN=your-tenant.auth0.com 20 | AUTH0_AUDIENCE=https://your-tenant.auth0.com/api/v2/ 21 | AUTH0_CLIENT_ID=your-client-id 22 | AUTH0_CLIENT_SECRET=your-client-secret 23 | """ 24 | 25 | auth0_domain: str # Auth0 domain, e.g. "your-tenant.auth0.com" 26 | auth0_audience: str # Audience, e.g. "https://your-tenant.auth0.com/api/v2/" 27 | auth0_client_id: str 28 | auth0_client_secret: str 29 | 30 | @property 31 | def auth0_jwks_url(self): 32 | return f"https://{self.auth0_domain}/.well-known/jwks.json" 33 | 34 | @property 35 | def auth0_oauth_metadata_url(self): 36 | return f"https://{self.auth0_domain}/.well-known/openid-configuration" 37 | 38 | class Config: 39 | env_file = ".env" 40 | 41 | 42 | settings = Settings() # type: ignore 43 | 44 | 45 | async def lifespan(app: FastAPI): 46 | app.state.jwks_public_key = await fetch_jwks_public_key(settings.auth0_jwks_url) 47 | logger.info(f"Auth0 client ID in settings: {settings.auth0_client_id}") 48 | logger.info(f"Auth0 domain in settings: {settings.auth0_domain}") 49 | logger.info(f"Auth0 audience in settings: {settings.auth0_audience}") 50 | yield 51 | 52 | 53 | async def verify_auth(request: Request) -> dict[str, Any]: 54 | try: 55 | import jwt 56 | 57 | auth_header = request.headers.get("authorization", "") 58 | if not auth_header.startswith("Bearer "): 59 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authorization header") 60 | 61 | token = auth_header.split(" ")[1] 62 | 63 | header = jwt.get_unverified_header(token) 64 | 65 | # Check if this is a JWE token (encrypted token) 66 | if header.get("alg") == "dir" and header.get("enc") == "A256GCM": 67 | raise ValueError( 68 | "Token is encrypted, offline validation not possible. " 69 | "This is usually due to not specifying the audience when requesting the token." 70 | ) 71 | 72 | # Otherwise, it's a JWT, we can validate it offline 73 | if header.get("alg") in ["RS256", "HS256"]: 74 | claims = jwt.decode( 75 | token, 76 | app.state.jwks_public_key, 77 | algorithms=["RS256", "HS256"], 78 | audience=settings.auth0_audience, 79 | issuer=f"https://{settings.auth0_domain}/", 80 | options={"verify_signature": True}, 81 | ) 82 | return claims 83 | 84 | except Exception as e: 85 | logger.error(f"Auth error: {str(e)}") 86 | 87 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized") 88 | 89 | 90 | async def get_current_user_id(claims: dict = Depends(verify_auth)) -> str: 91 | user_id = claims.get("sub") 92 | 93 | if not user_id: 94 | logger.error("No user ID found in token") 95 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized") 96 | 97 | return user_id 98 | 99 | 100 | app = FastAPI(lifespan=lifespan) 101 | 102 | 103 | @app.get("/api/public", operation_id="public") 104 | async def public(): 105 | return {"message": "This is a public route"} 106 | 107 | 108 | @app.get("/api/protected", operation_id="protected") 109 | async def protected(user_id: str = Depends(get_current_user_id)): 110 | return {"message": f"Hello, {user_id}!", "user_id": user_id} 111 | 112 | 113 | # Set up FastAPI-MCP with Auth0 auth 114 | mcp = FastApiMCP( 115 | app, 116 | name="MCP With Auth0", 117 | description="Example of FastAPI-MCP with Auth0 authentication", 118 | auth_config=AuthConfig( 119 | issuer=f"https://{settings.auth0_domain}/", 120 | authorize_url=f"https://{settings.auth0_domain}/authorize", 121 | oauth_metadata_url=settings.auth0_oauth_metadata_url, 122 | audience=settings.auth0_audience, 123 | client_id=settings.auth0_client_id, 124 | client_secret=settings.auth0_client_secret, 125 | dependencies=[Depends(verify_auth)], 126 | setup_proxies=True, 127 | ), 128 | ) 129 | 130 | # Mount the MCP server 131 | mcp.mount() 132 | 133 | 134 | if __name__ == "__main__": 135 | import uvicorn 136 | 137 | uvicorn.run(app, host="0.0.0.0", port=8000) 138 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # FastAPI-MCP Examples 2 | 3 | The following examples demonstrate various features and usage patterns of FastAPI-MCP: 4 | 5 | 1. [Basic Usage Example](01_basic_usage_example.py) - Basic FastAPI-MCP integration 6 | 2. [Full Schema Description](02_full_schema_description_example.py) - Customizing schema descriptions 7 | 3. [Custom Exposed Endpoints](03_custom_exposed_endpoints_example.py) - Controlling which endpoints are exposed 8 | 4. [Separate Server](04_separate_server_example.py) - Deploying MCP server separately 9 | 5. [Reregister Tools](05_reregister_tools_example.py) - Adding endpoints after MCP server creation 10 | 6. [Custom MCP Router](06_custom_mcp_router_example.py) - Advanced routing configuration 11 | 7. [Configure HTTP Timeout](07_configure_http_timeout_example.py) - Customizing timeout settings 12 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tadata-org/fastapi_mcp/f180bda8e66c7e8d3a767efb1aad6f21f26ab9c0/examples/__init__.py -------------------------------------------------------------------------------- /examples/shared/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tadata-org/fastapi_mcp/f180bda8e66c7e8d3a767efb1aad6f21f26ab9c0/examples/shared/__init__.py -------------------------------------------------------------------------------- /examples/shared/apps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tadata-org/fastapi_mcp/f180bda8e66c7e8d3a767efb1aad6f21f26ab9c0/examples/shared/apps/__init__.py -------------------------------------------------------------------------------- /examples/shared/apps/items.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple example of using FastAPI-MCP to add an MCP server to a FastAPI app. 3 | """ 4 | 5 | from fastapi import FastAPI, HTTPException, Query 6 | from pydantic import BaseModel 7 | from typing import List, Optional 8 | 9 | 10 | app = FastAPI() 11 | 12 | 13 | class Item(BaseModel): 14 | id: int 15 | name: str 16 | description: Optional[str] = None 17 | price: float 18 | tags: List[str] = [] 19 | 20 | 21 | items_db: dict[int, Item] = {} 22 | 23 | 24 | @app.get("/items/", response_model=List[Item], tags=["items"], operation_id="list_items") 25 | async def list_items(skip: int = 0, limit: int = 10): 26 | """ 27 | List all items in the database. 28 | 29 | Returns a list of items, with pagination support. 30 | """ 31 | return list(items_db.values())[skip : skip + limit] 32 | 33 | 34 | @app.get("/items/{item_id}", response_model=Item, tags=["items"], operation_id="get_item") 35 | async def read_item(item_id: int): 36 | """ 37 | Get a specific item by its ID. 38 | 39 | Raises a 404 error if the item does not exist. 40 | """ 41 | if item_id not in items_db: 42 | raise HTTPException(status_code=404, detail="Item not found") 43 | return items_db[item_id] 44 | 45 | 46 | @app.post("/items/", response_model=Item, tags=["items"], operation_id="create_item") 47 | async def create_item(item: Item): 48 | """ 49 | Create a new item in the database. 50 | 51 | Returns the created item with its assigned ID. 52 | """ 53 | items_db[item.id] = item 54 | return item 55 | 56 | 57 | @app.put("/items/{item_id}", response_model=Item, tags=["items"], operation_id="update_item") 58 | async def update_item(item_id: int, item: Item): 59 | """ 60 | Update an existing item. 61 | 62 | Raises a 404 error if the item does not exist. 63 | """ 64 | if item_id not in items_db: 65 | raise HTTPException(status_code=404, detail="Item not found") 66 | 67 | item.id = item_id 68 | items_db[item_id] = item 69 | return item 70 | 71 | 72 | @app.delete("/items/{item_id}", tags=["items"], operation_id="delete_item") 73 | async def delete_item(item_id: int): 74 | """ 75 | Delete an item from the database. 76 | 77 | Raises a 404 error if the item does not exist. 78 | """ 79 | if item_id not in items_db: 80 | raise HTTPException(status_code=404, detail="Item not found") 81 | 82 | del items_db[item_id] 83 | return {"message": "Item deleted successfully"} 84 | 85 | 86 | @app.get("/items/search/", response_model=List[Item], tags=["search"], operation_id="search_items") 87 | async def search_items( 88 | q: Optional[str] = Query(None, description="Search query string"), 89 | min_price: Optional[float] = Query(None, description="Minimum price"), 90 | max_price: Optional[float] = Query(None, description="Maximum price"), 91 | tags: List[str] = Query([], description="Filter by tags"), 92 | ): 93 | """ 94 | Search for items with various filters. 95 | 96 | Returns a list of items that match the search criteria. 97 | """ 98 | results = list(items_db.values()) 99 | 100 | if q: 101 | q = q.lower() 102 | results = [ 103 | item for item in results if q in item.name.lower() or (item.description and q in item.description.lower()) 104 | ] 105 | 106 | if min_price is not None: 107 | results = [item for item in results if item.price >= min_price] 108 | if max_price is not None: 109 | results = [item for item in results if item.price <= max_price] 110 | 111 | if tags: 112 | results = [item for item in results if all(tag in item.tags for tag in tags)] 113 | 114 | return results 115 | 116 | 117 | sample_items = [ 118 | Item(id=1, name="Hammer", description="A tool for hammering nails", price=9.99, tags=["tool", "hardware"]), 119 | Item(id=2, name="Screwdriver", description="A tool for driving screws", price=7.99, tags=["tool", "hardware"]), 120 | Item(id=3, name="Wrench", description="A tool for tightening bolts", price=12.99, tags=["tool", "hardware"]), 121 | Item(id=4, name="Saw", description="A tool for cutting wood", price=19.99, tags=["tool", "hardware", "cutting"]), 122 | Item(id=5, name="Drill", description="A tool for drilling holes", price=49.99, tags=["tool", "hardware", "power"]), 123 | ] 124 | for item in sample_items: 125 | items_db[item.id] = item 126 | -------------------------------------------------------------------------------- /examples/shared/auth.py: -------------------------------------------------------------------------------- 1 | from jwt.algorithms import RSAAlgorithm 2 | from cryptography.hazmat.primitives import serialization 3 | from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey 4 | 5 | import logging 6 | import httpx 7 | 8 | from examples.shared.setup import setup_logging 9 | 10 | setup_logging() 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | async def fetch_jwks_public_key(url: str) -> str: 16 | """ 17 | Fetch JWKS from a given URL and extract the primary public key in PEM format. 18 | 19 | Args: 20 | url: The JWKS URL to fetch from 21 | 22 | Returns: 23 | PEM-formatted public key as a string 24 | """ 25 | logger.info(f"Fetching JWKS from: {url}") 26 | async with httpx.AsyncClient() as client: 27 | response = await client.get(url) 28 | response.raise_for_status() 29 | jwks_data = response.json() 30 | 31 | if not jwks_data or "keys" not in jwks_data or not jwks_data["keys"]: 32 | logger.error("Invalid JWKS data format: missing or empty 'keys' array") 33 | raise ValueError("Invalid JWKS data format: missing or empty 'keys' array") 34 | 35 | # Just use the first key in the set 36 | jwk = jwks_data["keys"][0] 37 | 38 | # Convert JWK to PEM format 39 | public_key = RSAAlgorithm.from_jwk(jwk) 40 | if isinstance(public_key, RSAPublicKey): 41 | pem = public_key.public_bytes( 42 | encoding=serialization.Encoding.PEM, 43 | format=serialization.PublicFormat.SubjectPublicKeyInfo, 44 | ) 45 | pem_str = pem.decode("utf-8") 46 | logger.info("Successfully extracted public key from JWKS") 47 | return pem_str 48 | else: 49 | logger.error("Invalid JWKS data format: expected RSA public key") 50 | raise ValueError("Invalid JWKS data format: expected RSA public key") 51 | -------------------------------------------------------------------------------- /examples/shared/setup.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class LoggingConfig(BaseModel): 7 | LOGGER_NAME: str = "fastapi_mcp" 8 | LOG_FORMAT: str = "%(levelprefix)s %(asctime)s\t[%(name)s] %(message)s" 9 | LOG_LEVEL: str = logging.getLevelName(logging.DEBUG) 10 | 11 | version: int = 1 12 | disable_existing_loggers: bool = False 13 | formatters: dict = { 14 | "default": { 15 | "()": "uvicorn.logging.DefaultFormatter", 16 | "fmt": LOG_FORMAT, 17 | "datefmt": "%Y-%m-%d %H:%M:%S", 18 | }, 19 | } 20 | handlers: dict = { 21 | "default": { 22 | "formatter": "default", 23 | "class": "logging.StreamHandler", 24 | "stream": "ext://sys.stdout", 25 | }, 26 | } 27 | loggers: dict = { 28 | "": {"handlers": ["default"], "level": LOG_LEVEL}, # Root logger 29 | "uvicorn": {"handlers": ["default"], "level": LOG_LEVEL}, 30 | LOGGER_NAME: {"handlers": ["default"], "level": LOG_LEVEL}, 31 | } 32 | 33 | 34 | def setup_logging(): 35 | from logging.config import dictConfig 36 | 37 | logging_config = LoggingConfig() 38 | dictConfig(logging_config.model_dump()) 39 | -------------------------------------------------------------------------------- /fastapi_mcp/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | FastAPI-MCP: Automatic MCP server generator for FastAPI applications. 3 | 4 | Created by Tadata Inc. (https://github.com/tadata-org) 5 | """ 6 | 7 | try: 8 | from importlib.metadata import version 9 | 10 | __version__ = version("fastapi-mcp") 11 | except Exception: # pragma: no cover 12 | # Fallback for local development 13 | __version__ = "0.0.0.dev0" # pragma: no cover 14 | 15 | from .server import FastApiMCP 16 | from .types import AuthConfig, OAuthMetadata 17 | 18 | 19 | __all__ = [ 20 | "FastApiMCP", 21 | "AuthConfig", 22 | "OAuthMetadata", 23 | ] 24 | -------------------------------------------------------------------------------- /fastapi_mcp/auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tadata-org/fastapi_mcp/f180bda8e66c7e8d3a767efb1aad6f21f26ab9c0/fastapi_mcp/auth/__init__.py -------------------------------------------------------------------------------- /fastapi_mcp/auth/proxy.py: -------------------------------------------------------------------------------- 1 | from typing_extensions import Annotated, Doc 2 | from fastapi import FastAPI, HTTPException, Request, status 3 | from fastapi.responses import RedirectResponse 4 | import httpx 5 | from typing import Optional 6 | import logging 7 | from urllib.parse import urlencode 8 | 9 | from fastapi_mcp.types import ( 10 | ClientRegistrationRequest, 11 | ClientRegistrationResponse, 12 | AuthConfig, 13 | OAuthMetadata, 14 | OAuthMetadataDict, 15 | StrHttpUrl, 16 | ) 17 | 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | def setup_oauth_custom_metadata( 23 | app: Annotated[FastAPI, Doc("The FastAPI app instance")], 24 | auth_config: Annotated[AuthConfig, Doc("The AuthConfig used")], 25 | metadata: Annotated[OAuthMetadataDict, Doc("The custom metadata specified in AuthConfig")], 26 | include_in_schema: Annotated[bool, Doc("Whether to include the metadata endpoint in your OpenAPI docs")] = False, 27 | ): 28 | """ 29 | Just serve the custom metadata provided to AuthConfig under the path specified in `metadata_path`. 30 | """ 31 | 32 | auth_config = AuthConfig.model_validate(auth_config) 33 | metadata = OAuthMetadata.model_validate(metadata) 34 | 35 | @app.get( 36 | auth_config.metadata_path, 37 | response_model=OAuthMetadata, 38 | response_model_exclude_unset=True, 39 | response_model_exclude_none=True, 40 | include_in_schema=include_in_schema, 41 | operation_id="oauth_custom_metadata", 42 | ) 43 | async def oauth_metadata_proxy(): 44 | return metadata 45 | 46 | 47 | def setup_oauth_metadata_proxy( 48 | app: Annotated[FastAPI, Doc("The FastAPI app instance")], 49 | metadata_url: Annotated[ 50 | str, 51 | Doc( 52 | """ 53 | The URL of the OAuth provider's metadata endpoint that you want to proxy. 54 | """ 55 | ), 56 | ], 57 | path: Annotated[ 58 | str, 59 | Doc( 60 | """ 61 | The path to mount the OAuth metadata endpoint at. 62 | 63 | Clients will usually expect this to be /.well-known/oauth-authorization-server 64 | """ 65 | ), 66 | ] = "/.well-known/oauth-authorization-server", 67 | authorize_path: Annotated[ 68 | str, 69 | Doc( 70 | """ 71 | The path to mount the authorize endpoint at. 72 | 73 | Clients will usually expect this to be /oauth/authorize 74 | """ 75 | ), 76 | ] = "/oauth/authorize", 77 | register_path: Annotated[ 78 | Optional[str], 79 | Doc( 80 | """ 81 | The path to mount the register endpoint at. 82 | 83 | Clients will usually expect this to be /oauth/register 84 | """ 85 | ), 86 | ] = None, 87 | include_in_schema: Annotated[bool, Doc("Whether to include the metadata endpoint in your OpenAPI docs")] = False, 88 | ): 89 | """ 90 | Proxy for your OAuth provider's Metadata endpoint, just adding our (fake) registration endpoint. 91 | """ 92 | 93 | @app.get( 94 | path, 95 | response_model=OAuthMetadata, 96 | response_model_exclude_unset=True, 97 | response_model_exclude_none=True, 98 | include_in_schema=include_in_schema, 99 | operation_id="oauth_metadata_proxy", 100 | ) 101 | async def oauth_metadata_proxy(request: Request): 102 | base_url = str(request.base_url).rstrip("/") 103 | 104 | # Fetch your OAuth provider's OpenID Connect metadata 105 | async with httpx.AsyncClient() as client: 106 | response = await client.get(metadata_url) 107 | if response.status_code != 200: 108 | logger.error( 109 | f"Failed to fetch OAuth metadata from {metadata_url}: {response.status_code}. Response: {response.text}" 110 | ) 111 | raise HTTPException( 112 | status_code=status.HTTP_502_BAD_GATEWAY, 113 | detail="Failed to fetch OAuth metadata", 114 | ) 115 | 116 | oauth_metadata = response.json() 117 | 118 | # Override the registration endpoint if provided 119 | if register_path: 120 | oauth_metadata["registration_endpoint"] = f"{base_url}{register_path}" 121 | 122 | # Replace your OAuth provider's authorize endpoint with our proxy 123 | oauth_metadata["authorization_endpoint"] = f"{base_url}{authorize_path}" 124 | 125 | return OAuthMetadata.model_validate(oauth_metadata) 126 | 127 | 128 | def setup_oauth_authorize_proxy( 129 | app: Annotated[FastAPI, Doc("The FastAPI app instance")], 130 | client_id: Annotated[ 131 | str, 132 | Doc( 133 | """ 134 | In case the client doesn't specify a client ID, this will be used as the default client ID on the 135 | request to your OAuth provider. 136 | """ 137 | ), 138 | ], 139 | authorize_url: Annotated[ 140 | Optional[StrHttpUrl], 141 | Doc( 142 | """ 143 | The URL of your OAuth provider's authorization endpoint. 144 | 145 | Usually this is something like `https://app.example.com/oauth/authorize`. 146 | """ 147 | ), 148 | ], 149 | audience: Annotated[ 150 | Optional[str], 151 | Doc( 152 | """ 153 | Currently (2025-04-21), some Auth-supporting MCP clients (like `npx mcp-remote`) might not specify the 154 | audience when sending a request to your server. 155 | 156 | This may cause unexpected behavior from your OAuth provider, so this is a workaround. 157 | 158 | In case the client doesn't specify an audience, this will be used as the default audience on the 159 | request to your OAuth provider. 160 | """ 161 | ), 162 | ] = None, 163 | default_scope: Annotated[ 164 | str, 165 | Doc( 166 | """ 167 | Currently (2025-04-21), some Auth-supporting MCP clients (like `npx mcp-remote`) might not specify the 168 | scope when sending a request to your server. 169 | 170 | This may cause unexpected behavior from your OAuth provider, so this is a workaround. 171 | 172 | Here is where you can optionally specify a default scope that will be sent to your OAuth provider in case 173 | the client doesn't specify it. 174 | """ 175 | ), 176 | ] = "openid profile email", 177 | path: Annotated[str, Doc("The path to mount the authorize endpoint at")] = "/oauth/authorize", 178 | include_in_schema: Annotated[bool, Doc("Whether to include the authorize endpoint in your OpenAPI docs")] = False, 179 | ): 180 | """ 181 | Proxy for your OAuth provider's authorize endpoint that logs the requested scopes and adds 182 | default scopes and the audience parameter if not provided. 183 | """ 184 | 185 | @app.get( 186 | path, 187 | include_in_schema=include_in_schema, 188 | ) 189 | async def oauth_authorize_proxy( 190 | response_type: str = "code", 191 | client_id: Optional[str] = client_id, 192 | redirect_uri: Optional[str] = None, 193 | scope: str = "", 194 | state: Optional[str] = None, 195 | code_challenge: Optional[str] = None, 196 | code_challenge_method: Optional[str] = None, 197 | audience: Optional[str] = audience, 198 | ): 199 | if not scope: 200 | logger.warning("Client didn't provide any scopes! Using default scopes.") 201 | scope = default_scope 202 | 203 | scopes = scope.split() 204 | for required_scope in default_scope.split(): 205 | if required_scope not in scopes: 206 | scopes.append(required_scope) 207 | 208 | params = { 209 | "response_type": response_type, 210 | "client_id": client_id, 211 | "redirect_uri": redirect_uri, 212 | "scope": " ".join(scopes), 213 | "audience": audience, 214 | } 215 | 216 | if state: 217 | params["state"] = state 218 | if code_challenge: 219 | params["code_challenge"] = code_challenge 220 | if code_challenge_method: 221 | params["code_challenge_method"] = code_challenge_method 222 | 223 | auth_url = f"{authorize_url}?{urlencode(params)}" 224 | 225 | return RedirectResponse(url=auth_url) 226 | 227 | 228 | def setup_oauth_fake_dynamic_register_endpoint( 229 | app: Annotated[FastAPI, Doc("The FastAPI app instance")], 230 | client_id: Annotated[str, Doc("The client ID of the pre-registered client")], 231 | client_secret: Annotated[str, Doc("The client secret of the pre-registered client")], 232 | path: Annotated[str, Doc("The path to mount the register endpoint at")] = "/oauth/register", 233 | include_in_schema: Annotated[bool, Doc("Whether to include the register endpoint in your OpenAPI docs")] = False, 234 | ): 235 | """ 236 | A proxy for dynamic client registration endpoint. 237 | 238 | In MCP 2025-03-26 Spec, it is recommended to support OAuth Dynamic Client Registration (RFC 7591). 239 | Furthermore, `npx mcp-remote` which is the current de-facto client that supports MCP's up-to-date spec, 240 | requires this endpoint to be present. 241 | 242 | But, this is an overcomplication for most use cases. 243 | 244 | So instead of actually implementing dynamic client registration, we just echo back the pre-registered 245 | client ID and secret. 246 | 247 | Use this if you don't need dynamic client registration, or if your OAuth provider doesn't support it. 248 | """ 249 | 250 | @app.post( 251 | path, 252 | response_model=ClientRegistrationResponse, 253 | include_in_schema=include_in_schema, 254 | ) 255 | async def oauth_register_proxy(request: ClientRegistrationRequest) -> ClientRegistrationResponse: 256 | client_response = ClientRegistrationResponse( 257 | client_name=request.client_name or "MCP Server", # Name doesn't really affect functionality 258 | client_id=client_id, 259 | client_secret=client_secret, 260 | redirect_uris=request.redirect_uris, # Just echo back their requested URIs 261 | grant_types=request.grant_types or ["authorization_code"], 262 | token_endpoint_auth_method=request.token_endpoint_auth_method or "none", 263 | ) 264 | return client_response 265 | -------------------------------------------------------------------------------- /fastapi_mcp/openapi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tadata-org/fastapi_mcp/f180bda8e66c7e8d3a767efb1aad6f21f26ab9c0/fastapi_mcp/openapi/__init__.py -------------------------------------------------------------------------------- /fastapi_mcp/openapi/convert.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from typing import Any, Dict, List, Tuple 4 | 5 | import mcp.types as types 6 | 7 | from .utils import ( 8 | clean_schema_for_display, 9 | generate_example_from_schema, 10 | resolve_schema_references, 11 | get_single_param_type_from_schema, 12 | ) 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def convert_openapi_to_mcp_tools( 18 | openapi_schema: Dict[str, Any], 19 | describe_all_responses: bool = False, 20 | describe_full_response_schema: bool = False, 21 | ) -> Tuple[List[types.Tool], Dict[str, Dict[str, Any]]]: 22 | """ 23 | Convert OpenAPI operations to MCP tools. 24 | 25 | Args: 26 | openapi_schema: The OpenAPI schema 27 | describe_all_responses: Whether to include all possible response schemas in tool descriptions 28 | describe_full_response_schema: Whether to include full response schema in tool descriptions 29 | 30 | Returns: 31 | A tuple containing: 32 | - A list of MCP tools 33 | - A mapping of operation IDs to operation details for HTTP execution 34 | """ 35 | # Resolve all references in the schema at once 36 | resolved_openapi_schema = resolve_schema_references(openapi_schema, openapi_schema) 37 | 38 | tools = [] 39 | operation_map = {} 40 | 41 | # Process each path in the OpenAPI schema 42 | for path, path_item in resolved_openapi_schema.get("paths", {}).items(): 43 | for method, operation in path_item.items(): 44 | # Skip non-HTTP methods 45 | if method not in ["get", "post", "put", "delete", "patch"]: 46 | logger.warning(f"Skipping non-HTTP method: {method}") 47 | continue 48 | 49 | # Get operation metadata 50 | operation_id = operation.get("operationId") 51 | if not operation_id: 52 | logger.warning(f"Skipping operation with no operationId: {operation}") 53 | continue 54 | 55 | # Save operation details for later HTTP calls 56 | operation_map[operation_id] = { 57 | "path": path, 58 | "method": method, 59 | "parameters": operation.get("parameters", []), 60 | "request_body": operation.get("requestBody", {}), 61 | } 62 | 63 | summary = operation.get("summary", "") 64 | description = operation.get("description", "") 65 | 66 | # Build tool description 67 | tool_description = f"{summary}" if summary else f"{method.upper()} {path}" 68 | if description: 69 | tool_description += f"\n\n{description}" 70 | 71 | # Add response information to the description 72 | responses = operation.get("responses", {}) 73 | if responses: 74 | response_info = "\n\n### Responses:\n" 75 | 76 | # Find the success response 77 | success_codes = range(200, 300) 78 | success_response = None 79 | for status_code in success_codes: 80 | if str(status_code) in responses: 81 | success_response = responses[str(status_code)] 82 | break 83 | 84 | # Get the list of responses to include 85 | responses_to_include = responses 86 | if not describe_all_responses and success_response: 87 | # If we're not describing all responses, only include the success response 88 | success_code = next((code for code in success_codes if str(code) in responses), None) 89 | if success_code: 90 | responses_to_include = {str(success_code): success_response} 91 | 92 | # Process all selected responses 93 | for status_code, response_data in responses_to_include.items(): 94 | response_desc = response_data.get("description", "") 95 | response_info += f"\n**{status_code}**: {response_desc}" 96 | 97 | # Highlight if this is the main success response 98 | if response_data == success_response: 99 | response_info += " (Success Response)" 100 | 101 | # Add schema information if available 102 | if "content" in response_data: 103 | for content_type, content_data in response_data["content"].items(): 104 | if "schema" in content_data: 105 | schema = content_data["schema"] 106 | response_info += f"\nContent-Type: {content_type}" 107 | 108 | # Clean the schema for display 109 | display_schema = clean_schema_for_display(schema) 110 | 111 | # Try to get example response 112 | example_response = None 113 | 114 | # Check if content has examples 115 | if "examples" in content_data: 116 | for example_key, example_data in content_data["examples"].items(): 117 | if "value" in example_data: 118 | example_response = example_data["value"] 119 | break 120 | # If content has example 121 | elif "example" in content_data: 122 | example_response = content_data["example"] 123 | 124 | # If we have an example response, add it to the docs 125 | if example_response: 126 | response_info += "\n\n**Example Response:**\n```json\n" 127 | response_info += json.dumps(example_response, indent=2) 128 | response_info += "\n```" 129 | # Otherwise generate an example from the schema 130 | else: 131 | generated_example = generate_example_from_schema(display_schema) 132 | if generated_example: 133 | response_info += "\n\n**Example Response:**\n```json\n" 134 | response_info += json.dumps(generated_example, indent=2) 135 | response_info += "\n```" 136 | 137 | # Only include full schema information if requested 138 | if describe_full_response_schema: 139 | # Format schema information based on its type 140 | if display_schema.get("type") == "array" and "items" in display_schema: 141 | items_schema = display_schema["items"] 142 | 143 | response_info += "\n\n**Output Schema:** Array of items with the following structure:\n```json\n" 144 | response_info += json.dumps(items_schema, indent=2) 145 | response_info += "\n```" 146 | elif "properties" in display_schema: 147 | response_info += "\n\n**Output Schema:**\n```json\n" 148 | response_info += json.dumps(display_schema, indent=2) 149 | response_info += "\n```" 150 | else: 151 | response_info += "\n\n**Output Schema:**\n```json\n" 152 | response_info += json.dumps(display_schema, indent=2) 153 | response_info += "\n```" 154 | 155 | tool_description += response_info 156 | 157 | # Organize parameters by type 158 | path_params = [] 159 | query_params = [] 160 | header_params = [] 161 | body_params = [] 162 | 163 | for param in operation.get("parameters", []): 164 | param_name = param.get("name") 165 | param_in = param.get("in") 166 | required = param.get("required", False) 167 | 168 | if param_in == "path": 169 | path_params.append((param_name, param)) 170 | elif param_in == "query": 171 | query_params.append((param_name, param)) 172 | elif param_in == "header": 173 | header_params.append((param_name, param)) 174 | 175 | # Process request body if present 176 | request_body = operation.get("requestBody", {}) 177 | if request_body and "content" in request_body: 178 | content_type = next(iter(request_body["content"]), None) 179 | if content_type and "schema" in request_body["content"][content_type]: 180 | schema = request_body["content"][content_type]["schema"] 181 | if "properties" in schema: 182 | for prop_name, prop_schema in schema["properties"].items(): 183 | required = prop_name in schema.get("required", []) 184 | body_params.append( 185 | ( 186 | prop_name, 187 | { 188 | "name": prop_name, 189 | "schema": prop_schema, 190 | "required": required, 191 | }, 192 | ) 193 | ) 194 | 195 | # Create input schema properties for all parameters 196 | properties = {} 197 | required_props = [] 198 | 199 | # Add path parameters to properties 200 | for param_name, param in path_params: 201 | param_schema = param.get("schema", {}) 202 | param_desc = param.get("description", "") 203 | param_required = param.get("required", True) # Path params are usually required 204 | 205 | properties[param_name] = param_schema.copy() 206 | properties[param_name]["title"] = param_name 207 | if param_desc: 208 | properties[param_name]["description"] = param_desc 209 | 210 | if "type" not in properties[param_name]: 211 | properties[param_name]["type"] = param_schema.get("type", "string") 212 | 213 | if param_required: 214 | required_props.append(param_name) 215 | 216 | # Add query parameters to properties 217 | for param_name, param in query_params: 218 | param_schema = param.get("schema", {}) 219 | param_desc = param.get("description", "") 220 | param_required = param.get("required", False) 221 | 222 | properties[param_name] = param_schema.copy() 223 | properties[param_name]["title"] = param_name 224 | if param_desc: 225 | properties[param_name]["description"] = param_desc 226 | 227 | if "type" not in properties[param_name]: 228 | properties[param_name]["type"] = get_single_param_type_from_schema(param_schema) 229 | 230 | if "default" in param_schema: 231 | properties[param_name]["default"] = param_schema["default"] 232 | 233 | if param_required: 234 | required_props.append(param_name) 235 | 236 | # Add body parameters to properties 237 | for param_name, param in body_params: 238 | param_schema = param.get("schema", {}) 239 | param_desc = param.get("description", "") 240 | param_required = param.get("required", False) 241 | 242 | properties[param_name] = param_schema.copy() 243 | properties[param_name]["title"] = param_name 244 | if param_desc: 245 | properties[param_name]["description"] = param_desc 246 | 247 | if "type" not in properties[param_name]: 248 | properties[param_name]["type"] = get_single_param_type_from_schema(param_schema) 249 | 250 | if "default" in param_schema: 251 | properties[param_name]["default"] = param_schema["default"] 252 | 253 | if param_required: 254 | required_props.append(param_name) 255 | 256 | # Create a proper input schema for the tool 257 | input_schema = {"type": "object", "properties": properties, "title": f"{operation_id}Arguments"} 258 | 259 | if required_props: 260 | input_schema["required"] = required_props 261 | 262 | # Create the MCP tool definition 263 | tool = types.Tool(name=operation_id, description=tool_description, inputSchema=input_schema) 264 | 265 | tools.append(tool) 266 | 267 | return tools, operation_map 268 | -------------------------------------------------------------------------------- /fastapi_mcp/openapi/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | 4 | def get_single_param_type_from_schema(param_schema: Dict[str, Any]) -> str: 5 | """ 6 | Get the type of a parameter from the schema. 7 | If the schema is a union type, return the first type. 8 | """ 9 | if "anyOf" in param_schema: 10 | types = {schema.get("type") for schema in param_schema["anyOf"] if schema.get("type")} 11 | if "null" in types: 12 | types.remove("null") 13 | if types: 14 | return next(iter(types)) 15 | return "string" 16 | return param_schema.get("type", "string") 17 | 18 | 19 | def resolve_schema_references(schema_part: Dict[str, Any], reference_schema: Dict[str, Any]) -> Dict[str, Any]: 20 | """ 21 | Resolve schema references in OpenAPI schemas. 22 | 23 | Args: 24 | schema_part: The part of the schema being processed that may contain references 25 | reference_schema: The complete schema used to resolve references from 26 | 27 | Returns: 28 | The schema with references resolved 29 | """ 30 | # Make a copy to avoid modifying the input schema 31 | schema_part = schema_part.copy() 32 | 33 | # Handle $ref directly in the schema 34 | if "$ref" in schema_part: 35 | ref_path = schema_part["$ref"] 36 | # Standard OpenAPI references are in the format "#/components/schemas/ModelName" 37 | if ref_path.startswith("#/components/schemas/"): 38 | model_name = ref_path.split("/")[-1] 39 | if "components" in reference_schema and "schemas" in reference_schema["components"]: 40 | if model_name in reference_schema["components"]["schemas"]: 41 | # Replace with the resolved schema 42 | ref_schema = reference_schema["components"]["schemas"][model_name].copy() 43 | # Remove the $ref key and merge with the original schema 44 | schema_part.pop("$ref") 45 | schema_part.update(ref_schema) 46 | 47 | # Recursively resolve references in all dictionary values 48 | for key, value in schema_part.items(): 49 | if isinstance(value, dict): 50 | schema_part[key] = resolve_schema_references(value, reference_schema) 51 | elif isinstance(value, list): 52 | # Only process list items that are dictionaries since only they can contain refs 53 | schema_part[key] = [ 54 | resolve_schema_references(item, reference_schema) if isinstance(item, dict) else item for item in value 55 | ] 56 | 57 | return schema_part 58 | 59 | 60 | def clean_schema_for_display(schema: Dict[str, Any]) -> Dict[str, Any]: 61 | """ 62 | Clean up a schema for display by removing internal fields. 63 | 64 | Args: 65 | schema: The schema to clean 66 | 67 | Returns: 68 | The cleaned schema 69 | """ 70 | # Make a copy to avoid modifying the input schema 71 | schema = schema.copy() 72 | 73 | # Remove common internal fields that are not helpful for LLMs 74 | fields_to_remove = [ 75 | "allOf", 76 | "anyOf", 77 | "oneOf", 78 | "nullable", 79 | "discriminator", 80 | "readOnly", 81 | "writeOnly", 82 | "xml", 83 | "externalDocs", 84 | ] 85 | for field in fields_to_remove: 86 | if field in schema: 87 | schema.pop(field) 88 | 89 | # Process nested properties 90 | if "properties" in schema: 91 | for prop_name, prop_schema in schema["properties"].items(): 92 | if isinstance(prop_schema, dict): 93 | schema["properties"][prop_name] = clean_schema_for_display(prop_schema) 94 | 95 | # Process array items 96 | if "type" in schema and schema["type"] == "array" and "items" in schema: 97 | if isinstance(schema["items"], dict): 98 | schema["items"] = clean_schema_for_display(schema["items"]) 99 | 100 | return schema 101 | 102 | 103 | def generate_example_from_schema(schema: Dict[str, Any]) -> Any: 104 | """ 105 | Generate a simple example response from a JSON schema. 106 | 107 | Args: 108 | schema: The JSON schema to generate an example from 109 | 110 | Returns: 111 | An example object based on the schema 112 | """ 113 | if not schema or not isinstance(schema, dict): 114 | return None 115 | 116 | # Handle different types 117 | schema_type = schema.get("type") 118 | 119 | if schema_type == "object": 120 | result = {} 121 | if "properties" in schema: 122 | for prop_name, prop_schema in schema["properties"].items(): 123 | # Generate an example for each property 124 | prop_example = generate_example_from_schema(prop_schema) 125 | if prop_example is not None: 126 | result[prop_name] = prop_example 127 | return result 128 | 129 | elif schema_type == "array": 130 | if "items" in schema: 131 | # Generate a single example item 132 | item_example = generate_example_from_schema(schema["items"]) 133 | if item_example is not None: 134 | return [item_example] 135 | return [] 136 | 137 | elif schema_type == "string": 138 | # Check if there's a format 139 | format_type = schema.get("format") 140 | if format_type == "date-time": 141 | return "2023-01-01T00:00:00Z" 142 | elif format_type == "date": 143 | return "2023-01-01" 144 | elif format_type == "email": 145 | return "user@example.com" 146 | elif format_type == "uri": 147 | return "https://example.com" 148 | # Use title or property name if available 149 | return schema.get("title", "string") 150 | 151 | elif schema_type == "integer": 152 | return 1 153 | 154 | elif schema_type == "number": 155 | return 1.0 156 | 157 | elif schema_type == "boolean": 158 | return True 159 | 160 | elif schema_type == "null": 161 | return None 162 | 163 | # Default case 164 | return None 165 | -------------------------------------------------------------------------------- /fastapi_mcp/transport/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tadata-org/fastapi_mcp/f180bda8e66c7e8d3a767efb1aad6f21f26ab9c0/fastapi_mcp/transport/__init__.py -------------------------------------------------------------------------------- /fastapi_mcp/transport/sse.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | import logging 3 | from typing import Union 4 | 5 | from anyio.streams.memory import MemoryObjectSendStream 6 | from fastapi import Request, Response, BackgroundTasks, HTTPException 7 | from fastapi.responses import JSONResponse 8 | from pydantic import ValidationError 9 | from mcp.server.sse import SseServerTransport 10 | from mcp.types import JSONRPCMessage, JSONRPCError, ErrorData 11 | from fastapi_mcp.types import HTTPRequestInfo 12 | 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class FastApiSseTransport(SseServerTransport): 18 | async def handle_fastapi_post_message(self, request: Request) -> Response: 19 | """ 20 | A reimplementation of the handle_post_message method of SseServerTransport 21 | that integrates better with FastAPI. 22 | 23 | A few good reasons for doing this: 24 | 1. Avoid mounting a whole Starlette app and instead use a more FastAPI-native 25 | approach. Mounting has some known issues and limitations. 26 | 2. Avoid re-constructing the scope, receive, and send from the request, as done 27 | in the original implementation. 28 | 3. Use FastAPI's native response handling mechanisms and exception patterns to 29 | avoid unexpected rabbit holes. 30 | 31 | The combination of mounting a whole Starlette app and reconstructing the scope 32 | and send from the request proved to be especially error-prone for us when using 33 | tracing tools like Sentry, which had destructive effects on the request object 34 | when using the original implementation. 35 | """ 36 | 37 | logger.debug("Handling POST message SSE") 38 | 39 | session_id_param = request.query_params.get("session_id") 40 | if session_id_param is None: 41 | logger.warning("Received request without session_id") 42 | raise HTTPException(status_code=400, detail="session_id is required") 43 | 44 | try: 45 | session_id = UUID(hex=session_id_param) 46 | logger.debug(f"Parsed session ID: {session_id}") 47 | except ValueError: 48 | logger.warning(f"Received invalid session ID: {session_id_param}") 49 | raise HTTPException(status_code=400, detail="Invalid session ID") 50 | 51 | writer = self._read_stream_writers.get(session_id) 52 | if not writer: 53 | logger.warning(f"Could not find session for ID: {session_id}") 54 | raise HTTPException(status_code=404, detail="Could not find session") 55 | 56 | body = await request.body() 57 | logger.debug(f"Received JSON: {body.decode()}") 58 | 59 | try: 60 | message = JSONRPCMessage.model_validate_json(body) 61 | 62 | # HACK to inject the HTTP request info into the MCP message, 63 | # so we can use it for auth. 64 | # It is then used in our custom `LowlevelMCPServer.call_tool()` decorator. 65 | if hasattr(message.root, "params") and message.root.params is not None: 66 | message.root.params["_http_request_info"] = HTTPRequestInfo( 67 | method=request.method, 68 | path=request.url.path, 69 | headers=dict(request.headers), 70 | cookies=request.cookies, 71 | query_params=dict(request.query_params), 72 | body=body.decode(), 73 | ).model_dump(mode="json") 74 | 75 | logger.debug(f"Validated client message: {message}") 76 | except ValidationError as err: 77 | logger.error(f"Failed to parse message: {err}") 78 | # Create background task to send error 79 | background_tasks = BackgroundTasks() 80 | background_tasks.add_task(self._send_message_safely, writer, err) 81 | response = JSONResponse(content={"error": "Could not parse message"}, status_code=400) 82 | response.background = background_tasks 83 | return response 84 | except Exception as e: 85 | logger.error(f"Error processing request body: {e}") 86 | raise HTTPException(status_code=400, detail="Invalid request body") 87 | 88 | # Create background task to send message 89 | background_tasks = BackgroundTasks() 90 | background_tasks.add_task(self._send_message_safely, writer, message) 91 | logger.debug("Accepting message, will send in background") 92 | 93 | # Return response with background task 94 | response = JSONResponse(content={"message": "Accepted"}, status_code=202) 95 | response.background = background_tasks 96 | return response 97 | 98 | async def _send_message_safely( 99 | self, writer: MemoryObjectSendStream[JSONRPCMessage], message: Union[JSONRPCMessage, ValidationError] 100 | ): 101 | """Send a message to the writer, avoiding ASGI race conditions""" 102 | 103 | try: 104 | logger.debug(f"Sending message to writer from background task: {message}") 105 | 106 | if isinstance(message, ValidationError): 107 | # Convert ValidationError to JSONRPCError 108 | error_data = ErrorData( 109 | code=-32700, # Parse error code in JSON-RPC 110 | message="Parse error", 111 | data={"validation_error": str(message)}, 112 | ) 113 | json_rpc_error = JSONRPCError( 114 | jsonrpc="2.0", 115 | id="unknown", # We don't know the ID from the invalid request 116 | error=error_data, 117 | ) 118 | error_message = JSONRPCMessage(root=json_rpc_error) 119 | await writer.send(error_message) 120 | else: 121 | await writer.send(message) 122 | except Exception as e: 123 | logger.error(f"Error sending message to writer: {e}") 124 | -------------------------------------------------------------------------------- /fastapi_mcp/types.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Any, Dict, Annotated, Union, Optional, Sequence, Literal, List 3 | from typing_extensions import Doc 4 | from pydantic import ( 5 | BaseModel, 6 | ConfigDict, 7 | HttpUrl, 8 | field_validator, 9 | model_validator, 10 | ) 11 | from pydantic.main import IncEx 12 | from fastapi import params 13 | 14 | 15 | StrHttpUrl = Annotated[Union[str, HttpUrl], HttpUrl] 16 | 17 | 18 | class BaseType(BaseModel): 19 | model_config = ConfigDict(extra="ignore", arbitrary_types_allowed=True) 20 | 21 | 22 | class HTTPRequestInfo(BaseType): 23 | method: str 24 | path: str 25 | headers: Dict[str, str] 26 | cookies: Dict[str, str] 27 | query_params: Dict[str, str] 28 | body: Any 29 | 30 | 31 | class OAuthMetadata(BaseType): 32 | """OAuth 2.0 Server Metadata according to RFC 8414""" 33 | 34 | issuer: Annotated[ 35 | StrHttpUrl, 36 | Doc( 37 | """ 38 | The authorization server's issuer identifier, which is a URL that uses the https scheme. 39 | """ 40 | ), 41 | ] 42 | 43 | authorization_endpoint: Annotated[ 44 | Optional[StrHttpUrl], 45 | Doc( 46 | """ 47 | URL of the authorization server's authorization endpoint. 48 | """ 49 | ), 50 | ] = None 51 | 52 | token_endpoint: Annotated[ 53 | StrHttpUrl, 54 | Doc( 55 | """ 56 | URL of the authorization server's token endpoint. 57 | """ 58 | ), 59 | ] 60 | 61 | scopes_supported: Annotated[ 62 | List[str], 63 | Doc( 64 | """ 65 | List of OAuth 2.0 scopes that the authorization server supports. 66 | """ 67 | ), 68 | ] = ["openid", "profile", "email"] 69 | 70 | response_types_supported: Annotated[ 71 | List[str], 72 | Doc( 73 | """ 74 | List of the OAuth 2.0 response_type values that the authorization server supports. 75 | """ 76 | ), 77 | ] = ["code"] 78 | 79 | grant_types_supported: Annotated[ 80 | List[str], 81 | Doc( 82 | """ 83 | List of the OAuth 2.0 grant type values that the authorization server supports. 84 | """ 85 | ), 86 | ] = ["authorization_code", "client_credentials"] 87 | 88 | token_endpoint_auth_methods_supported: Annotated[ 89 | List[str], 90 | Doc( 91 | """ 92 | List of client authentication methods supported by the token endpoint. 93 | """ 94 | ), 95 | ] = ["none"] 96 | 97 | code_challenge_methods_supported: Annotated[ 98 | List[str], 99 | Doc( 100 | """ 101 | List of PKCE code challenge methods supported by the authorization server. 102 | """ 103 | ), 104 | ] = ["S256"] 105 | 106 | registration_endpoint: Annotated[ 107 | Optional[StrHttpUrl], 108 | Doc( 109 | """ 110 | URL of the authorization server's client registration endpoint. 111 | """ 112 | ), 113 | ] = None 114 | 115 | @field_validator( 116 | "scopes_supported", 117 | "response_types_supported", 118 | "grant_types_supported", 119 | "token_endpoint_auth_methods_supported", 120 | "code_challenge_methods_supported", 121 | ) 122 | @classmethod 123 | def validate_non_empty_lists(cls, v, info): 124 | if not v: 125 | raise ValueError(f"{info.field_name} cannot be empty") 126 | 127 | return v 128 | 129 | @model_validator(mode="after") 130 | def validate_endpoints_for_grant_types(self): 131 | if "authorization_code" in self.grant_types_supported and not self.authorization_endpoint: 132 | raise ValueError("authorization_endpoint is required when authorization_code grant type is supported") 133 | return self 134 | 135 | def model_dump( 136 | self, 137 | *, 138 | mode: Literal["json", "python"] | str = "python", 139 | include: IncEx | None = None, 140 | exclude: IncEx | None = None, 141 | context: Any | None = None, 142 | by_alias: bool = False, 143 | exclude_unset: bool = True, 144 | exclude_defaults: bool = False, 145 | exclude_none: bool = True, 146 | round_trip: bool = False, 147 | warnings: bool | Literal["none", "warn", "error"] = True, 148 | serialize_as_any: bool = False, 149 | ) -> dict[str, Any]: 150 | # Always exclude unset and None fields, since clients don't take it well when 151 | # OAuth metadata fields are present but empty. 152 | exclude_unset = True 153 | exclude_none = True 154 | return super().model_dump( 155 | mode=mode, 156 | include=include, 157 | exclude=exclude, 158 | context=context, 159 | by_alias=by_alias, 160 | exclude_unset=exclude_unset, 161 | exclude_defaults=exclude_defaults, 162 | exclude_none=exclude_none, 163 | round_trip=round_trip, 164 | warnings=warnings, 165 | serialize_as_any=serialize_as_any, 166 | ) 167 | 168 | 169 | OAuthMetadataDict = Annotated[Union[Dict[str, Any], OAuthMetadata], OAuthMetadata] 170 | 171 | 172 | class AuthConfig(BaseType): 173 | version: Annotated[ 174 | Literal["2025-03-26"], 175 | Doc( 176 | """ 177 | The MCP spec version to use for setting up authorization. 178 | Currently only "2025-03-26" is supported. 179 | """ 180 | ), 181 | ] = "2025-03-26" 182 | 183 | dependencies: Annotated[ 184 | Optional[Sequence[params.Depends]], 185 | Doc( 186 | """ 187 | FastAPI dependencies (using `Depends()`) that check for authentication or authorization 188 | and raise 401 or 403 errors if the request is not authenticated or authorized. 189 | 190 | This is necessary to trigger the client to start an OAuth flow. 191 | 192 | Example: 193 | ```python 194 | # Your authentication dependency 195 | async def authenticate_request(request: Request, token: str = Depends(oauth2_scheme)): 196 | payload = verify_token(request, token) 197 | if payload is None: 198 | raise HTTPException(status_code=401, detail="Unauthorized") 199 | return payload 200 | 201 | # Usage with FastAPI-MCP 202 | mcp = FastApiMCP( 203 | app, 204 | auth_config=AuthConfig(dependencies=[Depends(authenticate_request)]), 205 | ) 206 | ``` 207 | """ 208 | ), 209 | ] = None 210 | 211 | issuer: Annotated[ 212 | Optional[str], 213 | Doc( 214 | """ 215 | The issuer of the OAuth 2.0 server. 216 | Required if you don't provide `custom_oauth_metadata`. 217 | Usually it's either the base URL of your app, or the URL of the OAuth provider. 218 | For example, for Auth0, this would be `https://your-tenant.auth0.com`. 219 | """ 220 | ), 221 | ] = None 222 | 223 | oauth_metadata_url: Annotated[ 224 | Optional[StrHttpUrl], 225 | Doc( 226 | """ 227 | The full URL of the OAuth provider's metadata endpoint. 228 | 229 | If not provided, FastAPI-MCP will attempt to guess based on the `issuer` and `metadata_path`. 230 | 231 | Only relevant if `setup_proxies` is `True`. 232 | 233 | If this URL is wrong, the metadata proxy will not work. 234 | """ 235 | ), 236 | ] = None 237 | 238 | authorize_url: Annotated[ 239 | Optional[StrHttpUrl], 240 | Doc( 241 | """ 242 | The URL of your OAuth provider's authorization endpoint. 243 | 244 | Usually this is something like `https://app.example.com/oauth/authorize`. 245 | """ 246 | ), 247 | ] = None 248 | 249 | audience: Annotated[ 250 | Optional[str], 251 | Doc( 252 | """ 253 | Currently (2025-04-21), some Auth-supporting MCP clients (like `npx mcp-remote`) might not specify the 254 | audience when sending a request to your server. 255 | 256 | This may cause unexpected behavior from your OAuth provider, so this is a workaround. 257 | 258 | In case the client doesn't specify an audience, this will be used as the default audience on the 259 | request to your OAuth provider. 260 | """ 261 | ), 262 | ] = None 263 | 264 | default_scope: Annotated[ 265 | str, 266 | Doc( 267 | """ 268 | Currently (2025-04-21), some Auth-supporting MCP clients (like `npx mcp-remote`) might not specify the 269 | scope when sending a request to your server. 270 | 271 | This may cause unexpected behavior from your OAuth provider, so this is a workaround. 272 | 273 | Here is where you can optionally specify a default scope that will be sent to your OAuth provider in case 274 | the client doesn't specify it. 275 | """ 276 | ), 277 | ] = "openid profile email" 278 | 279 | client_id: Annotated[ 280 | Optional[str], 281 | Doc( 282 | """ 283 | In case the client doesn't specify a client ID, this will be used as the default client ID on the 284 | request to your OAuth provider. 285 | 286 | This is mandatory only if you set `setup_proxies` to `True`. 287 | """ 288 | ), 289 | ] = None 290 | 291 | client_secret: Annotated[ 292 | Optional[str], 293 | Doc( 294 | """ 295 | The client secret to use for the client ID. 296 | 297 | This is mandatory only if you set `setup_proxies` to `True` and `setup_fake_dynamic_registration` to `True`. 298 | """ 299 | ), 300 | ] = None 301 | 302 | custom_oauth_metadata: Annotated[ 303 | Optional[OAuthMetadataDict], 304 | Doc( 305 | """ 306 | Custom OAuth metadata to register. 307 | 308 | If your OAuth flow works with MCP out-of-the-box, you should just use this option to provide the 309 | metadata yourself. 310 | 311 | Otherwise, set `setup_proxies` to `True` to automatically setup MCP-compliant proxies around your 312 | OAuth provider's endpoints. 313 | """ 314 | ), 315 | ] = None 316 | 317 | setup_proxies: Annotated[ 318 | bool, 319 | Doc( 320 | """ 321 | Whether to automatically setup MCP-compliant proxies around your original OAuth provider's endpoints. 322 | """ 323 | ), 324 | ] = False 325 | 326 | setup_fake_dynamic_registration: Annotated[ 327 | bool, 328 | Doc( 329 | """ 330 | Whether to automatically setup a fake dynamic client registration endpoint. 331 | 332 | In MCP 2025-03-26 Spec, it is recommended to support OAuth Dynamic Client Registration (RFC 7591). 333 | Furthermore, `npx mcp-remote` which is the current de-facto client that supports MCP's up-to-date spec, 334 | requires this endpoint to be present. 335 | 336 | For most cases, a fake dynamic registration endpoint is enough, thus you should set this to `True`. 337 | 338 | This is only used if `setup_proxies` is also `True`. 339 | """ 340 | ), 341 | ] = True 342 | 343 | metadata_path: Annotated[ 344 | str, 345 | Doc( 346 | """ 347 | The path to mount the OAuth metadata endpoint at. 348 | 349 | Clients will usually expect this to be /.well-known/oauth-authorization-server 350 | """ 351 | ), 352 | ] = "/.well-known/oauth-authorization-server" 353 | 354 | @model_validator(mode="after") 355 | def validate_required_fields(self): 356 | if self.custom_oauth_metadata is None and self.issuer is None and not self.dependencies: 357 | raise ValueError("at least one of 'issuer', 'custom_oauth_metadata' or 'dependencies' is required") 358 | 359 | if self.setup_proxies: 360 | if self.client_id is None: 361 | raise ValueError("'client_id' is required when 'setup_proxies' is True") 362 | 363 | if self.setup_fake_dynamic_registration and not self.client_secret: 364 | raise ValueError("'client_secret' is required when 'setup_fake_dynamic_registration' is True") 365 | 366 | return self 367 | 368 | 369 | class ClientRegistrationRequest(BaseType): 370 | redirect_uris: List[str] 371 | client_name: Optional[str] = None 372 | grant_types: Optional[List[str]] = ["authorization_code"] 373 | token_endpoint_auth_method: Optional[str] = "none" 374 | 375 | 376 | class ClientRegistrationResponse(BaseType): 377 | client_id: str 378 | client_id_issued_at: int = int(time.time()) 379 | client_secret: Optional[str] = None 380 | client_secret_expires_at: int = 0 381 | redirect_uris: List[str] 382 | grant_types: List[str] 383 | token_endpoint_auth_method: str 384 | client_name: str 385 | -------------------------------------------------------------------------------- /fastapi_mcp/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tadata-org/fastapi_mcp/f180bda8e66c7e8d3a767efb1aad6f21f26ab9c0/fastapi_mcp/utils/__init__.py -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling", "tomli"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "fastapi-mcp" 7 | version = "0.3.3" 8 | description = "Automatic MCP server generator for FastAPI applications - converts FastAPI endpoints to MCP tools for LLM integration" 9 | readme = "README.md" 10 | requires-python = ">=3.10" 11 | license = {file = "LICENSE"} 12 | authors = [ 13 | {name = "Tadata Inc.", email = "itay@tadata.com"}, 14 | ] 15 | classifiers = [ 16 | "Development Status :: 3 - Alpha", 17 | "Intended Audience :: Developers", 18 | "License :: OSI Approved :: MIT License", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Topic :: Software Development :: Libraries :: Python Modules", 24 | "Topic :: Internet :: WWW/HTTP", 25 | "Framework :: FastAPI", 26 | ] 27 | keywords = ["fastapi", "openapi", "mcp", "llm", "claude", "ai", "tools", "api", "conversion", "modelcontextprotocol"] 28 | dependencies = [ 29 | "fastapi>=0.100.0", 30 | "typer>=0.9.0", 31 | "rich>=13.0.0", 32 | "mcp>=1.6.0", 33 | "pydantic>=2.0.0", 34 | "pydantic-settings>=2.5.2", 35 | "uvicorn>=0.20.0", 36 | "httpx>=0.24.0", 37 | "requests>=2.25.0", 38 | "tomli>=2.2.1", 39 | ] 40 | 41 | [dependency-groups] 42 | dev = [ 43 | "mypy>=1.15.0", 44 | "ruff>=0.9.10", 45 | "types-setuptools>=75.8.2.20250305", 46 | "pytest>=8.3.5", 47 | "pytest-asyncio>=0.26.0", 48 | "pytest-cov>=6.1.1", 49 | "pre-commit>=4.2.0", 50 | "pyjwt>=2.10.1", 51 | "cryptography>=44.0.2", 52 | ] 53 | 54 | [project.urls] 55 | Homepage = "https://github.com/tadata-org/fastapi_mcp" 56 | Documentation = "https://github.com/tadata-org/fastapi_mcp#readme" 57 | "Bug Tracker" = "https://github.com/tadata-org/fastapi_mcp/issues" 58 | "PyPI" = "https://pypi.org/project/fastapi-mcp/" 59 | "Source Code" = "https://github.com/tadata-org/fastapi_mcp" 60 | "Changelog" = "https://github.com/tadata-org/fastapi_mcp/blob/main/CHANGELOG.md" 61 | 62 | [tool.hatch.build.targets.wheel] 63 | packages = ["fastapi_mcp"] 64 | 65 | [tool.ruff] 66 | line-length = 120 67 | target-version = "py312" 68 | 69 | [tool.pytest.ini_options] 70 | asyncio_mode = "auto" 71 | testpaths = ["tests"] 72 | python_files = "test_*.py" 73 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -vvv --cov=. --cov-report xml --cov-report term-missing --cov-fail-under=80 --cov-config=.coveragerc 3 | asyncio_mode = auto 4 | log_cli = true 5 | log_cli_level = DEBUG 6 | log_cli_format = %(asctime)s - %(name)s - %(levelname)s - %(message)s 7 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tadata-org/fastapi_mcp/f180bda8e66c7e8d3a767efb1aad6f21f26ab9c0/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import pytest 4 | import coverage 5 | 6 | # Add the parent directory to the path 7 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 8 | 9 | from .fixtures.types import * # noqa: F403 10 | from .fixtures.example_data import * # noqa: F403 11 | from .fixtures.simple_app import * # noqa: F403 12 | from .fixtures.complex_app import * # noqa: F403 13 | 14 | 15 | @pytest.hookimpl(trylast=True) 16 | def pytest_configure(config): 17 | """Configure pytest-cov for proper subprocess coverage.""" 18 | if config.pluginmanager.hasplugin("pytest_cov"): 19 | # Ensure environment variables are set for subprocess coverage 20 | os.environ["COVERAGE_PROCESS_START"] = os.path.abspath(".coveragerc") 21 | 22 | # Set up environment for combinining coverage data from subprocesses 23 | os.environ["PYTHONPATH"] = os.path.abspath(".") 24 | 25 | # Make sure the pytest-cov plugin is active for subprocesses 26 | config.option.cov_fail_under = 0 # Disable fail under in the primary process 27 | 28 | 29 | @pytest.hookimpl(trylast=True) 30 | def pytest_sessionfinish(session, exitstatus): 31 | """Combine coverage data from subprocesses at the end of the test session.""" 32 | cov_dir = os.path.abspath(".") 33 | if exitstatus == 0 and os.environ.get("COVERAGE_PROCESS_START"): 34 | try: 35 | cov = coverage.Coverage() 36 | cov.combine(data_paths=[cov_dir], strict=True) 37 | cov.save() 38 | except Exception as e: 39 | print(f"Error combining coverage data: {e}", file=sys.stderr) 40 | -------------------------------------------------------------------------------- /tests/fixtures/complex_app.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List, Dict, Any, Union 2 | from uuid import UUID 3 | 4 | from fastapi import FastAPI, Query, Path, Body, Header, Cookie 5 | import pytest 6 | 7 | from .types import ( 8 | Product, 9 | Customer, 10 | OrderResponse, 11 | PaginatedResponse, 12 | ProductCategory, 13 | OrderRequest, 14 | ErrorResponse, 15 | ) 16 | 17 | 18 | def make_complex_fastapi_app( 19 | example_product: Product, 20 | example_customer: Customer, 21 | example_order_response: OrderResponse, 22 | ) -> FastAPI: 23 | app = FastAPI( 24 | title="Complex E-Commerce API", 25 | description="A more complex API with nested models and various schemas", 26 | version="1.0.0", 27 | ) 28 | 29 | @app.get( 30 | "/products", 31 | response_model=PaginatedResponse, 32 | tags=["products"], 33 | operation_id="list_products", 34 | response_model_exclude_none=True, 35 | ) 36 | async def list_products( 37 | category: Optional[ProductCategory] = Query(None, description="Filter by product category"), 38 | min_price: Optional[float] = Query(None, description="Minimum price filter", gt=0), 39 | max_price: Optional[float] = Query(None, description="Maximum price filter", gt=0), 40 | tag: Optional[List[str]] = Query(None, description="Filter by tags"), 41 | sort_by: str = Query("created_at", description="Field to sort by"), 42 | sort_direction: str = Query("desc", description="Sort direction (asc or desc)"), 43 | in_stock_only: bool = Query(False, description="Show only in-stock products"), 44 | page: int = Query(1, description="Page number", ge=1), 45 | size: int = Query(20, description="Page size", ge=1, le=100), 46 | user_agent: Optional[str] = Header(None, description="User agent header"), 47 | ): 48 | """ 49 | List products with various filtering, sorting and pagination options. 50 | Returns a paginated response of products. 51 | """ 52 | return PaginatedResponse(items=[example_product], total=1, page=page, size=size, pages=1) 53 | 54 | @app.get( 55 | "/products/{product_id}", 56 | response_model=Product, 57 | tags=["products"], 58 | operation_id="get_product", 59 | responses={ 60 | 404: {"model": ErrorResponse, "description": "Product not found"}, 61 | }, 62 | ) 63 | async def get_product( 64 | product_id: UUID = Path(..., description="The ID of the product to retrieve"), 65 | include_unavailable: bool = Query(False, description="Include product even if not available"), 66 | ): 67 | """ 68 | Get detailed information about a specific product by its ID. 69 | Includes all variants, images, and metadata. 70 | """ 71 | # Just returning the example product with the requested ID 72 | product_copy = example_product.model_copy() 73 | product_copy.id = product_id 74 | return product_copy 75 | 76 | @app.post( 77 | "/orders", 78 | response_model=OrderResponse, 79 | tags=["orders"], 80 | operation_id="create_order", 81 | status_code=201, 82 | responses={ 83 | 400: {"model": ErrorResponse, "description": "Invalid order data"}, 84 | 404: {"model": ErrorResponse, "description": "Customer or product not found"}, 85 | 422: {"model": ErrorResponse, "description": "Validation error"}, 86 | }, 87 | ) 88 | async def create_order( 89 | order: OrderRequest = Body(..., description="Order details"), 90 | user_id: Optional[UUID] = Cookie(None, description="User ID from cookie"), 91 | authorization: Optional[str] = Header(None, description="Authorization header"), 92 | ): 93 | """ 94 | Create a new order with multiple items, shipping details, and payment information. 95 | Returns the created order with full details including status and tracking information. 96 | """ 97 | # Return a copy of the example order response with the customer ID from the request 98 | order_copy = example_order_response.model_copy() 99 | order_copy.customer_id = order.customer_id 100 | order_copy.items = order.items 101 | return order_copy 102 | 103 | @app.get( 104 | "/customers/{customer_id}", 105 | response_model=Union[Customer, Dict[str, Any]], 106 | tags=["customers"], 107 | operation_id="get_customer", 108 | responses={ 109 | 404: {"model": ErrorResponse, "description": "Customer not found"}, 110 | 403: {"model": ErrorResponse, "description": "Forbidden access"}, 111 | }, 112 | ) 113 | async def get_customer( 114 | customer_id: UUID = Path(..., description="The ID of the customer to retrieve"), 115 | include_orders: bool = Query(False, description="Include customer's order history"), 116 | include_payment_methods: bool = Query(False, description="Include customer's saved payment methods"), 117 | fields: List[str] = Query(None, description="Specific fields to include in response"), 118 | ): 119 | """ 120 | Get detailed information about a specific customer by ID. 121 | Can include additional related information like order history. 122 | """ 123 | # Return a copy of the example customer with the requested ID 124 | customer_copy = example_customer.model_copy() 125 | customer_copy.id = customer_id 126 | return customer_copy 127 | 128 | return app 129 | 130 | 131 | @pytest.fixture 132 | def complex_fastapi_app( 133 | example_product: Product, 134 | example_customer: Customer, 135 | example_order_response: OrderResponse, 136 | ) -> FastAPI: 137 | return make_complex_fastapi_app( 138 | example_product=example_product, 139 | example_customer=example_customer, 140 | example_order_response=example_order_response, 141 | ) 142 | -------------------------------------------------------------------------------- /tests/fixtures/example_data.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, date 2 | from uuid import UUID 3 | 4 | import pytest 5 | 6 | from .types import ( 7 | Address, 8 | ProductVariant, 9 | Product, 10 | ProductCategory, 11 | Customer, 12 | CustomerTier, 13 | OrderItem, 14 | PaymentDetails, 15 | OrderRequest, 16 | OrderResponse, 17 | PaginatedResponse, 18 | OrderStatus, 19 | PaymentMethod, 20 | ) 21 | 22 | 23 | @pytest.fixture 24 | def example_address() -> Address: 25 | return Address(street="123 Main St", city="Anytown", state="CA", postal_code="12345", country="US", is_primary=True) 26 | 27 | 28 | @pytest.fixture 29 | def example_product_variant() -> ProductVariant: 30 | return ProductVariant( 31 | sku="EP-001-BLK", color="Black", stock_count=10, size=None, weight=None, dimensions=None, in_stock=True 32 | ) 33 | 34 | 35 | @pytest.fixture 36 | def example_product(example_product_variant) -> Product: 37 | return Product( 38 | id=UUID("550e8400-e29b-41d4-a716-446655440000"), 39 | name="Example Product", 40 | description="This is an example product", 41 | category=ProductCategory.ELECTRONICS, 42 | price=199.99, 43 | discount_percent=None, 44 | tax_rate=None, 45 | rating=None, 46 | review_count=0, 47 | tags=["example", "new"], 48 | image_urls=["https://example.com/image.jpg"], 49 | created_at=datetime.now(), 50 | variants=[example_product_variant], 51 | ) 52 | 53 | 54 | @pytest.fixture 55 | def example_customer(example_address) -> Customer: 56 | return Customer( 57 | id=UUID("770f9511-f39c-42d5-a860-557654551222"), 58 | email="customer@example.com", 59 | full_name="John Doe", 60 | phone="1234567890", 61 | tier=CustomerTier.STANDARD, 62 | addresses=[example_address], 63 | created_at=datetime.now(), 64 | preferences={"theme": "dark", "notifications": True}, 65 | consent={"marketing": True, "analytics": True}, 66 | ) 67 | 68 | 69 | @pytest.fixture 70 | def example_order_item() -> OrderItem: 71 | return OrderItem( 72 | product_id=UUID("550e8400-e29b-41d4-a716-446655440000"), 73 | variant_sku="EP-001-BLK", 74 | quantity=2, 75 | unit_price=199.99, 76 | discount_amount=10.00, 77 | total=389.98, 78 | ) 79 | 80 | 81 | @pytest.fixture 82 | def example_payment_details() -> PaymentDetails: 83 | return PaymentDetails( 84 | method=PaymentMethod.CREDIT_CARD, 85 | transaction_id="txn_12345", 86 | status="completed", 87 | amount=389.98, 88 | currency="USD", 89 | paid_at=datetime.now(), 90 | ) 91 | 92 | 93 | @pytest.fixture 94 | def example_order_request(example_order_item) -> OrderRequest: 95 | return OrderRequest( 96 | customer_id=UUID("770f9511-f39c-42d5-a860-557654551222"), 97 | items=[example_order_item], 98 | shipping_address_id=UUID("880f9511-f39c-42d5-a860-557654551333"), 99 | billing_address_id=None, 100 | payment_method=PaymentMethod.CREDIT_CARD, 101 | notes="Please deliver before 6pm", 102 | use_loyalty_points=False, 103 | ) 104 | 105 | 106 | @pytest.fixture 107 | def example_order_response(example_order_item, example_address, example_payment_details) -> OrderResponse: 108 | return OrderResponse( 109 | id=UUID("660f9511-f39c-42d5-a860-557654551111"), 110 | customer_id=UUID("770f9511-f39c-42d5-a860-557654551222"), 111 | status=OrderStatus.PENDING, 112 | items=[example_order_item], 113 | shipping_address=example_address, 114 | billing_address=example_address, 115 | payment=example_payment_details, 116 | subtotal=389.98, 117 | shipping_cost=10.0, 118 | tax_amount=20.0, 119 | discount_amount=10.0, 120 | total_amount=409.98, 121 | tracking_number="TRK123456789", 122 | estimated_delivery=date.today(), 123 | created_at=datetime.now(), 124 | notes="Please deliver before 6pm", 125 | metadata={}, 126 | ) 127 | 128 | 129 | @pytest.fixture 130 | def example_paginated_products(example_product) -> PaginatedResponse: 131 | return PaginatedResponse(items=[example_product], total=1, page=1, size=20, pages=1) 132 | -------------------------------------------------------------------------------- /tests/fixtures/simple_app.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | 3 | from fastapi import FastAPI, Query, Path, Body, HTTPException 4 | import pytest 5 | 6 | from .types import Item 7 | 8 | 9 | def make_simple_fastapi_app() -> FastAPI: 10 | app = FastAPI( 11 | title="Test API", 12 | description="A test API app for unit testing", 13 | version="0.1.0", 14 | ) 15 | 16 | items = [ 17 | Item(id=1, name="Item 1", price=10.0, tags=["tag1", "tag2"], description="Item 1 description"), 18 | Item(id=2, name="Item 2", price=20.0, tags=["tag2", "tag3"]), 19 | Item(id=3, name="Item 3", price=30.0, tags=["tag3", "tag4"], description="Item 3 description"), 20 | ] 21 | 22 | @app.get("/items/", response_model=List[Item], tags=["items"], operation_id="list_items") 23 | async def list_items( 24 | skip: int = Query(0, description="Number of items to skip"), 25 | limit: int = Query(10, description="Max number of items to return"), 26 | sort_by: Optional[str] = Query(None, description="Field to sort by"), 27 | ) -> List[Item]: 28 | """List all items with pagination and sorting options.""" 29 | return items[skip : skip + limit] 30 | 31 | @app.get("/items/{item_id}", response_model=Item, tags=["items"], operation_id="get_item") 32 | async def read_item( 33 | item_id: int = Path(..., description="The ID of the item to retrieve"), 34 | include_details: bool = Query(False, description="Include additional details"), 35 | ) -> Item: 36 | """Get a specific item by its ID with optional details.""" 37 | found_item = next((item for item in items if item.id == item_id), None) 38 | if found_item is None: 39 | raise HTTPException(status_code=404, detail="Item not found") 40 | return found_item 41 | 42 | @app.post("/items/", response_model=Item, tags=["items"], operation_id="create_item") 43 | async def create_item(item: Item = Body(..., description="The item to create")) -> Item: 44 | """Create a new item in the database.""" 45 | items.append(item) 46 | return item 47 | 48 | @app.put("/items/{item_id}", response_model=Item, tags=["items"], operation_id="update_item") 49 | async def update_item( 50 | item_id: int = Path(..., description="The ID of the item to update"), 51 | item: Item = Body(..., description="The updated item data"), 52 | ) -> Item: 53 | """Update an existing item.""" 54 | item.id = item_id 55 | return item 56 | 57 | @app.delete("/items/{item_id}", status_code=204, tags=["items"], operation_id="delete_item") 58 | async def delete_item(item_id: int = Path(..., description="The ID of the item to delete")) -> None: 59 | """Delete an item from the database.""" 60 | return None 61 | 62 | @app.get("/error", tags=["error"], operation_id="raise_error") 63 | async def raise_error() -> None: 64 | """Fail on purpose and cause a 500 error.""" 65 | raise Exception("This is a test error") 66 | 67 | return app 68 | 69 | 70 | @pytest.fixture 71 | def simple_fastapi_app() -> FastAPI: 72 | return make_simple_fastapi_app() 73 | -------------------------------------------------------------------------------- /tests/fixtures/types.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List, Dict, Any 2 | from datetime import datetime, date 3 | from enum import Enum 4 | from uuid import UUID 5 | 6 | from pydantic import BaseModel, Field 7 | 8 | 9 | class Item(BaseModel): 10 | id: int 11 | name: str 12 | description: Optional[str] = None 13 | price: float 14 | tags: List[str] = [] 15 | 16 | 17 | class OrderStatus(str, Enum): 18 | PENDING = "pending" 19 | PROCESSING = "processing" 20 | SHIPPED = "shipped" 21 | DELIVERED = "delivered" 22 | CANCELLED = "cancelled" 23 | RETURNED = "returned" 24 | 25 | 26 | class PaymentMethod(str, Enum): 27 | CREDIT_CARD = "credit_card" 28 | DEBIT_CARD = "debit_card" 29 | PAYPAL = "paypal" 30 | BANK_TRANSFER = "bank_transfer" 31 | CASH_ON_DELIVERY = "cash_on_delivery" 32 | 33 | 34 | class ProductCategory(str, Enum): 35 | ELECTRONICS = "electronics" 36 | CLOTHING = "clothing" 37 | FOOD = "food" 38 | BOOKS = "books" 39 | OTHER = "other" 40 | 41 | 42 | class ProductVariant(BaseModel): 43 | sku: str = Field(..., description="Stock keeping unit code") 44 | color: Optional[str] = Field(None, description="Color variant") 45 | size: Optional[str] = Field(None, description="Size variant") 46 | weight: Optional[float] = Field(None, description="Weight in kg", gt=0) 47 | dimensions: Optional[Dict[str, float]] = Field(None, description="Dimensions in cm (length, width, height)") 48 | in_stock: bool = Field(True, description="Whether this variant is in stock") 49 | stock_count: Optional[int] = Field(None, description="Number of items in stock", ge=0) 50 | 51 | 52 | class Address(BaseModel): 53 | street: str 54 | city: str 55 | state: str 56 | postal_code: str 57 | country: str 58 | is_primary: bool = False 59 | 60 | 61 | class CustomerTier(str, Enum): 62 | STANDARD = "standard" 63 | PREMIUM = "premium" 64 | VIP = "vip" 65 | 66 | 67 | class Customer(BaseModel): 68 | id: UUID 69 | email: str 70 | full_name: str 71 | phone: Optional[str] = Field(None, min_length=10, max_length=15) 72 | tier: CustomerTier = CustomerTier.STANDARD 73 | addresses: List[Address] = [] 74 | is_active: bool = True 75 | created_at: datetime 76 | last_login: Optional[datetime] = None 77 | preferences: Dict[str, Any] = {} 78 | consent: Dict[str, bool] = {} 79 | 80 | 81 | class Product(BaseModel): 82 | id: UUID 83 | name: str 84 | description: str 85 | category: ProductCategory 86 | price: float = Field(..., gt=0) 87 | discount_percent: Optional[float] = Field(None, ge=0, le=100) 88 | tax_rate: Optional[float] = Field(None, ge=0, le=100) 89 | variants: List[ProductVariant] = [] 90 | tags: List[str] = [] 91 | image_urls: List[str] = [] 92 | rating: Optional[float] = Field(None, ge=0, le=5) 93 | review_count: int = Field(0, ge=0) 94 | created_at: datetime 95 | updated_at: Optional[datetime] = None 96 | is_available: bool = True 97 | metadata: Dict[str, Any] = {} 98 | 99 | 100 | class OrderItem(BaseModel): 101 | product_id: UUID 102 | variant_sku: Optional[str] = None 103 | quantity: int = Field(..., gt=0) 104 | unit_price: float 105 | discount_amount: float = 0 106 | total: float 107 | 108 | 109 | class PaymentDetails(BaseModel): 110 | method: PaymentMethod 111 | transaction_id: Optional[str] = None 112 | status: str 113 | amount: float 114 | currency: str = "USD" 115 | paid_at: Optional[datetime] = None 116 | 117 | 118 | class OrderRequest(BaseModel): 119 | customer_id: UUID 120 | items: List[OrderItem] 121 | shipping_address_id: UUID 122 | billing_address_id: Optional[UUID] = None 123 | payment_method: PaymentMethod 124 | notes: Optional[str] = None 125 | use_loyalty_points: bool = False 126 | 127 | 128 | class OrderResponse(BaseModel): 129 | id: UUID 130 | customer_id: UUID 131 | status: OrderStatus = OrderStatus.PENDING 132 | items: List[OrderItem] 133 | shipping_address: Address 134 | billing_address: Address 135 | payment: PaymentDetails 136 | subtotal: float 137 | shipping_cost: float 138 | tax_amount: float 139 | discount_amount: float 140 | total_amount: float 141 | tracking_number: Optional[str] = None 142 | estimated_delivery: Optional[date] = None 143 | created_at: datetime 144 | updated_at: Optional[datetime] = None 145 | notes: Optional[str] = None 146 | metadata: Dict[str, Any] = {} 147 | 148 | 149 | class PaginatedResponse(BaseModel): 150 | items: List[Any] 151 | total: int 152 | page: int 153 | size: int 154 | pages: int 155 | 156 | 157 | class ErrorResponse(BaseModel): 158 | status_code: int 159 | message: str 160 | details: Optional[Dict[str, Any]] = None 161 | -------------------------------------------------------------------------------- /tests/test_basic_functionality.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from mcp.server.lowlevel.server import Server 3 | 4 | from fastapi_mcp import FastApiMCP 5 | 6 | 7 | def test_create_mcp_server(simple_fastapi_app: FastAPI): 8 | """Test creating an MCP server without mounting it.""" 9 | mcp = FastApiMCP( 10 | simple_fastapi_app, 11 | name="Test MCP Server", 12 | description="Test description", 13 | ) 14 | 15 | # Verify the MCP server was created correctly 16 | assert mcp.name == "Test MCP Server" 17 | assert mcp.description == "Test description" 18 | assert isinstance(mcp.server, Server) 19 | assert len(mcp.tools) > 0, "Should have extracted tools from the app" 20 | assert len(mcp.operation_map) > 0, "Should have operation mapping" 21 | 22 | # Check that the operation map contains all expected operations from simple_app 23 | expected_operations = ["list_items", "get_item", "create_item", "update_item", "delete_item", "raise_error"] 24 | for op in expected_operations: 25 | assert op in mcp.operation_map, f"Operation {op} not found in operation map" 26 | 27 | 28 | def test_default_values(simple_fastapi_app: FastAPI): 29 | """Test that default values are used when not explicitly provided.""" 30 | mcp = FastApiMCP(simple_fastapi_app) 31 | 32 | # Verify default values 33 | assert mcp.name == simple_fastapi_app.title 34 | assert mcp.description == simple_fastapi_app.description 35 | 36 | # Mount with default path 37 | mcp.mount() 38 | 39 | # Check that the MCP server was properly mounted 40 | # Look for a route that includes our mount path in its pattern 41 | route_found = any("/mcp" in str(route) for route in simple_fastapi_app.routes) 42 | assert route_found, "MCP server mount point not found in app routes" 43 | 44 | 45 | def test_normalize_paths(simple_fastapi_app: FastAPI): 46 | """Test that mount paths are normalized correctly.""" 47 | mcp = FastApiMCP(simple_fastapi_app) 48 | 49 | # Test with path without leading slash 50 | mount_path = "test-mcp" 51 | mcp.mount(mount_path=mount_path) 52 | 53 | # Check that the route was added with a normalized path 54 | route_found = any("/test-mcp" in str(route) for route in simple_fastapi_app.routes) 55 | assert route_found, "Normalized mount path not found in app routes" 56 | 57 | # Create a new MCP server 58 | mcp2 = FastApiMCP(simple_fastapi_app) 59 | 60 | # Test with path with trailing slash 61 | mount_path = "/test-mcp2/" 62 | mcp2.mount(mount_path=mount_path) 63 | 64 | # Check that the route was added with a normalized path 65 | route_found = any("/test-mcp2" in str(route) for route in simple_fastapi_app.routes) 66 | assert route_found, "Normalized mount path not found in app routes" 67 | -------------------------------------------------------------------------------- /tests/test_mcp_complex_app.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | import mcp.types as types 5 | from mcp.server.lowlevel import Server 6 | from mcp.shared.memory import create_connected_server_and_client_session 7 | from fastapi import FastAPI 8 | 9 | from fastapi_mcp import FastApiMCP 10 | 11 | from .fixtures.types import Product, Customer, OrderResponse 12 | 13 | 14 | @pytest.fixture 15 | def fastapi_mcp(complex_fastapi_app: FastAPI) -> FastApiMCP: 16 | mcp = FastApiMCP( 17 | complex_fastapi_app, 18 | name="Test MCP Server", 19 | description="Test description", 20 | ) 21 | mcp.mount() 22 | return mcp 23 | 24 | 25 | @pytest.fixture 26 | def lowlevel_server_complex_app(fastapi_mcp: FastApiMCP) -> Server: 27 | return fastapi_mcp.server 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_list_tools(lowlevel_server_complex_app: Server): 32 | async with create_connected_server_and_client_session(lowlevel_server_complex_app) as client_session: 33 | tools_result = await client_session.list_tools() 34 | 35 | assert len(tools_result.tools) > 0 36 | 37 | tool_names = [tool.name for tool in tools_result.tools] 38 | expected_operations = ["list_products", "get_product", "create_order", "get_customer"] 39 | for op in expected_operations: 40 | assert op in tool_names 41 | 42 | 43 | @pytest.mark.asyncio 44 | async def test_call_tool_list_products_default(lowlevel_server_complex_app: Server): 45 | async with create_connected_server_and_client_session(lowlevel_server_complex_app) as client_session: 46 | response = await client_session.call_tool("list_products", {}) 47 | 48 | assert not response.isError 49 | assert len(response.content) > 0 50 | 51 | text_content = next(c for c in response.content if isinstance(c, types.TextContent)) 52 | result = json.loads(text_content.text) 53 | 54 | assert "items" in result 55 | assert result["total"] == 1 56 | assert result["page"] == 1 57 | assert len(result["items"]) == 1 58 | 59 | 60 | @pytest.mark.asyncio 61 | async def test_call_tool_list_products_with_filters(lowlevel_server_complex_app: Server): 62 | async with create_connected_server_and_client_session(lowlevel_server_complex_app) as client_session: 63 | response = await client_session.call_tool( 64 | "list_products", 65 | {"category": "electronics", "min_price": 10.0, "page": 1, "size": 10, "in_stock_only": True}, 66 | ) 67 | 68 | assert not response.isError 69 | assert len(response.content) > 0 70 | 71 | text_content = next(c for c in response.content if isinstance(c, types.TextContent)) 72 | result = json.loads(text_content.text) 73 | 74 | assert "items" in result 75 | assert result["page"] == 1 76 | assert result["size"] == 10 77 | 78 | 79 | @pytest.mark.asyncio 80 | async def test_call_tool_get_product(lowlevel_server_complex_app: Server, example_product: Product): 81 | product_id = "123e4567-e89b-12d3-a456-426614174000" # Valid UUID format 82 | 83 | async with create_connected_server_and_client_session(lowlevel_server_complex_app) as client_session: 84 | response = await client_session.call_tool("get_product", {"product_id": product_id}) 85 | 86 | assert not response.isError 87 | assert len(response.content) > 0 88 | 89 | text_content = next(c for c in response.content if isinstance(c, types.TextContent)) 90 | result = json.loads(text_content.text) 91 | 92 | assert result["id"] == product_id 93 | assert "name" in result 94 | assert "price" in result 95 | assert "description" in result 96 | 97 | 98 | @pytest.mark.asyncio 99 | async def test_call_tool_get_product_with_options(lowlevel_server_complex_app: Server): 100 | product_id = "123e4567-e89b-12d3-a456-426614174000" # Valid UUID format 101 | 102 | async with create_connected_server_and_client_session(lowlevel_server_complex_app) as client_session: 103 | response = await client_session.call_tool( 104 | "get_product", {"product_id": product_id, "include_unavailable": True} 105 | ) 106 | 107 | assert not response.isError 108 | assert len(response.content) > 0 109 | 110 | text_content = next(c for c in response.content if isinstance(c, types.TextContent)) 111 | result = json.loads(text_content.text) 112 | 113 | assert result["id"] == product_id 114 | 115 | 116 | @pytest.mark.asyncio 117 | async def test_call_tool_create_order(lowlevel_server_complex_app: Server, example_order_response: OrderResponse): 118 | customer_id = "123e4567-e89b-12d3-a456-426614174000" # Valid UUID format 119 | product_id = "123e4567-e89b-12d3-a456-426614174001" # Valid UUID format 120 | shipping_address_id = "123e4567-e89b-12d3-a456-426614174002" # Valid UUID format 121 | 122 | order_request = { 123 | "customer_id": customer_id, 124 | "items": [{"product_id": product_id, "quantity": 2, "unit_price": 29.99, "total": 59.98}], 125 | "shipping_address_id": shipping_address_id, 126 | "payment_method": "credit_card", 127 | } 128 | 129 | async with create_connected_server_and_client_session(lowlevel_server_complex_app) as client_session: 130 | response = await client_session.call_tool("create_order", order_request) 131 | 132 | assert not response.isError 133 | assert len(response.content) > 0 134 | 135 | text_content = next(c for c in response.content if isinstance(c, types.TextContent)) 136 | result = json.loads(text_content.text) 137 | 138 | assert result["customer_id"] == customer_id 139 | assert "id" in result 140 | assert "status" in result 141 | assert "items" in result 142 | assert len(result["items"]) > 0 143 | 144 | 145 | @pytest.mark.asyncio 146 | async def test_call_tool_create_order_validation_error(lowlevel_server_complex_app: Server): 147 | # Missing required fields 148 | order_request = { 149 | # Missing customer_id 150 | "items": [], 151 | # Missing shipping_address_id 152 | "payment_method": "credit_card", 153 | } 154 | 155 | async with create_connected_server_and_client_session(lowlevel_server_complex_app) as client_session: 156 | response = await client_session.call_tool("create_order", order_request) 157 | 158 | assert response.isError 159 | assert len(response.content) > 0 160 | 161 | text_content = next(c for c in response.content if isinstance(c, types.TextContent)) 162 | assert "422" in text_content.text or "validation" in text_content.text.lower() 163 | 164 | 165 | @pytest.mark.asyncio 166 | async def test_call_tool_get_customer(lowlevel_server_complex_app: Server, example_customer: Customer): 167 | customer_id = "123e4567-e89b-12d3-a456-426614174000" # Valid UUID format 168 | 169 | async with create_connected_server_and_client_session(lowlevel_server_complex_app) as client_session: 170 | response = await client_session.call_tool("get_customer", {"customer_id": customer_id}) 171 | 172 | assert not response.isError 173 | assert len(response.content) > 0 174 | 175 | text_content = next(c for c in response.content if isinstance(c, types.TextContent)) 176 | result = json.loads(text_content.text) 177 | 178 | assert result["id"] == customer_id 179 | assert "full_name" in result 180 | assert "email" in result 181 | 182 | 183 | @pytest.mark.asyncio 184 | async def test_call_tool_get_customer_with_options(lowlevel_server_complex_app: Server): 185 | customer_id = "123e4567-e89b-12d3-a456-426614174000" # Valid UUID format 186 | 187 | async with create_connected_server_and_client_session(lowlevel_server_complex_app) as client_session: 188 | response = await client_session.call_tool( 189 | "get_customer", 190 | { 191 | "customer_id": customer_id, 192 | "include_orders": True, 193 | "include_payment_methods": True, 194 | "fields": ["full_name", "email", "orders"], 195 | }, 196 | ) 197 | 198 | assert not response.isError 199 | assert len(response.content) > 0 200 | 201 | text_content = next(c for c in response.content if isinstance(c, types.TextContent)) 202 | result = json.loads(text_content.text) 203 | 204 | assert result["id"] == customer_id 205 | 206 | 207 | @pytest.mark.asyncio 208 | async def test_error_handling_missing_parameter(lowlevel_server_complex_app: Server): 209 | async with create_connected_server_and_client_session(lowlevel_server_complex_app) as client_session: 210 | # Missing required product_id parameter 211 | response = await client_session.call_tool("get_product", {}) 212 | 213 | assert response.isError 214 | assert len(response.content) > 0 215 | 216 | text_content = next(c for c in response.content if isinstance(c, types.TextContent)) 217 | assert ( 218 | "422" in text_content.text 219 | or "parameter" in text_content.text.lower() 220 | or "field" in text_content.text.lower() 221 | ) 222 | -------------------------------------------------------------------------------- /tests/test_mcp_execute_api_tool.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import AsyncMock, patch, MagicMock 3 | from fastapi import FastAPI 4 | 5 | from fastapi_mcp import FastApiMCP 6 | from mcp.types import TextContent 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_execute_api_tool_success(simple_fastapi_app: FastAPI): 11 | """Test successful execution of an API tool.""" 12 | mcp = FastApiMCP(simple_fastapi_app) 13 | 14 | # Mock the HTTP client response 15 | mock_response = MagicMock() 16 | mock_response.json.return_value = {"id": 1, "name": "Test Item"} 17 | mock_response.status_code = 200 18 | mock_response.text = '{"id": 1, "name": "Test Item"}' 19 | 20 | # Mock the HTTP client 21 | mock_client = AsyncMock() 22 | mock_client.get.return_value = mock_response 23 | 24 | # Test parameters 25 | tool_name = "get_item" 26 | arguments = {"item_id": 1} 27 | 28 | # Execute the tool 29 | with patch.object(mcp, '_http_client', mock_client): 30 | result = await mcp._execute_api_tool( 31 | client=mock_client, 32 | tool_name=tool_name, 33 | arguments=arguments, 34 | operation_map=mcp.operation_map 35 | ) 36 | 37 | # Verify the result 38 | assert len(result) == 1 39 | assert isinstance(result[0], TextContent) 40 | assert result[0].text == '{\n "id": 1,\n "name": "Test Item"\n}' 41 | 42 | # Verify the HTTP client was called correctly 43 | mock_client.get.assert_called_once_with( 44 | "/items/1", 45 | params={}, 46 | headers={} 47 | ) 48 | 49 | 50 | @pytest.mark.asyncio 51 | async def test_execute_api_tool_with_query_params(simple_fastapi_app: FastAPI): 52 | """Test execution of an API tool with query parameters.""" 53 | mcp = FastApiMCP(simple_fastapi_app) 54 | 55 | # Mock the HTTP client response 56 | mock_response = MagicMock() 57 | mock_response.json.return_value = [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}] 58 | mock_response.status_code = 200 59 | mock_response.text = '[{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}]' 60 | 61 | # Mock the HTTP client 62 | mock_client = AsyncMock() 63 | mock_client.get.return_value = mock_response 64 | 65 | # Test parameters 66 | tool_name = "list_items" 67 | arguments = {"skip": 0, "limit": 2} 68 | 69 | # Execute the tool 70 | with patch.object(mcp, '_http_client', mock_client): 71 | result = await mcp._execute_api_tool( 72 | client=mock_client, 73 | tool_name=tool_name, 74 | arguments=arguments, 75 | operation_map=mcp.operation_map 76 | ) 77 | 78 | # Verify the result 79 | assert len(result) == 1 80 | assert isinstance(result[0], TextContent) 81 | 82 | # Verify the HTTP client was called with query parameters 83 | mock_client.get.assert_called_once_with( 84 | "/items/", 85 | params={"skip": 0, "limit": 2}, 86 | headers={} 87 | ) 88 | 89 | 90 | @pytest.mark.asyncio 91 | async def test_execute_api_tool_with_body(simple_fastapi_app: FastAPI): 92 | """Test execution of an API tool with request body.""" 93 | mcp = FastApiMCP(simple_fastapi_app) 94 | 95 | # Mock the HTTP client response 96 | mock_response = MagicMock() 97 | mock_response.json.return_value = {"id": 1, "name": "New Item"} 98 | mock_response.status_code = 200 99 | mock_response.text = '{"id": 1, "name": "New Item"}' 100 | 101 | # Mock the HTTP client 102 | mock_client = AsyncMock() 103 | mock_client.post.return_value = mock_response 104 | 105 | # Test parameters 106 | tool_name = "create_item" 107 | arguments = { 108 | "item": { 109 | "id": 1, 110 | "name": "New Item", 111 | "price": 10.0, 112 | "tags": ["tag1"], 113 | "description": "New item description" 114 | } 115 | } 116 | 117 | # Execute the tool 118 | with patch.object(mcp, '_http_client', mock_client): 119 | result = await mcp._execute_api_tool( 120 | client=mock_client, 121 | tool_name=tool_name, 122 | arguments=arguments, 123 | operation_map=mcp.operation_map 124 | ) 125 | 126 | # Verify the result 127 | assert len(result) == 1 128 | assert isinstance(result[0], TextContent) 129 | 130 | # Verify the HTTP client was called with the request body 131 | mock_client.post.assert_called_once_with( 132 | "/items/", 133 | params={}, 134 | headers={}, 135 | json=arguments 136 | ) 137 | 138 | 139 | @pytest.mark.asyncio 140 | async def test_execute_api_tool_with_non_ascii_chars(simple_fastapi_app: FastAPI): 141 | """Test execution of an API tool with non-ASCII characters.""" 142 | mcp = FastApiMCP(simple_fastapi_app) 143 | 144 | # Test data with both ASCII and non-ASCII characters 145 | test_data = { 146 | "id": 1, 147 | "name": "你好 World", # Chinese characters + ASCII 148 | "price": 10.0, 149 | "tags": ["tag1", "标签2"], # Chinese characters in tags 150 | "description": "这是一个测试描述" # All Chinese characters 151 | } 152 | 153 | # Mock the HTTP client response 154 | mock_response = MagicMock() 155 | mock_response.json.return_value = test_data 156 | mock_response.status_code = 200 157 | mock_response.text = '{"id": 1, "name": "你好 World", "price": 10.0, "tags": ["tag1", "标签2"], "description": "这是一个测试描述"}' 158 | 159 | # Mock the HTTP client 160 | mock_client = AsyncMock() 161 | mock_client.get.return_value = mock_response 162 | 163 | # Test parameters 164 | tool_name = "get_item" 165 | arguments = {"item_id": 1} 166 | 167 | # Execute the tool 168 | with patch.object(mcp, '_http_client', mock_client): 169 | result = await mcp._execute_api_tool( 170 | client=mock_client, 171 | tool_name=tool_name, 172 | arguments=arguments, 173 | operation_map=mcp.operation_map 174 | ) 175 | 176 | # Verify the result 177 | assert len(result) == 1 178 | assert isinstance(result[0], TextContent) 179 | 180 | # Verify that the response contains both ASCII and non-ASCII characters 181 | response_text = result[0].text 182 | assert "你好" in response_text # Chinese characters preserved 183 | assert "World" in response_text # ASCII characters preserved 184 | assert "标签2" in response_text # Chinese characters in tags preserved 185 | assert "这是一个测试描述" in response_text # All Chinese description preserved 186 | 187 | # Verify the HTTP client was called correctly 188 | mock_client.get.assert_called_once_with( 189 | "/items/1", 190 | params={}, 191 | headers={} 192 | ) 193 | -------------------------------------------------------------------------------- /tests/test_mcp_simple_app.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | import mcp.types as types 5 | from mcp.server.lowlevel import Server 6 | from mcp.shared.memory import create_connected_server_and_client_session 7 | from fastapi import FastAPI 8 | 9 | from fastapi_mcp import FastApiMCP 10 | 11 | from .fixtures.types import Item 12 | 13 | 14 | @pytest.fixture 15 | def fastapi_mcp(simple_fastapi_app: FastAPI) -> FastApiMCP: 16 | mcp = FastApiMCP( 17 | simple_fastapi_app, 18 | name="Test MCP Server", 19 | description="Test description", 20 | ) 21 | mcp.mount() 22 | return mcp 23 | 24 | 25 | @pytest.fixture 26 | def lowlevel_server_simple_app(fastapi_mcp: FastApiMCP) -> Server: 27 | return fastapi_mcp.server 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_list_tools(lowlevel_server_simple_app: Server): 32 | """Test listing tools via direct MCP connection.""" 33 | async with create_connected_server_and_client_session(lowlevel_server_simple_app) as client_session: 34 | tools_result = await client_session.list_tools() 35 | 36 | assert len(tools_result.tools) > 0 37 | 38 | tool_names = [tool.name for tool in tools_result.tools] 39 | expected_operations = ["list_items", "get_item", "create_item", "update_item", "delete_item", "raise_error"] 40 | for op in expected_operations: 41 | assert op in tool_names 42 | 43 | 44 | @pytest.mark.asyncio 45 | async def test_call_tool_get_item_1(lowlevel_server_simple_app: Server): 46 | async with create_connected_server_and_client_session(lowlevel_server_simple_app) as client_session: 47 | response = await client_session.call_tool("get_item", {"item_id": 1}) 48 | 49 | assert not response.isError 50 | assert len(response.content) > 0 51 | 52 | text_content = next(c for c in response.content if isinstance(c, types.TextContent)) 53 | result: dict = json.loads(text_content.text) 54 | parsed_result = Item(**result) 55 | 56 | assert parsed_result.id == 1 57 | assert parsed_result.name == "Item 1" 58 | assert parsed_result.price == 10.0 59 | assert parsed_result.tags == ["tag1", "tag2"] 60 | 61 | 62 | @pytest.mark.asyncio 63 | async def test_call_tool_get_item_2(lowlevel_server_simple_app: Server): 64 | async with create_connected_server_and_client_session(lowlevel_server_simple_app) as client_session: 65 | response = await client_session.call_tool("get_item", {"item_id": 2}) 66 | 67 | assert not response.isError 68 | assert len(response.content) > 0 69 | 70 | text_content = next(c for c in response.content if isinstance(c, types.TextContent)) 71 | result: dict = json.loads(text_content.text) 72 | parsed_result = Item(**result) 73 | 74 | assert parsed_result.id == 2 75 | assert parsed_result.name == "Item 2" 76 | assert parsed_result.price == 20.0 77 | assert parsed_result.tags == ["tag2", "tag3"] 78 | 79 | 80 | @pytest.mark.asyncio 81 | async def test_call_tool_raise_error(lowlevel_server_simple_app: Server): 82 | async with create_connected_server_and_client_session(lowlevel_server_simple_app) as client_session: 83 | response = await client_session.call_tool("raise_error", {}) 84 | 85 | assert response.isError 86 | assert len(response.content) > 0 87 | 88 | text_content = next(c for c in response.content if isinstance(c, types.TextContent)) 89 | assert "500" in text_content.text 90 | assert "internal server error" in text_content.text.lower() 91 | 92 | 93 | @pytest.mark.asyncio 94 | async def test_error_handling(lowlevel_server_simple_app: Server): 95 | async with create_connected_server_and_client_session(lowlevel_server_simple_app) as client_session: 96 | response = await client_session.call_tool("get_item", {}) 97 | 98 | assert response.isError 99 | assert len(response.content) > 0 100 | 101 | text_content = next(c for c in response.content if isinstance(c, types.TextContent)) 102 | assert "item_id" in text_content.text.lower() or "missing" in text_content.text.lower() 103 | assert "422" in text_content.text, "Expected a 422 status to appear in the response text" 104 | 105 | 106 | @pytest.mark.asyncio 107 | async def test_complex_tool_arguments(lowlevel_server_simple_app: Server): 108 | test_item = { 109 | "id": 42, 110 | "name": "Test Item", 111 | "description": "A test item for MCP", 112 | "price": 9.99, 113 | "tags": ["test", "mcp"], 114 | } 115 | 116 | async with create_connected_server_and_client_session(lowlevel_server_simple_app) as client_session: 117 | response = await client_session.call_tool("create_item", test_item) 118 | 119 | assert not response.isError 120 | assert len(response.content) > 0 121 | 122 | text_content = next(c for c in response.content if isinstance(c, types.TextContent)) 123 | result = json.loads(text_content.text) 124 | 125 | assert result["id"] == test_item["id"] 126 | assert result["name"] == test_item["name"] 127 | assert result["price"] == test_item["price"] 128 | assert result["tags"] == test_item["tags"] 129 | 130 | 131 | @pytest.mark.asyncio 132 | async def test_call_tool_list_items_default(lowlevel_server_simple_app: Server): 133 | async with create_connected_server_and_client_session(lowlevel_server_simple_app) as client_session: 134 | response = await client_session.call_tool("list_items", {}) 135 | 136 | assert not response.isError 137 | assert len(response.content) > 0 138 | 139 | text_content = next(c for c in response.content if isinstance(c, types.TextContent)) 140 | results = json.loads(text_content.text) 141 | assert len(results) == 3 # Default should return all three items with default pagination 142 | 143 | # Check first item matches expected data 144 | item = results[0] 145 | assert item["id"] == 1 146 | assert item["name"] == "Item 1" 147 | 148 | 149 | @pytest.mark.asyncio 150 | async def test_call_tool_list_items_with_pagination(lowlevel_server_simple_app: Server): 151 | async with create_connected_server_and_client_session(lowlevel_server_simple_app) as client_session: 152 | response = await client_session.call_tool("list_items", {"skip": 1, "limit": 1}) 153 | 154 | assert not response.isError 155 | assert len(response.content) > 0 156 | 157 | text_content = next(c for c in response.content if isinstance(c, types.TextContent)) 158 | results = json.loads(text_content.text) 159 | assert len(results) == 1 160 | 161 | # Should be the second item in the list (after skipping the first) 162 | item = results[0] 163 | assert item["id"] == 2 164 | assert item["name"] == "Item 2" 165 | 166 | 167 | @pytest.mark.asyncio 168 | async def test_call_tool_get_item_not_found(lowlevel_server_simple_app: Server): 169 | async with create_connected_server_and_client_session(lowlevel_server_simple_app) as client_session: 170 | response = await client_session.call_tool("get_item", {"item_id": 999}) 171 | 172 | assert response.isError 173 | assert len(response.content) > 0 174 | 175 | text_content = next(c for c in response.content if isinstance(c, types.TextContent)) 176 | assert "404" in text_content.text 177 | assert "not found" in text_content.text.lower() 178 | 179 | 180 | @pytest.mark.asyncio 181 | async def test_call_tool_update_item(lowlevel_server_simple_app: Server): 182 | test_update = { 183 | "item_id": 3, 184 | "id": 3, 185 | "name": "Updated Item 3", 186 | "description": "Updated description", 187 | "price": 35.99, 188 | "tags": ["updated", "modified"], 189 | } 190 | 191 | async with create_connected_server_and_client_session(lowlevel_server_simple_app) as client_session: 192 | response = await client_session.call_tool("update_item", test_update) 193 | 194 | assert not response.isError 195 | assert len(response.content) > 0 196 | 197 | text_content = next(c for c in response.content if isinstance(c, types.TextContent)) 198 | result = json.loads(text_content.text) 199 | 200 | assert result["id"] == test_update["item_id"] 201 | assert result["name"] == test_update["name"] 202 | assert result["description"] == test_update["description"] 203 | assert result["price"] == test_update["price"] 204 | assert result["tags"] == test_update["tags"] 205 | 206 | 207 | @pytest.mark.asyncio 208 | async def test_call_tool_delete_item(lowlevel_server_simple_app: Server): 209 | async with create_connected_server_and_client_session(lowlevel_server_simple_app) as client_session: 210 | response = await client_session.call_tool("delete_item", {"item_id": 3}) 211 | 212 | assert not response.isError 213 | # The endpoint returns 204 No Content, so we expect an empty response 214 | text_content = next(c for c in response.content if isinstance(c, types.TextContent)) 215 | assert ( 216 | text_content.text.strip() == "{}" or text_content.text.strip() == "null" or text_content.text.strip() == "" 217 | ) 218 | 219 | 220 | @pytest.mark.asyncio 221 | async def test_call_tool_get_item_with_details(lowlevel_server_simple_app: Server): 222 | async with create_connected_server_and_client_session(lowlevel_server_simple_app) as client_session: 223 | response = await client_session.call_tool("get_item", {"item_id": 1, "include_details": True}) 224 | 225 | assert not response.isError 226 | assert len(response.content) > 0 227 | 228 | text_content = next(c for c in response.content if isinstance(c, types.TextContent)) 229 | result: dict = json.loads(text_content.text) 230 | parsed_result = Item(**result) 231 | 232 | assert parsed_result.id == 1 233 | assert parsed_result.name == "Item 1" 234 | assert parsed_result.price == 10.0 235 | assert parsed_result.tags == ["tag1", "tag2"] 236 | assert parsed_result.description == "Item 1 description" 237 | 238 | 239 | @pytest.mark.asyncio 240 | async def test_headers_passthrough_to_tool_handler(fastapi_mcp: FastApiMCP): 241 | """Test that the original request's headers pass through to the MCP tool call handler.""" 242 | from unittest.mock import patch, MagicMock 243 | from fastapi_mcp.types import HTTPRequestInfo 244 | 245 | # Test with uppercase "Authorization" header 246 | with patch.object(fastapi_mcp, "_request") as mock_request: 247 | mock_response = MagicMock() 248 | mock_response.status_code = 200 249 | mock_response.text = '{"result": "success"}' 250 | mock_response.json.return_value = {"result": "success"} 251 | mock_request.return_value = mock_response 252 | 253 | http_request_info = HTTPRequestInfo( 254 | method="POST", 255 | path="/test", 256 | headers={"Authorization": "Bearer token123"}, 257 | cookies={}, 258 | query_params={}, 259 | body=None, 260 | ) 261 | 262 | try: 263 | # Call the _execute_api_tool method directly 264 | # We don't care if it succeeds, just that _request gets the right headers 265 | await fastapi_mcp._execute_api_tool( 266 | client=fastapi_mcp._http_client, 267 | tool_name="get_item", 268 | arguments={"item_id": 1}, 269 | operation_map=fastapi_mcp.operation_map, 270 | http_request_info=http_request_info, 271 | ) 272 | except Exception: 273 | pass 274 | 275 | assert mock_request.called, "The _request method was not called" 276 | 277 | if mock_request.called: 278 | headers_arg = mock_request.call_args[0][4] # headers are the 5th argument 279 | assert "Authorization" in headers_arg 280 | assert headers_arg["Authorization"] == "Bearer token123" 281 | 282 | # Test again with lowercase "authorization" header 283 | with patch.object(fastapi_mcp, "_request") as mock_request: 284 | mock_response = MagicMock() 285 | mock_response.status_code = 200 286 | mock_response.text = '{"result": "success"}' 287 | mock_response.json.return_value = {"result": "success"} 288 | mock_request.return_value = mock_response 289 | 290 | http_request_info = HTTPRequestInfo( 291 | method="POST", 292 | path="/test", 293 | headers={"authorization": "Bearer token456"}, 294 | cookies={}, 295 | query_params={}, 296 | body=None, 297 | ) 298 | 299 | try: 300 | await fastapi_mcp._execute_api_tool( 301 | client=fastapi_mcp._http_client, 302 | tool_name="get_item", 303 | arguments={"item_id": 1}, 304 | operation_map=fastapi_mcp.operation_map, 305 | http_request_info=http_request_info, 306 | ) 307 | except Exception: 308 | pass 309 | 310 | assert mock_request.called, "The _request method was not called" 311 | 312 | if mock_request.called: 313 | headers_arg = mock_request.call_args[0][4] # headers are the 5th argument 314 | assert "Authorization" in headers_arg 315 | assert headers_arg["Authorization"] == "Bearer token456" 316 | -------------------------------------------------------------------------------- /tests/test_sse_mock_transport.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import uuid 3 | from uuid import UUID 4 | from unittest.mock import AsyncMock, MagicMock, patch 5 | from fastapi import HTTPException, Request 6 | from pydantic import ValidationError 7 | from anyio.streams.memory import MemoryObjectSendStream 8 | 9 | from fastapi_mcp.transport.sse import FastApiSseTransport 10 | from mcp.types import JSONRPCMessage, JSONRPCError 11 | 12 | 13 | @pytest.fixture 14 | def mock_transport() -> FastApiSseTransport: 15 | # Initialize transport with a mock endpoint 16 | transport = FastApiSseTransport("/messages") 17 | transport._read_stream_writers = {} 18 | return transport 19 | 20 | 21 | @pytest.fixture 22 | def valid_session_id(): 23 | session_id = uuid.uuid4() 24 | return session_id 25 | 26 | 27 | @pytest.fixture 28 | def mock_writer(): 29 | return AsyncMock(spec=MemoryObjectSendStream) 30 | 31 | 32 | @pytest.mark.anyio 33 | async def test_handle_post_message_missing_session_id(mock_transport: FastApiSseTransport) -> None: 34 | """Test handling a request with a missing session_id.""" 35 | # Create a mock request with no session_id 36 | mock_request = MagicMock(spec=Request) 37 | mock_request.query_params = {} 38 | 39 | # Check that the function raises HTTPException with the correct status code 40 | with pytest.raises(HTTPException) as excinfo: 41 | await mock_transport.handle_fastapi_post_message(mock_request) 42 | 43 | assert excinfo.value.status_code == 400 44 | assert "session_id is required" in excinfo.value.detail 45 | 46 | 47 | @pytest.mark.anyio 48 | async def test_handle_post_message_invalid_session_id(mock_transport: FastApiSseTransport) -> None: 49 | """Test handling a request with an invalid session_id.""" 50 | # Create a mock request with an invalid session_id 51 | mock_request = MagicMock(spec=Request) 52 | mock_request.query_params = {"session_id": "not-a-valid-uuid"} 53 | 54 | # Check that the function raises HTTPException with the correct status code 55 | with pytest.raises(HTTPException) as excinfo: 56 | await mock_transport.handle_fastapi_post_message(mock_request) 57 | 58 | assert excinfo.value.status_code == 400 59 | assert "Invalid session ID" in excinfo.value.detail 60 | 61 | 62 | @pytest.mark.anyio 63 | async def test_handle_post_message_session_not_found( 64 | mock_transport: FastApiSseTransport, valid_session_id: UUID 65 | ) -> None: 66 | """Test handling a request with a valid session_id that doesn't exist.""" 67 | # Create a mock request with a valid session_id 68 | mock_request = MagicMock(spec=Request) 69 | mock_request.query_params = {"session_id": valid_session_id.hex} 70 | 71 | # The session_id is valid but not in the transport's writers 72 | with pytest.raises(HTTPException) as excinfo: 73 | await mock_transport.handle_fastapi_post_message(mock_request) 74 | 75 | assert excinfo.value.status_code == 404 76 | assert "Could not find session" in excinfo.value.detail 77 | 78 | 79 | @pytest.mark.anyio 80 | async def test_handle_post_message_validation_error( 81 | mock_transport: FastApiSseTransport, valid_session_id: UUID, mock_writer: AsyncMock 82 | ) -> None: 83 | """Test handling a request with invalid JSON that causes a ValidationError.""" 84 | # Set up the mock transport with a valid session 85 | mock_transport._read_stream_writers[valid_session_id] = mock_writer 86 | 87 | # Create a mock request with valid session_id but invalid body 88 | mock_request = MagicMock(spec=Request) 89 | mock_request.query_params = {"session_id": valid_session_id.hex} 90 | mock_request.body = AsyncMock(return_value=b'{"invalid": "json"}') 91 | 92 | # Mock BackgroundTasks 93 | with patch("fastapi_mcp.transport.sse.BackgroundTasks") as MockBackgroundTasks: 94 | mock_background_tasks = MockBackgroundTasks.return_value 95 | 96 | # Call the function 97 | response = await mock_transport.handle_fastapi_post_message(mock_request) 98 | 99 | # Verify response and background task setup 100 | assert response.status_code == 400 101 | assert "error" in response.body.decode() if isinstance(response.body, bytes) else False 102 | assert mock_background_tasks.add_task.called 103 | assert response.background == mock_background_tasks 104 | 105 | 106 | @pytest.mark.anyio 107 | async def test_handle_post_message_general_exception( 108 | mock_transport: FastApiSseTransport, valid_session_id: UUID, mock_writer: AsyncMock 109 | ) -> None: 110 | """Test handling a request that causes a general exception during body processing.""" 111 | # Set up the mock transport with a valid session 112 | mock_transport._read_stream_writers[valid_session_id] = mock_writer 113 | 114 | # Create a mock request that raises an exception when body is accessed 115 | mock_request = MagicMock(spec=Request) 116 | mock_request.query_params = {"session_id": valid_session_id.hex} 117 | 118 | # Instead of mocking the body method to raise an exception, 119 | # we'll patch the body method to return a normal value and then 120 | # patch JSONRPCMessage.model_validate_json to raise the exception 121 | mock_request.body = AsyncMock(return_value=b'{"jsonrpc": "2.0", "method": "test", "id": "1"}') 122 | 123 | # Mock the model_validate_json method to raise an Exception 124 | with patch("mcp.types.JSONRPCMessage.model_validate_json", side_effect=Exception("Test exception")): 125 | # Check that the function raises HTTPException with the correct status code 126 | with pytest.raises(HTTPException) as excinfo: 127 | await mock_transport.handle_fastapi_post_message(mock_request) 128 | 129 | assert excinfo.value.status_code == 400 130 | assert "Invalid request body" in excinfo.value.detail 131 | 132 | 133 | @pytest.mark.anyio 134 | async def test_send_message_safely_with_validation_error( 135 | mock_transport: FastApiSseTransport, mock_writer: AsyncMock 136 | ) -> None: 137 | """Test sending a ValidationError message safely.""" 138 | # Create a minimal validation error manually instead of using from_exception_data 139 | mock_validation_error = MagicMock(spec=ValidationError) 140 | mock_validation_error.__str__.return_value = "Mock validation error" # type: ignore 141 | 142 | # Call the function 143 | await mock_transport._send_message_safely(mock_writer, mock_validation_error) 144 | 145 | # Verify that the writer.send was called with a JSONRPCError 146 | assert mock_writer.send.called 147 | sent_message = mock_writer.send.call_args[0][0] 148 | assert isinstance(sent_message, JSONRPCMessage) 149 | assert isinstance(sent_message.root, JSONRPCError) 150 | assert sent_message.root.error.code == -32700 # Parse error code 151 | 152 | 153 | @pytest.mark.anyio 154 | async def test_send_message_safely_with_jsonrpc_message( 155 | mock_transport: FastApiSseTransport, mock_writer: AsyncMock 156 | ) -> None: 157 | """Test sending a JSONRPCMessage safely.""" 158 | # Create a JSONRPCMessage 159 | message = JSONRPCMessage.model_validate({"jsonrpc": "2.0", "id": "123", "method": "test_method", "params": {}}) 160 | 161 | # Call the function 162 | await mock_transport._send_message_safely(mock_writer, message) 163 | 164 | # Verify that the writer.send was called with the message 165 | assert mock_writer.send.called 166 | sent_message = mock_writer.send.call_args[0][0] 167 | assert sent_message == message 168 | 169 | 170 | @pytest.mark.anyio 171 | async def test_send_message_safely_exception_handling( 172 | mock_transport: FastApiSseTransport, mock_writer: AsyncMock 173 | ) -> None: 174 | """Test exception handling when sending a message.""" 175 | # Set up the writer to raise an exception 176 | mock_writer.send.side_effect = Exception("Test exception") 177 | 178 | # Create a message 179 | message = JSONRPCMessage.model_validate({"jsonrpc": "2.0", "id": "123", "method": "test_method", "params": {}}) 180 | 181 | # Call the function - it should not raise an exception 182 | await mock_transport._send_message_safely(mock_writer, message) 183 | 184 | # Verify that the writer.send was called 185 | assert mock_writer.send.called 186 | -------------------------------------------------------------------------------- /tests/test_sse_real_transport.py: -------------------------------------------------------------------------------- 1 | import anyio 2 | import multiprocessing 3 | import socket 4 | import time 5 | import os 6 | import signal 7 | import atexit 8 | import sys 9 | import threading 10 | import coverage 11 | from typing import AsyncGenerator, Generator 12 | from mcp.client.session import ClientSession 13 | from mcp.client.sse import sse_client 14 | from mcp import InitializeResult 15 | from mcp.types import EmptyResult, CallToolResult, ListToolsResult 16 | import pytest 17 | import httpx 18 | import uvicorn 19 | from fastapi_mcp import FastApiMCP 20 | 21 | from .fixtures.simple_app import make_simple_fastapi_app 22 | 23 | 24 | HOST = "127.0.0.1" 25 | SERVER_NAME = "Test MCP Server" 26 | 27 | 28 | @pytest.fixture 29 | def server_port() -> int: 30 | with socket.socket() as s: 31 | s.bind((HOST, 0)) 32 | return s.getsockname()[1] 33 | 34 | 35 | @pytest.fixture 36 | def server_url(server_port: int) -> str: 37 | return f"http://{HOST}:{server_port}" 38 | 39 | 40 | def run_server(server_port: int) -> None: 41 | # Initialize coverage for subprocesses 42 | cov = None 43 | if "COVERAGE_PROCESS_START" in os.environ: 44 | cov = coverage.Coverage(source=["fastapi_mcp"]) 45 | cov.start() 46 | 47 | # Create a function to save coverage data at exit 48 | def cleanup(): 49 | if cov: 50 | cov.stop() 51 | cov.save() 52 | 53 | # Register multiple cleanup mechanisms to ensure coverage data is saved 54 | atexit.register(cleanup) 55 | 56 | # Setup signal handler for clean termination 57 | def handle_signal(signum, frame): 58 | cleanup() 59 | sys.exit(0) 60 | 61 | signal.signal(signal.SIGTERM, handle_signal) 62 | 63 | # Backup thread to ensure coverage is written if process is terminated abruptly 64 | def periodic_save(): 65 | while True: 66 | time.sleep(1.0) 67 | if cov: 68 | cov.save() 69 | 70 | save_thread = threading.Thread(target=periodic_save) 71 | save_thread.daemon = True 72 | save_thread.start() 73 | 74 | # Configure the server 75 | fastapi = make_simple_fastapi_app() 76 | mcp = FastApiMCP( 77 | fastapi, 78 | name=SERVER_NAME, 79 | description="Test description", 80 | ) 81 | mcp.mount() 82 | 83 | # Start the server 84 | server = uvicorn.Server(config=uvicorn.Config(app=fastapi, host=HOST, port=server_port, log_level="error")) 85 | server.run() 86 | 87 | # Give server time to start 88 | while not server.started: 89 | time.sleep(0.5) 90 | 91 | # Ensure coverage is saved if exiting the normal way 92 | if cov: 93 | cov.stop() 94 | cov.save() 95 | 96 | 97 | @pytest.fixture() 98 | def server(server_port: int) -> Generator[None, None, None]: 99 | # Ensure COVERAGE_PROCESS_START is set in the environment for subprocesses 100 | coverage_rc = os.path.abspath(".coveragerc") 101 | os.environ["COVERAGE_PROCESS_START"] = coverage_rc 102 | 103 | proc = multiprocessing.Process(target=run_server, kwargs={"server_port": server_port}, daemon=True) 104 | proc.start() 105 | 106 | # Wait for server to be running 107 | max_attempts = 20 108 | attempt = 0 109 | while attempt < max_attempts: 110 | try: 111 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 112 | s.connect((HOST, server_port)) 113 | break 114 | except ConnectionRefusedError: 115 | time.sleep(0.1) 116 | attempt += 1 117 | else: 118 | raise RuntimeError(f"Server failed to start after {max_attempts} attempts") 119 | 120 | yield 121 | 122 | # Signal the server to stop - added graceful shutdown before kill 123 | try: 124 | proc.terminate() 125 | proc.join(timeout=2) 126 | except (OSError, AttributeError): 127 | pass 128 | 129 | if proc.is_alive(): 130 | proc.kill() 131 | proc.join(timeout=2) 132 | if proc.is_alive(): 133 | raise RuntimeError("server process failed to terminate") 134 | 135 | 136 | @pytest.fixture() 137 | async def http_client(server: None, server_url: str) -> AsyncGenerator[httpx.AsyncClient, None]: 138 | async with httpx.AsyncClient(base_url=server_url) as client: 139 | yield client 140 | 141 | 142 | @pytest.mark.anyio 143 | async def test_raw_sse_connection(http_client: httpx.AsyncClient) -> None: 144 | """Test the SSE connection establishment simply with an HTTP client.""" 145 | async with anyio.create_task_group(): 146 | 147 | async def connection_test() -> None: 148 | async with http_client.stream("GET", "/mcp") as response: 149 | assert response.status_code == 200 150 | assert response.headers["content-type"] == "text/event-stream; charset=utf-8" 151 | 152 | line_number = 0 153 | async for line in response.aiter_lines(): 154 | if line_number == 0: 155 | assert line == "event: endpoint" 156 | elif line_number == 1: 157 | assert line.startswith("data: /mcp/messages/?session_id=") 158 | else: 159 | return 160 | line_number += 1 161 | 162 | # Add timeout to prevent test from hanging if it fails 163 | with anyio.fail_after(3): 164 | await connection_test() 165 | 166 | 167 | @pytest.mark.anyio 168 | async def test_sse_basic_connection(server: None, server_url: str) -> None: 169 | async with sse_client(server_url + "/mcp") as streams: 170 | async with ClientSession(*streams) as session: 171 | # Test initialization 172 | result = await session.initialize() 173 | assert isinstance(result, InitializeResult) 174 | assert result.serverInfo.name == SERVER_NAME 175 | 176 | # Test ping 177 | ping_result = await session.send_ping() 178 | assert isinstance(ping_result, EmptyResult) 179 | 180 | 181 | @pytest.mark.anyio 182 | async def test_sse_tool_call(server: None, server_url: str) -> None: 183 | async with sse_client(server_url + "/mcp") as streams: 184 | async with ClientSession(*streams) as session: 185 | await session.initialize() 186 | 187 | tools_list_result = await session.list_tools() 188 | assert isinstance(tools_list_result, ListToolsResult) 189 | assert len(tools_list_result.tools) > 0 190 | 191 | tool_call_result = await session.call_tool("get_item", {"item_id": 1}) 192 | assert isinstance(tool_call_result, CallToolResult) 193 | assert not tool_call_result.isError 194 | assert tool_call_result.content is not None 195 | assert len(tool_call_result.content) > 0 196 | -------------------------------------------------------------------------------- /tests/test_types_validation.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pydantic import ValidationError 3 | from fastapi import Depends 4 | 5 | from fastapi_mcp.types import ( 6 | OAuthMetadata, 7 | AuthConfig, 8 | ) 9 | 10 | 11 | class TestOAuthMetadata: 12 | def test_non_empty_lists_validation(self): 13 | for field in [ 14 | "scopes_supported", 15 | "response_types_supported", 16 | "grant_types_supported", 17 | "token_endpoint_auth_methods_supported", 18 | "code_challenge_methods_supported", 19 | ]: 20 | with pytest.raises(ValidationError, match=f"{field} cannot be empty"): 21 | OAuthMetadata( 22 | issuer="https://example.com", 23 | authorization_endpoint="https://example.com/auth", 24 | token_endpoint="https://example.com/token", 25 | **{field: []}, 26 | ) 27 | 28 | def test_authorization_endpoint_required_for_authorization_code(self): 29 | with pytest.raises(ValidationError) as exc_info: 30 | OAuthMetadata( 31 | issuer="https://example.com", 32 | token_endpoint="https://example.com/token", 33 | grant_types_supported=["authorization_code", "client_credentials"], 34 | ) 35 | assert "authorization_endpoint is required when authorization_code grant type is supported" in str( 36 | exc_info.value 37 | ) 38 | 39 | OAuthMetadata( 40 | issuer="https://example.com", 41 | token_endpoint="https://example.com/token", 42 | authorization_endpoint="https://example.com/auth", 43 | grant_types_supported=["client_credentials"], 44 | ) 45 | 46 | def test_model_dump_excludes_none(self): 47 | metadata = OAuthMetadata( 48 | issuer="https://example.com", 49 | authorization_endpoint="https://example.com/auth", 50 | token_endpoint="https://example.com/token", 51 | ) 52 | 53 | dumped = metadata.model_dump() 54 | 55 | assert "registration_endpoint" not in dumped 56 | 57 | 58 | class TestAuthConfig: 59 | def test_required_fields_validation(self): 60 | with pytest.raises( 61 | ValidationError, match="at least one of 'issuer', 'custom_oauth_metadata' or 'dependencies' is required" 62 | ): 63 | AuthConfig() 64 | 65 | AuthConfig(issuer="https://example.com") 66 | 67 | AuthConfig( 68 | custom_oauth_metadata={ 69 | "issuer": "https://example.com", 70 | "authorization_endpoint": "https://example.com/auth", 71 | "token_endpoint": "https://example.com/token", 72 | }, 73 | ) 74 | 75 | def dummy_dependency(): 76 | pass 77 | 78 | AuthConfig(dependencies=[Depends(dummy_dependency)]) 79 | 80 | def test_client_id_required_for_setup_proxies(self): 81 | with pytest.raises(ValidationError, match="'client_id' is required when 'setup_proxies' is True"): 82 | AuthConfig( 83 | issuer="https://example.com", 84 | setup_proxies=True, 85 | ) 86 | 87 | AuthConfig( 88 | issuer="https://example.com", 89 | setup_proxies=True, 90 | client_id="test-client-id", 91 | client_secret="test-client-secret", 92 | ) 93 | 94 | def test_client_secret_required_for_fake_registration(self): 95 | with pytest.raises( 96 | ValidationError, match="'client_secret' is required when 'setup_fake_dynamic_registration' is True" 97 | ): 98 | AuthConfig( 99 | issuer="https://example.com", 100 | setup_proxies=True, 101 | client_id="test-client-id", 102 | setup_fake_dynamic_registration=True, 103 | ) 104 | 105 | AuthConfig( 106 | issuer="https://example.com", 107 | setup_proxies=True, 108 | client_id="test-client-id", 109 | client_secret="test-client-secret", 110 | setup_fake_dynamic_registration=True, 111 | ) 112 | --------------------------------------------------------------------------------