diff --git a/.github/workflows/martian-issue-triage.yml b/.github/workflows/martian-issue-triage.yml new file mode 100644 index 000000000..6c31237b6 --- /dev/null +++ b/.github/workflows/martian-issue-triage.yml @@ -0,0 +1,172 @@ +name: Martian Issue Triage + +on: + issues: + types: [opened, labeled] + +concurrency: + group: triage-martian-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + martian-issue-triage: + if: | + (github.event.action == 'opened' && github.actor == 'strawgate') || + (github.event.action == 'labeled' && github.event.label.name == 'triage-martian') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + issues: write + pull-requests: read + id-token: write + + steps: + - name: Checkout base repository + uses: actions/checkout@v5 + with: + repository: ${{ github.repository }} + ref: ${{ github.event.repository.default_branch }} + + # Install UV package manager + - name: Install UV + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Generate Marvin App token + id: marvin-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.MARVIN_APP_ID }} + private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} + + - name: Set triage prompt + id: triage-prompt + run: | + cat >> $GITHUB_OUTPUT << 'EOF' + PROMPT< and tags. You shouldn't put everything in collapsible sections, especially if the response is short. Use your discretion to determine when to use collapsible sections to avoid overwhelming the reader with too much detail -- think of them like an appendix that can be expanded if the reader is interested. + + # Example output for "Recommendation" part of the response + PR #654 already implements the requested feature but is incomplete. The Pull Request is not in a mergeable state yet, the remaining work should be completed: 1) update the Calculator.divide method to utilize the new DivisionByZeroError or the safe_divide function, and 2) update the tests to ensure that the Calculator.divide method raises the new DivisionByZeroError when the divisor is 0. + +
+ Findings + ...details from the code analysis that are relevant to the issue and the recommendation... +
+ +
+ Detailed Action Plan + ...a detailed plan that a junior developer could follow to implement the recommendation... +
+ + # Example Output for "Related Items" part of the response + +
+ Related Issues and Pull Requests + + | Repository | Issue or PR | Relevance | + | --- | --- | --- | + | jlowin/fastmcp | [Add matrix operations support](https://github.com/jlowin/fastmcp/pull/680) | This pull request directly addresses the feature request for adding matrix operations to the calculator. | + | jlowin/fastmcp | [Add matrix operations support](https://github.com/jlowin/fastmcp/issues/681) | This issue directly addresses the feature request for adding matrix operations to the calculator. | +
+ +
+ Related Files + + | Repository | File | Relevance | Sections | + | --- | --- | --- | --- | + | modelcontextprotocol/python-sdk | [test_calculator.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/test_calculator.py) | This file contains the test cases for the Calculator class, including a test that specifically asserts a ValueError is raised for division by zero, confirming the current intended behavior. | [25-27](https://github.com/modelcontextprotocol/python-sdk/blob/main/test_calculator.py#L25-L27) | + | modelcontextprotocol/python-sdk | [calculator.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/calculator.py) | This file contains the implementation of the Calculator class, specifically the `divide` method which raises the ValueError when dividing by zero, matching the bug report. | [29-32](https://github.com/modelcontextprotocol/python-sdk/blob/main/calculator.py#L29-L32) | +
+ +
+ Related Webpages + | Name | URL | Relevance | + | --- | --- | --- | + | Handling Division by Zero Best Practices | https://my-blog-about-division-by-zero.com/handling+division+by+zero+in+calculator | This webpage provides general best practices for handling division by zero in calculator applications and in Python, which is directly relevant to the issue and potential solutions. | +
+ + PROMPT_END + EOF + + - name: Setup GitHub MCP Server + run: | + mkdir -p /tmp/mcp-config + cat > /tmp/mcp-config/mcp-servers.json << 'EOF' + { + "mcpServers": { + "repository-summary": { + "type": "http", + "url": "https://agents-md-generator.fastmcp.app/mcp" + }, + "code-search": { + "type": "http", + "url": "https://public-code-search.fastmcp.app/mcp" + }, + "github-research": { + "type": "stdio", + "command": "uvx", + "args": [ + "github-research-mcp" + ], + "env": { + "DISABLE_SUMMARIES": "true", + "GITHUB_PERSONAL_ACCESS_TOKEN": "${{ steps.marvin-token.outputs.token }}" + } + } + } + } + EOF + + - name: Run Martian for Issue Triage + uses: anthropics/claude-code-action@v1 + with: + github_token: ${{ steps.marvin-token.outputs.token }} + bot_name: "Marvin Context Protocol" + prompt: ${{ steps.triage-prompt.outputs.PROMPT }} + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }} + track_progress: true + claude_args: | + --model claude-sonnet-4-5-20250929 + --allowedTools mcp__repository-summary,mcp__code-search__search_code,mcp__github-research__get_repository,mcp__github-research__get_issue,mcp__github-research__get_pull_request,mcp__github-research__search_issues,mcp__github-research__search_pull_requests,mcp__github-research__get_files + --mcp-config /tmp/mcp-config/mcp-servers.json + settings: | + { + "GH_TOKEN": "${{ steps.marvin-token.outputs.token }}" + } diff --git a/.github/workflows/marvin-dedupe-issues.yml b/.github/workflows/marvin-dedupe-issues.yml index 7e3b5a27b..aaa8987c9 100644 --- a/.github/workflows/marvin-dedupe-issues.yml +++ b/.github/workflows/marvin-dedupe-issues.yml @@ -1,5 +1,5 @@ name: Marvin Issue Dedupe -description: Automatically dedupe GitHub issues using Marvin +# description: Automatically dedupe GitHub issues using Marvin on: issues: types: [opened] @@ -17,6 +17,7 @@ jobs: permissions: contents: read issues: write + id-token: write steps: - name: Checkout repository @@ -29,21 +30,22 @@ jobs: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} - - name: Create dedupe prompt + - name: Set dedupe prompt + id: dedupe-prompt run: | - mkdir -p /tmp/claude-prompts - cat > /tmp/claude-prompts/dedupe-prompt.txt << 'EOF' + cat >> $GITHUB_OUTPUT << 'EOF' + PROMPT< /tmp/claude-prompts/triage-prompt.txt << 'EOF' + cat >> $GITHUB_OUTPUT << 'EOF' + PROMPT< /tmp/mcp-config/mcp-servers.json << 'EOF' - { - "mcpServers": { - "github": { - "command": "docker", - "args": [ - "run", - "-i", - "--rm", - "-e", - "GITHUB_PERSONAL_ACCESS_TOKEN", - "ghcr.io/github/github-mcp-server:sha-7aced2b" - ], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "${{ steps.marvin-token.outputs.token }}" - } + - name: Run Marvin for Issue Triage + uses: anthropics/claude-code-action@v1 + with: + github_token: ${{ steps.marvin-token.outputs.token }} + bot_name: "Marvin Context Protocol" + prompt: ${{ steps.triage-prompt.outputs.PROMPT }} + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }} + allowed_non_write_users: "*" # Required for issue triage workflow, if users without repo write access create issues + claude_args: | + --allowedTools Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__get_pull_request_files + settings: | + { + "model": "claude-sonnet-4-5-20250929", + "env": { + "GH_TOKEN": "${{ steps.marvin-token.outputs.token }}" } } - } - EOF - - - name: Run Marvin for Issue Triage - uses: anthropics/claude-code-base-action@beta - with: - prompt_file: /tmp/claude-prompts/triage-prompt.txt - allowed_tools: "Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__get_pull_request_files" - timeout_minutes: "5" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }} - mcp_config: /tmp/mcp-config/mcp-servers.json - claude_env: | - GH_TOKEN: ${{ steps.marvin-token.outputs.token }} diff --git a/.github/workflows/marvin.yml b/.github/workflows/marvin.yml index 8d26185a9..165edb7ad 100644 --- a/.github/workflows/marvin.yml +++ b/.github/workflows/marvin.yml @@ -59,13 +59,20 @@ jobs: # Marvin Assistant - name: Run Marvin - uses: anthropics/claude-code-action@beta + uses: anthropics/claude-code-action@v1 with: github_token: ${{ steps.marvin-token.outputs.token }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - mode: tag trigger_phrase: "/marvin" allowed_bots: "*" - allowed_tools: "WebSearch,WebFetch,Bash(uv:*),Bash(pre-commit:*),Bash(pytest:*),Bash(ruff:*),Bash(ty:*),Bash(git:*),Bash(gh:*),mcp__github__add_issue_comment,mcp__github__create_issue,mcp__github__get_issue,mcp__github__list_issues,mcp__github__search_issues,mcp__github__update_issue,mcp__github__update_issue_comment,mcp__github__create_pull_request,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_files,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__list_pull_requests,mcp__github__update_pull_request,mcp__github__update_pull_request_branch,mcp__github__update_pull_request_comment,mcp__github__merge_pull_request" + claude_args: | + --allowedTools WebSearch,WebFetch,Bash(uv:*),Bash(pre-commit:*),Bash(pytest:*),Bash(ruff:*),Bash(ty:*),Bash(git:*),Bash(gh:*),mcp__github__add_issue_comment,mcp__github__create_issue,mcp__github__get_issue,mcp__github__list_issues,mcp__github__search_issues,mcp__github__update_issue,mcp__github__update_issue_comment,mcp__github__create_pull_request,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_files,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__list_pull_requests,mcp__github__update_pull_request,mcp__github__update_pull_request_branch,mcp__github__update_pull_request_comment,mcp__github__merge_pull_request additional_permissions: | actions: read + settings: | + { + "model": "claude-sonnet-4-5-20250929", + "env": { + "GH_TOKEN": "${{ steps.marvin-token.outputs.token }}" + } + } diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index cf5477d0c..7b9672978 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -48,10 +48,10 @@ jobs: run: uv sync --frozen - name: Run tests (excluding integration and client_process) - run: uv run pytest -v tests -m "not integration and not client_process" + run: uv run pytest --inline-snapshot=disable tests -m "not integration and not client_process" --numprocesses auto --maxprocesses 4 --dist worksteal - name: Run client process tests separately - run: uv run pytest -v tests -m "client_process" -x + run: uv run pytest --inline-snapshot=disable tests -m "client_process" -x run_integration_tests: name: "Run integration tests" @@ -74,7 +74,7 @@ jobs: - name: Run integration tests # use longer per-test timeout than the default 3s - run: uv run pytest -v tests -m "integration" --timeout=15 + run: uv run pytest tests -m "integration" --timeout=15 --numprocesses auto --maxprocesses 2 --dist worksteal env: FASTMCP_GITHUB_TOKEN: ${{ secrets.FASTMCP_GITHUB_TOKEN }} FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID: ${{ secrets.FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 74e3541da..548696b55 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,3 +39,10 @@ repos: - id: no-commit-to-branch name: prevent commits to main args: [--branch, main] + + - repo: https://github.com/codespell-project/codespell + rev: v2.4.1 + hooks: + - id: codespell # See pyproject.toml for args + additional_dependencies: + - tomli diff --git a/AGENTS.md b/AGENTS.md index 1a943175e..cdb5bee7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,24 +20,24 @@ uv run pytest # Run full test suite ## Repository Structure -| Path | Purpose | -| ---------------- | ------------------------------------------------------ | -| `src/fastmcp/` | Library source code (Python ≥ 3.10) | -| `├─server/` | Server implementation, `FastMCP`, auth, networking | -| `│ ├─auth/` | Authentication providers (Bearer, JWT, WorkOS) | -| `│ └─middleware/` | Error handling, logging, rate limiting | -| `├─client/` | High-level client SDK + transports | -| `│ └─auth/` | Client authentication (Bearer, OAuth) | -| `├─tools/` | Tool implementations + `ToolManager` | -| `├─resources/` | Resources, templates + `ResourceManager` | -| `├─prompts/` | Prompt templates + `PromptManager` | -| `├─cli/` | FastMCP CLI commands (`run`, `dev`, `install`) | -| `├─contrib/` | Community contributions (bulk caller, mixins) | -| `├─experimental/` | Experimental features (new OpenAPI parser) | -| `└─utilities/` | Shared utilities (logging, JSON schema, HTTP) | -| `tests/` | Comprehensive pytest suite with markers | -| `docs/` | Mintlify documentation (published to gofastmcp.com) | -| `examples/` | Runnable demo servers (echo, smart_home, atproto) | +| Path | Purpose | +| ------------------ | --------------------------------------------------- | +| `src/fastmcp/` | Library source code (Python ≥ 3.10) | +| `├─server/` | Server implementation, `FastMCP`, auth, networking | +| `│ ├─auth/` | Authentication providers (Bearer, JWT, WorkOS) | +| `│ └─middleware/` | Error handling, logging, rate limiting | +| `├─client/` | High-level client SDK + transports | +| `│ └─auth/` | Client authentication (Bearer, OAuth) | +| `├─tools/` | Tool implementations + `ToolManager` | +| `├─resources/` | Resources, templates + `ResourceManager` | +| `├─prompts/` | Prompt templates + `PromptManager` | +| `├─cli/` | FastMCP CLI commands (`run`, `dev`, `install`) | +| `├─contrib/` | Community contributions (bulk caller, mixins) | +| `├─experimental/` | Experimental features (new OpenAPI parser) | +| `└─utilities/` | Shared utilities (logging, JSON schema, HTTP) | +| `tests/` | Comprehensive pytest suite with markers | +| `docs/` | Mintlify documentation (published to gofastmcp.com) | +| `examples/` | Runnable demo servers (echo, smart_home, atproto) | ## Core MCP Objects @@ -64,13 +64,13 @@ When modifying MCP functionality, changes typically need to be applied across al - Every test: atomic, self-contained, single functionality - Use parameterization for multiple examples of same functionality - Use separate tests for different functionality pieces -- Put imports at the top of the file, not in the test body +- **ALWAYS** Put imports at the top of the file, not in the test body - **NEVER** add `@pytest.mark.asyncio` to tests - `asyncio_mode = "auto"` is set globally - **ALWAYS** run pytest after significant changes ### Inline Snapshots -FastMCP uses `inline-snapshot` for testing complex data structures. On first run with empty `snapshot()`, pytest will auto-populate the expected value. To update snapshots after intentional changes, run `pytest --inline-snapshot=fix`. This is particularly useful for testing JSON schemas and API responses. +FastMCP uses `inline-snapshot` for testing complex data structures. On first run with empty `snapshot()`, pytest will auto-populate the expected value when running `pytest --inline-snapshot=create`. To update snapshots after intentional changes, run `pytest --inline-snapshot=fix`. This is particularly useful for testing JSON schemas and API responses. ### Always Use In-Memory Transport diff --git a/README.md b/README.md index 33260f67a..e96b243e4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,13 @@
+ + + + + FastMCP Logo + + # FastMCP v2 🚀 The fast, Pythonic way to build MCP servers and clients. @@ -17,19 +24,19 @@ > [!Note] > -> #### Beyond the Protocol +> #### FastMCP 2.0: The Standard Framework > -> FastMCP is the standard framework for working with the Model Context Protocol. FastMCP 1.0 was incorporated into the [official MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) in 2024. +> FastMCP pioneered Python MCP development, and FastMCP 1.0 was incorporated into the [official MCP SDK](https://github.com/modelcontextprotocol/python-sdk) in 2024. > -> This is FastMCP 2.0, the **actively maintained version** that provides a complete toolkit for working with the MCP ecosystem. +> **This is FastMCP 2.0** — the actively maintained, production-ready framework that extends far beyond basic protocol implementation. While the SDK provides core functionality, FastMCP 2.0 delivers everything needed for production: advanced MCP patterns (server composition, proxying, OpenAPI/FastAPI generation, tool transformation), enterprise auth (Google, GitHub, WorkOS, Azure, Auth0, and more), deployment tools, testing utilities, and comprehensive client libraries. > -> FastMCP 2.0 has a comprehensive set of features that go far beyond the core MCP specification, all in service of providing **the simplest path to production**. These include deployment, auth, clients, server proxying and composition, generating servers from REST APIs, dynamic tool rewriting, built-in testing tools, integrations, and more. -> -> Ready to upgrade or get started? Follow the [installation instructions](https://gofastmcp.com/getting-started/installation), which include steps for upgrading from the official MCP SDK. +> **For production MCP applications, install FastMCP:** `pip install fastmcp` --- -The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) is a new, standardized way to provide context and tools to your LLMs, and FastMCP makes building MCP servers and clients simple and intuitive. Create tools, expose resources, define prompts, and connect components with clean, Pythonic code. +**FastMCP is the standard framework for building MCP applications**, providing the fastest path from idea to production. + +The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) is a standardized way to provide context and tools to LLMs. FastMCP makes building production-ready MCP servers simple, with enterprise auth, deployment tools, and a complete ecosystem built in. ```python # server.py @@ -68,28 +75,33 @@ There are two ways to access the LLM-friendly documentation: ## Table of Contents -- [What is MCP?](#what-is-mcp) -- [Why FastMCP?](#why-fastmcp) -- [Installation](#installation) -- [Core Concepts](#core-concepts) - - [The `FastMCP` Server](#the-fastmcp-server) - - [Tools](#tools) - - [Resources \& Templates](#resources--templates) - - [Prompts](#prompts) - - [Context](#context) - - [MCP Clients](#mcp-clients) -- [Advanced Features](#advanced-features) - - [Proxy Servers](#proxy-servers) - - [Composing MCP Servers](#composing-mcp-servers) - - [OpenAPI \& FastAPI Generation](#openapi--fastapi-generation) - - [Authentication \& Security](#authentication--security) -- [Running Your Server](#running-your-server) -- [Contributing](#contributing) - - [Prerequisites](#prerequisites) - - [Setup](#setup) - - [Unit Tests](#unit-tests) - - [Static Checks](#static-checks) - - [Pull Requests](#pull-requests) +- [FastMCP v2 🚀](#fastmcp-v2-) + - [📚 Documentation](#-documentation) + - [What is MCP?](#what-is-mcp) + - [Why FastMCP?](#why-fastmcp) + - [Installation](#installation) + - [Core Concepts](#core-concepts) + - [The `FastMCP` Server](#the-fastmcp-server) + - [Tools](#tools) + - [Resources \& Templates](#resources--templates) + - [Prompts](#prompts) + - [Context](#context) + - [MCP Clients](#mcp-clients) + - [Authentication](#authentication) + - [Enterprise Authentication, Zero Configuration](#enterprise-authentication-zero-configuration) + - [Deployment](#deployment) + - [From Development to Production](#from-development-to-production) + - [Advanced Features](#advanced-features) + - [Proxy Servers](#proxy-servers) + - [Composing MCP Servers](#composing-mcp-servers) + - [OpenAPI \& FastAPI Generation](#openapi--fastapi-generation) + - [Running Your Server](#running-your-server) + - [Contributing](#contributing) + - [Prerequisites](#prerequisites) + - [Setup](#setup) + - [Unit Tests](#unit-tests) + - [Static Checks](#static-checks) + - [Pull Requests](#pull-requests) --- @@ -106,11 +118,7 @@ FastMCP provides a high-level, Pythonic interface for building, managing, and in ## Why FastMCP? -The MCP protocol is powerful but implementing it involves a lot of boilerplate - server setup, protocol handlers, content types, error management. FastMCP handles all the complex protocol details and server management, so you can focus on building great tools. It's designed to be high-level and Pythonic; in most cases, decorating a function is all you need. - -FastMCP 2.0 has evolved into a comprehensive platform that goes far beyond basic protocol implementation. While 1.0 provided server-building capabilities (and is now part of the official MCP SDK), 2.0 offers a complete ecosystem including client libraries, authentication systems, deployment tools, integrations with major AI platforms, testing frameworks, and production-ready infrastructure patterns. - -FastMCP aims to be: +FastMCP handles all the complex protocol details so you can focus on building. In most cases, decorating a Python function is all you need — FastMCP handles the rest. 🚀 **Fast:** High-level interface means less code and faster development @@ -118,7 +126,9 @@ FastMCP aims to be: 🐍 **Pythonic:** Feels natural to Python developers -🔍 **Complete:** A comprehensive platform for all MCP use cases, from dev to prod +🔍 **Complete:** Everything for production — enterprise auth (Google, GitHub, Azure, Auth0, WorkOS), deployment tools, testing frameworks, client libraries, and more + +FastMCP provides the shortest path from idea to production. Deploy locally, to the cloud with [FastMCP Cloud](https://fastmcp.cloud), or to your own infrastructure. ## Installation @@ -288,9 +298,82 @@ async def main(): Learn more in the [**Client Documentation**](https://gofastmcp.com/clients/client) and [**Transports Documentation**](https://gofastmcp.com/clients/transports). +## Authentication + +### Enterprise Authentication, Zero Configuration + +FastMCP provides comprehensive authentication support that sets it apart from basic MCP implementations. Secure your servers and authenticate your clients with the same enterprise-grade providers used by major corporations. + +**Built-in OAuth Providers:** + +- **Google** +- **GitHub** +- **Microsoft Azure** +- **Auth0** +- **WorkOS** +- **Descope** +- **JWT/Custom** +- **API Keys** + +Protecting a server takes just two lines: + +```python +from fastmcp.server.auth import GoogleProvider + +auth = GoogleProvider(client_id="...", client_secret="...", base_url="https://myserver.com") +mcp = FastMCP("Protected Server", auth=auth) +``` + +Connecting to protected servers is even simpler: + +```python +async with Client("https://protected-server.com/mcp", auth="oauth") as client: + # Automatic browser-based OAuth flow + result = await client.call_tool("protected_tool") +``` + +**Why FastMCP Auth Matters:** + +- **Production-Ready:** Persistent storage, token refresh, comprehensive error handling +- **Zero-Config OAuth:** Just pass `auth="oauth"` for automatic setup +- **Enterprise Integration:** WorkOS SSO, Azure Active Directory, Auth0 tenants +- **Developer Experience:** Automatic browser launch, local callback server, environment variable support +- **Advanced Architecture:** Full OIDC support, Dynamic Client Registration (DCR), and unique OAuth proxy pattern that enables DCR with any provider + +*Authentication this comprehensive is unique to FastMCP 2.0.* + +Learn more in the **Authentication Documentation** for [servers](https://gofastmcp.com/servers/auth) and [clients](https://gofastmcp.com/clients/auth). + +## Deployment + +### From Development to Production + +FastMCP supports every deployment scenario from local development to global scale: + +**Development:** Run locally with a single command + +```bash +fastmcp run server.py +``` + +**Production:** Deploy to [**FastMCP Cloud**](https://fastmcp.cloud) — Remote MCP that just works + +- Instant HTTPS endpoints +- Built-in authentication +- Zero configuration +- Free for personal servers + +**Self-Hosted:** Use HTTP or SSE transports for your own infrastructure + +```python +mcp.run(transport="http", host="0.0.0.0", port=8000) +``` + +Learn more in the [**Deployment Documentation**](https://gofastmcp.com/deployment). + ## Advanced Features -FastMCP introduces powerful ways to structure and deploy your MCP applications. +FastMCP introduces powerful ways to structure and compose your MCP applications. ### Proxy Servers @@ -310,16 +393,6 @@ Automatically generate FastMCP servers from existing OpenAPI specifications (`Fa Learn more: [**OpenAPI Integration**](https://gofastmcp.com/integrations/openapi) | [**FastAPI Integration**](https://gofastmcp.com/integrations/fastapi). -### Authentication & Security - -FastMCP provides built-in authentication support to secure both your MCP servers and clients in production environments. Protect your server endpoints from unauthorized access and authenticate your clients against secured MCP servers using industry-standard protocols. - -- **Server Protection**: Secure your FastMCP server endpoints with configurable authentication providers -- **Client Authentication**: Connect to authenticated MCP servers with automatic credential management -- **Production Ready**: Support for common authentication patterns used in enterprise environments - -Learn more in the **Authentication Documentation** for [servers](https://gofastmcp.com/servers/auth) and [clients](https://gofastmcp.com/clients/auth). - ## Running Your Server The main way to run a FastMCP server is by calling the `run()` method on your server instance: diff --git a/docs/assets/brand/f-watercolor-waves-dark.png b/docs/assets/brand/f-watercolor-waves-dark.png new file mode 100644 index 000000000..a50956cc8 Binary files /dev/null and b/docs/assets/brand/f-watercolor-waves-dark.png differ diff --git a/docs/assets/brand/f-watercolor-waves.png b/docs/assets/brand/f-watercolor-waves.png new file mode 100644 index 000000000..93ccf6efb Binary files /dev/null and b/docs/assets/brand/f-watercolor-waves.png differ diff --git a/docs/assets/brand/wordmark-padded.png b/docs/assets/brand/wordmark-padded.png new file mode 100644 index 000000000..5dc7932e4 Binary files /dev/null and b/docs/assets/brand/wordmark-padded.png differ diff --git a/docs/assets/brand/wordmark-watercolor-rainbow-dark.png b/docs/assets/brand/wordmark-watercolor-rainbow-dark.png new file mode 100644 index 000000000..5c2d673d4 Binary files /dev/null and b/docs/assets/brand/wordmark-watercolor-rainbow-dark.png differ diff --git a/docs/assets/brand/wordmark-watercolor-rainbow.png b/docs/assets/brand/wordmark-watercolor-rainbow.png new file mode 100644 index 000000000..f16370447 Binary files /dev/null and b/docs/assets/brand/wordmark-watercolor-rainbow.png differ diff --git a/docs/assets/brand/wordmark-watercolor-waves-dark.png b/docs/assets/brand/wordmark-watercolor-waves-dark.png new file mode 100644 index 000000000..02d936762 Binary files /dev/null and b/docs/assets/brand/wordmark-watercolor-waves-dark.png differ diff --git a/docs/assets/brand/wordmark-watercolor-waves.png b/docs/assets/brand/wordmark-watercolor-waves.png new file mode 100644 index 000000000..84950f9ec Binary files /dev/null and b/docs/assets/brand/wordmark-watercolor-waves.png differ diff --git a/docs/assets/brand/wordmark-white-padded.png b/docs/assets/brand/wordmark-white-padded.png new file mode 100644 index 000000000..71eb80777 Binary files /dev/null and b/docs/assets/brand/wordmark-white-padded.png differ diff --git a/docs/assets/brand/wordmark-white.png b/docs/assets/brand/wordmark-white.png index 265fc9ce9..81377c5be 100644 Binary files a/docs/assets/brand/wordmark-white.png and b/docs/assets/brand/wordmark-white.png differ diff --git a/docs/assets/brand/wordmark.png b/docs/assets/brand/wordmark.png index 40e9c4bb6..41b5717b2 100644 Binary files a/docs/assets/brand/wordmark.png and b/docs/assets/brand/wordmark.png differ diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 00f60cd2c..fabf26c6e 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -4,6 +4,124 @@ icon: "list-check" rss: true --- + + +**[v2.12.4: OIDC What You Did There](https://github.com/jlowin/fastmcp/releases/tag/v2.12.4)** + +FastMCP 2.12.4 adds comprehensive OIDC support and expands authentication options with AWS Cognito and Descope providers. The release also includes improvements to logging middleware, URL handling for nested resources, persistent OAuth client registration storage, and various fixes to the experimental OpenAPI parser. + +## What's Changed +### New Features 🎉 +* feat: Add support for OIDC configuration by [@ruhulio](https://github.com/ruhulio) in [#1817](https://github.com/jlowin/fastmcp/pull/1817) +### Enhancements 🔧 +* feat: Move the Starlette context middleware to the front by [@akkuman](https://github.com/akkuman) in [#1812](https://github.com/jlowin/fastmcp/pull/1812) +* Refactor Logging and Structured Logging Middleware by [@strawgate](https://github.com/strawgate) in [#1805](https://github.com/jlowin/fastmcp/pull/1805) +* Update pull_request_template.md by [@jlowin](https://github.com/jlowin) in [#1824](https://github.com/jlowin/fastmcp/pull/1824) +* chore: Set redirect_path default in function by [@ruhulio](https://github.com/ruhulio) in [#1833](https://github.com/jlowin/fastmcp/pull/1833) +* feat: Set instructions in code by [@attiks](https://github.com/attiks) in [#1838](https://github.com/jlowin/fastmcp/pull/1838) +* Automatically Create inline Snapshots by [@strawgate](https://github.com/strawgate) in [#1779](https://github.com/jlowin/fastmcp/pull/1779) +* chore: Cleanup Auth0 redirect_path initialization by [@ruhulio](https://github.com/ruhulio) in [#1842](https://github.com/jlowin/fastmcp/pull/1842) +* feat: Add support for Descope Authentication by [@anvibanga](https://github.com/anvibanga) in [#1853](https://github.com/jlowin/fastmcp/pull/1853) +* Update descope version badges by [@jlowin](https://github.com/jlowin) in [#1870](https://github.com/jlowin/fastmcp/pull/1870) +* Update welcome images by [@jlowin](https://github.com/jlowin) in [#1884](https://github.com/jlowin/fastmcp/pull/1884) +* Fix rounded edges of image by [@jlowin](https://github.com/jlowin) in [#1886](https://github.com/jlowin/fastmcp/pull/1886) +* optimize test suite by [@zzstoatzz](https://github.com/zzstoatzz) in [#1893](https://github.com/jlowin/fastmcp/pull/1893) +* Enhancement: client completions support context_arguments by [@isijoe](https://github.com/isijoe) in [#1906](https://github.com/jlowin/fastmcp/pull/1906) +* Update Descope icon by [@anvibanga](https://github.com/anvibanga) in [#1912](https://github.com/jlowin/fastmcp/pull/1912) +* Add AWS Cognito OAuth Provider for Enterprise Authentication by [@stephaneberle9](https://github.com/stephaneberle9) in [#1873](https://github.com/jlowin/fastmcp/pull/1873) +* Fix typos discovered by codespell by [@cclauss](https://github.com/cclauss) in [#1922](https://github.com/jlowin/fastmcp/pull/1922) +* Use lowercase namespace for fastmcp logger by [@jlowin](https://github.com/jlowin) in [#1791](https://github.com/jlowin/fastmcp/pull/1791) +### Fixes 🐞 +* Update quickstart.mdx by [@radi-dev](https://github.com/radi-dev) in [#1821](https://github.com/jlowin/fastmcp/pull/1821) +* Remove extraneous union import by [@jlowin](https://github.com/jlowin) in [#1823](https://github.com/jlowin/fastmcp/pull/1823) +* Delay import of Provider classes until FastMCP Server Creation by [@strawgate](https://github.com/strawgate) in [#1820](https://github.com/jlowin/fastmcp/pull/1820) +* fix: correct documentation link in deprecation warning by [@strawgate](https://github.com/strawgate) in [#1828](https://github.com/jlowin/fastmcp/pull/1828) +* fix: Increase default 3s timeout on Pytest by [@dacamposol](https://github.com/dacamposol) in [#1866](https://github.com/jlowin/fastmcp/pull/1866) +* fix: Improve URL handling in OIDCConfiguration by [@ruhulio](https://github.com/ruhulio) in [#1850](https://github.com/jlowin/fastmcp/pull/1850) +* fix: correct typing for on_read_resource middleware method by [@strawgate](https://github.com/strawgate) in [#1858](https://github.com/jlowin/fastmcp/pull/1858) +* feat(experimental/openapi): replace $ref in additionalProperties; add tests by [@jlowin](https://github.com/jlowin) in [#1735](https://github.com/jlowin/fastmcp/pull/1735) +* Honor client supplied scopes during registration by [@dmikusa](https://github.com/dmikusa) in [#1860](https://github.com/jlowin/fastmcp/pull/1860) +* Fix: FastAPI list parameter parsing in experimental OpenAPI parser by [@jlowin](https://github.com/jlowin) in [#1834](https://github.com/jlowin/fastmcp/pull/1834) +* Add log level support for stdio and HTTP transports by [@jlowin](https://github.com/jlowin) in [#1840](https://github.com/jlowin/fastmcp/pull/1840) +* Fix OAuth pre-flight check to accept HTTP 200 responses by [@jlowin](https://github.com/jlowin) in [#1874](https://github.com/jlowin/fastmcp/pull/1874) +* Fix: Preserve OpenAPI parameter descriptions in experimental parser by [@shlomo666](https://github.com/shlomo666) in [#1877](https://github.com/jlowin/fastmcp/pull/1877) +* Add persistent storage for OAuth client registrations by [@jlowin](https://github.com/jlowin) in [#1879](https://github.com/jlowin/fastmcp/pull/1879) +* docs: update release dates based on github releases by [@lodu](https://github.com/lodu) in [#1890](https://github.com/jlowin/fastmcp/pull/1890) +* Small updates to Sampling types by [@strawgate](https://github.com/strawgate) in [#1882](https://github.com/jlowin/fastmcp/pull/1882) +* remove lockfile smart_home example by [@zzstoatzz](https://github.com/zzstoatzz) in [#1892](https://github.com/jlowin/fastmcp/pull/1892) +* Fix: Remove JSON schema title metadata while preserving parameters named 'title' by [@jlowin](https://github.com/jlowin) in [#1872](https://github.com/jlowin/fastmcp/pull/1872) +* Fix: get_resource_url nested URL handling by [@raphael-linx](https://github.com/raphael-linx) in [#1914](https://github.com/jlowin/fastmcp/pull/1914) +* Clean up code for creating the resource url by [@jlowin](https://github.com/jlowin) in [#1916](https://github.com/jlowin/fastmcp/pull/1916) +* Fix route count logging in OpenAPI server by [@zzstoatzz](https://github.com/zzstoatzz) in [#1928](https://github.com/jlowin/fastmcp/pull/1928) +### Docs 📚 +* docs: make Gemini CLI integration discoverable by [@jackwotherspoon](https://github.com/jackwotherspoon) in [#1827](https://github.com/jlowin/fastmcp/pull/1827) +* docs: update NEW tags for AI assistant integrations by [@jackwotherspoon](https://github.com/jackwotherspoon) in [#1829](https://github.com/jlowin/fastmcp/pull/1829) +* Update wordmark by [@jlowin](https://github.com/jlowin) in [#1832](https://github.com/jlowin/fastmcp/pull/1832) +* docs: improve OAuth and OIDC Proxy documentation by [@jlowin](https://github.com/jlowin) in [#1880](https://github.com/jlowin/fastmcp/pull/1880) +* Update readme + welcome docs by [@jlowin](https://github.com/jlowin) in [#1883](https://github.com/jlowin/fastmcp/pull/1883) +* Update dark mode image in README by [@jlowin](https://github.com/jlowin) in [#1885](https://github.com/jlowin/fastmcp/pull/1885) + +## New Contributors +* [@radi-dev](https://github.com/radi-dev) made their first contribution in [#1821](https://github.com/jlowin/fastmcp/pull/1821) +* [@akkuman](https://github.com/akkuman) made their first contribution in [#1812](https://github.com/jlowin/fastmcp/pull/1812) +* [@ruhulio](https://github.com/ruhulio) made their first contribution in [#1817](https://github.com/jlowin/fastmcp/pull/1817) +* [@attiks](https://github.com/attiks) made their first contribution in [#1838](https://github.com/jlowin/fastmcp/pull/1838) +* [@anvibanga](https://github.com/anvibanga) made their first contribution in [#1853](https://github.com/jlowin/fastmcp/pull/1853) +* [@shlomo666](https://github.com/shlomo666) made their first contribution in [#1877](https://github.com/jlowin/fastmcp/pull/1877) +* [@lodu](https://github.com/lodu) made their first contribution in [#1890](https://github.com/jlowin/fastmcp/pull/1890) +* [@isijoe](https://github.com/isijoe) made their first contribution in [#1906](https://github.com/jlowin/fastmcp/pull/1906) +* [@raphael-linx](https://github.com/raphael-linx) made their first contribution in [#1914](https://github.com/jlowin/fastmcp/pull/1914) +* [@stephaneberle9](https://github.com/stephaneberle9) made their first contribution in [#1873](https://github.com/jlowin/fastmcp/pull/1873) +* [@cclauss](https://github.com/cclauss) made their first contribution in [#1922](https://github.com/jlowin/fastmcp/pull/1922) + +**Full Changelog**: [v2.12.3...v2.12.4](https://github.com/jlowin/fastmcp/compare/v2.12.3...v2.12.4) + + + + + +**[v2.12.3: Double Time](https://github.com/jlowin/fastmcp/releases/tag/v2.12.3)** + +FastMCP 2.12.3 focuses on performance and developer experience improvements based on community feedback. This release includes optimized auth provider imports that reduce server startup time, enhanced OIDC authentication flows with proper token management, and several reliability fixes for OAuth proxy configurations. The addition of automatic inline snapshot creation significantly improves the testing experience for contributors. + +## What's Changed +### New Features 🎉 +* feat: Support setting MCP log level via transport configuration by [@jlowin](https://github.com/jlowin) in [#1756](https://github.com/jlowin/fastmcp/pull/1756) +### Enhancements 🔧 +* Add client-side auth support for mcp install cursor command by [@jlowin](https://github.com/jlowin) in [#1747](https://github.com/jlowin/fastmcp/pull/1747) +* Automatically Create inline Snapshots by [@strawgate](https://github.com/strawgate) in [#1779](https://github.com/jlowin/fastmcp/pull/1779) +* Use lowercase namespace for fastmcp logger by [@jlowin](https://github.com/jlowin) in [#1791](https://github.com/jlowin/fastmcp/pull/1791) +### Fixes 🐞 +* fix: correct merge mistake during auth0 refactor by [@strawgate](https://github.com/strawgate) in [#1742](https://github.com/jlowin/fastmcp/pull/1742) +* Remove extraneous union import by [@jlowin](https://github.com/jlowin) in [#1823](https://github.com/jlowin/fastmcp/pull/1823) +* Delay import of Provider classes until FastMCP Server Creation by [@strawgate](https://github.com/strawgate) in [#1820](https://github.com/jlowin/fastmcp/pull/1820) +* fix: refactor OIDC configuration provider for proper token management by [@strawgate](https://github.com/strawgate) in [#1751](https://github.com/jlowin/fastmcp/pull/1751) +* Fix smart_home example imports by [@strawgate](https://github.com/strawgate) in [#1753](https://github.com/jlowin/fastmcp/pull/1753) +* fix: correct oauth proxy initialization of client by [@strawgate](https://github.com/strawgate) in [#1759](https://github.com/jlowin/fastmcp/pull/1759) +* Fix: return empty string when prompts have no arguments by [@jlowin](https://github.com/jlowin) in [#1766](https://github.com/jlowin/fastmcp/pull/1766) +* Fix async server callbacks by [@strawgate](https://github.com/strawgate) in [#1774](https://github.com/jlowin/fastmcp/pull/1774) +* Fix error when retrieving Completion API errors by [@strawgate](https://github.com/strawgate) in [#1785](https://github.com/jlowin/fastmcp/pull/1785) +* fix: correct documentation link in deprecation warning by [@strawgate](https://github.com/strawgate) in [#1828](https://github.com/jlowin/fastmcp/pull/1828) +### Docs 📚 +* Add migration docs for 2.12 by [@jlowin](https://github.com/jlowin) in [#1745](https://github.com/jlowin/fastmcp/pull/1745) +* Update docs for default sampling implementation to mention OpenAI API Key by [@strawgate](https://github.com/strawgate) in [#1763](https://github.com/jlowin/fastmcp/pull/1763) +* Add tip about sampling prompts and user_context to sampling documentation by [@jlowin](https://github.com/jlowin) in [#1764](https://github.com/jlowin/fastmcp/pull/1764) +* Update quickstart.mdx by [@radi-dev](https://github.com/radi-dev) in [#1821](https://github.com/jlowin/fastmcp/pull/1821) +### Other Changes 🦾 +* Replace Marvin with Claude Code in CI by [@jlowin](https://github.com/jlowin) in [#1800](https://github.com/jlowin/fastmcp/pull/1800) +* Refactor logging and structured logging middleware by [@strawgate](https://github.com/strawgate) in [#1805](https://github.com/jlowin/fastmcp/pull/1805) +* feat: Move the Starlette context middleware to the front by [@akkuman](https://github.com/akkuman) in [#1812](https://github.com/jlowin/fastmcp/pull/1812) +* feat: Add support for OIDC configuration by [@ruhulio](https://github.com/ruhulio) in [#1817](https://github.com/jlowin/fastmcp/pull/1817) + +## New Contributors +* [@radi-dev](https://github.com/radi-dev) made their first contribution in [#1821](https://github.com/jlowin/fastmcp/pull/1821) +* [@akkuman](https://github.com/akkuman) made their first contribution in [#1812](https://github.com/jlowin/fastmcp/pull/1812) +* [@ruhulio](https://github.com/ruhulio) made their first contribution in [#1817](https://github.com/jlowin/fastmcp/pull/1817) + +**Full Changelog**: [v2.12.2...v2.12.3](https://github.com/jlowin/fastmcp/compare/v2.12.2...v2.12.3) + + + **[v2.12.2: Perchance to Stream](https://github.com/jlowin/fastmcp/releases/tag/v2.12.2)** @@ -1757,7 +1875,7 @@ This release is highlighted by the ability to handle complex JSON objects as MCP ### New Features 🎉 * Set up multiple os tests by [@jlowin](https://github.com/jlowin) in [#44](https://github.com/jlowin/fastmcp/pull/44) -* Changes to accomodate windows users. by [@justjoehere](https://github.com/justjoehere) in [#42](https://github.com/jlowin/fastmcp/pull/42) +* Changes to accommodate windows users. by [@justjoehere](https://github.com/justjoehere) in [#42](https://github.com/jlowin/fastmcp/pull/42) * Handle complex inputs by [@jurasofish](https://github.com/jurasofish) in [#31](https://github.com/jlowin/fastmcp/pull/31) ### Docs 📚 diff --git a/docs/development/tests.mdx b/docs/development/tests.mdx index ae3241736..7263d7dea 100644 --- a/docs/development/tests.mdx +++ b/docs/development/tests.mdx @@ -211,7 +211,7 @@ Try not to have too many assertions in a single test unless you truly need to ch #### Inline Snapshots -FastMCP uses `inline-snapshot` for testing complex data structures. On first run with empty `snapshot()`, pytest will auto-populate the expected value. To update snapshots after intentional changes, run `pytest --inline-snapshot=fix`. This is particularly useful for testing JSON schemas and API responses. +FastMCP uses `inline-snapshot` for testing complex data structures. On first run of `pytest --inline-snapshot=create` with an empty `snapshot()`, pytest will auto-populate the expected value. To update snapshots after intentional changes, run `pytest --inline-snapshot=fix`. This is particularly useful for testing JSON schemas and API responses. ```python from inline_snapshot import snapshot diff --git a/docs/docs.json b/docs/docs.json index a47589616..758eca70f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -12,13 +12,19 @@ "decoration": "windows" }, "banner": { - "content": "Remote MCP that just works: [FastMCP Cloud is here!](https://fastmcp.link/IhmBxWn) " + "content": "Host your server on [FastMCP Cloud](https://fastmcp.cloud) for free!" }, "colors": { "dark": "#f72585", "light": "#4cc9f0", "primary": "#2d00f7" }, + "contextual": { + "options": [ + "copy", + "view" + ] + }, "description": "The fast, Pythonic way to build MCP servers and clients.", "favicon": { "dark": "/assets/brand/favicon.svg", @@ -45,6 +51,13 @@ }, "name": "FastMCP", "navbar": { + "links": [ + { + "href": "https://fastmcp.cloud", + "icon": "cloud", + "label": "FastMCP Cloud" + } + ], "primary": { "href": "https://github.com/jlowin/fastmcp", "type": "github" @@ -119,7 +132,10 @@ { "group": "Essentials", "icon": "cube", - "pages": ["clients/client", "clients/transports"] + "pages": [ + "clients/client", + "clients/transports" + ] }, { "group": "Core Operations", @@ -145,7 +161,10 @@ { "group": "Authentication", "icon": "user-shield", - "pages": ["clients/auth/oauth", "clients/auth/bearer"] + "pages": [ + "clients/auth/oauth", + "clients/auth/bearer" + ] } ] }, @@ -158,8 +177,11 @@ "pages": [ "integrations/auth0", "integrations/authkit", + "integrations/aws-cognito", "integrations/azure", + "integrations/descope", "integrations/github", + "integrations/scalekit", "integrations/google", "integrations/workos" ] @@ -225,7 +247,10 @@ "tab": "Documentation" }, { - "pages": ["updates", "changelog"], + "pages": [ + "updates", + "changelog" + ], "tab": "What's New" }, { @@ -309,16 +334,21 @@ "python-sdk/fastmcp-server-auth-__init__", "python-sdk/fastmcp-server-auth-auth", "python-sdk/fastmcp-server-auth-oauth_proxy", + "python-sdk/fastmcp-server-auth-oidc_proxy", { "group": "providers", "pages": [ "python-sdk/fastmcp-server-auth-providers-__init__", + "python-sdk/fastmcp-server-auth-providers-auth0", + "python-sdk/fastmcp-server-auth-providers-aws", "python-sdk/fastmcp-server-auth-providers-azure", "python-sdk/fastmcp-server-auth-providers-bearer", + "python-sdk/fastmcp-server-auth-providers-descope", "python-sdk/fastmcp-server-auth-providers-github", "python-sdk/fastmcp-server-auth-providers-google", "python-sdk/fastmcp-server-auth-providers-in_memory", "python-sdk/fastmcp-server-auth-providers-jwt", + "python-sdk/fastmcp-server-auth-providers-scalekit", "python-sdk/fastmcp-server-auth-providers-workos" ] }, @@ -399,6 +429,7 @@ ] }, "python-sdk/fastmcp-utilities-openapi", + "python-sdk/fastmcp-utilities-storage", "python-sdk/fastmcp-utilities-tests", "python-sdk/fastmcp-utilities-types" ] diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx index 8c4095707..1c5c2330e 100644 --- a/docs/getting-started/quickstart.mdx +++ b/docs/getting-started/quickstart.mdx @@ -119,11 +119,10 @@ Note that: ## Deploy to FastMCP Cloud - [FastMCP Cloud](https://fastmcp.cloud) is a hosting service run by the FastMCP team at [Prefect](https://www.prefect.io/fastmcp). It is optimized to deploy authenticated FastMCP servers as quickly as possible, giving you a secure URL that you can plug into any LLM client. -Please note that FastMCP Cloud is a commercial service, though it is completely free for most personal servers. +FastMCP Cloud is **free for personal servers** and offers simple pay-as-you-go pricing for teams. To deploy your server, you'll need a [GitHub account](https://github.com). Once you have one, you can deploy your server in three steps: diff --git a/docs/getting-started/welcome.mdx b/docs/getting-started/welcome.mdx index f63bfd5e3..bc03f0fb1 100644 --- a/docs/getting-started/welcome.mdx +++ b/docs/getting-started/welcome.mdx @@ -4,9 +4,22 @@ sidebarTitle: "Welcome!" description: The fast, Pythonic way to build MCP servers and clients. icon: hand-wave --- +'F' logo on a watercolor background +'F' logo on a watercolor background -The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) is a new, standardized way to provide context and tools to your LLMs, and FastMCP makes building MCP servers and clients simple and intuitive. Create tools, expose resources, define prompts, and more with clean, Pythonic code: +**FastMCP is the standard framework for building MCP applications.** The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) provides a standardized way to connect LLMs to tools and data, and FastMCP makes it production-ready with clean, Pythonic code: ```python {1} from fastmcp import FastMCP @@ -23,15 +36,13 @@ if __name__ == "__main__": ``` -## Beyond the Protocol +## Beyond Basic MCP -FastMCP is the standard framework for working with the Model Context Protocol. FastMCP 1.0 was incorporated into the [official MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) in 2024. +FastMCP pioneered Python MCP development, with FastMCP 1.0 being incorporated into the [official MCP SDK](https://github.com/modelcontextprotocol/python-sdk) in 2024. -This is FastMCP 2.0, the **actively maintained version** that provides a complete toolkit for working with the MCP ecosystem. +**This is FastMCP 2.0** — the actively maintained version that extends far beyond basic protocol implementation. While the SDK provides core functionality, FastMCP 2.0 delivers everything needed for production: advanced MCP patterns (server composition, proxying, OpenAPI/FastAPI generation, tool transformation), enterprise auth (Google, GitHub, Azure, Auth0, WorkOS, and more), deployment tools, testing frameworks, and comprehensive client libraries. -FastMCP 2.0 has a comprehensive set of features that go far beyond the core MCP specification, all in service of providing **the simplest path to production**. These include deployment, auth, clients, server proxying and composition, generating servers from REST APIs, dynamic tool rewriting, built-in testing tools, integrations, and more. - -Ready to upgrade or get started? Follow the [installation instructions](/getting-started/installation), which include steps for upgrading from the official MCP SDK. +Ready to build? Start with our [installation guide](/getting-started/installation) or jump straight to the [quickstart](/getting-started/quickstart). ## What is MCP? @@ -47,11 +58,7 @@ FastMCP provides a high-level, Pythonic interface for building, managing, and in ## Why FastMCP? -The MCP protocol is powerful but implementing it involves a lot of boilerplate - server setup, protocol handlers, content types, error management. FastMCP handles all the complex protocol details and server management, so you can focus on building great tools. It's designed to be high-level and Pythonic; in most cases, decorating a function is all you need. - -FastMCP 2.0 has evolved into a comprehensive platform that goes far beyond basic protocol implementation. While 1.0 provided server-building capabilities (and is now part of the official MCP SDK), 2.0 offers a complete ecosystem including client libraries, authentication systems, deployment tools, integrations with major AI platforms, testing frameworks, and production-ready infrastructure patterns. - -FastMCP aims to be: +FastMCP handles all the complex protocol details so you can focus on building. In most cases, decorating a Python function is all you need — FastMCP handles the rest. 🚀 **Fast**: High-level interface means less code and faster development @@ -59,7 +66,9 @@ FastMCP aims to be: 🐍 **Pythonic**: Feels natural to Python developers -🔍 **Complete**: A comprehensive platform for all MCP use cases, from dev to prod +🔍 **Complete**: Everything for production — enterprise auth (Google, GitHub, Azure, Auth0, WorkOS), deployment tools, testing frameworks, client libraries, and more + +FastMCP provides the shortest path from idea to production. Deploy locally, to the cloud with [FastMCP Cloud](https://fastmcp.cloud) (free for personal servers), or to your own infrastructure. FastMCP is made with 💙 by [Prefect](https://www.prefect.io/). @@ -67,12 +76,35 @@ FastMCP is made with 💙 by [Prefect](https://www.prefect.io/). ## LLM-Friendly Docs -This documentation is also available in [llms.txt format](https://llmstxt.org/), which is a simple markdown standard that LLMs can consume easily. +The FastMCP documentation is available in multiple LLM-friendly formats: -There are two ways to access the LLM-friendly documentation: -- [llms.txt](https://gofastmcp.com/llms.txt) is essentially a sitemap, listing all the pages in the documentation. -- [llms-full.txt](https://gofastmcp.com/llms-full.txt) contains the entire documentation. Note this may exceed the context window of your LLM. +### MCP Server -In addition, any page can be accessed as markdown by appending `.md` to the URL. For example, this page would become `https://gofastmcp.com/getting-started/welcome.md`, which you can view [here](/getting-started/welcome.md). +The FastMCP docs are accessible via MCP! The server URL is `https://gofastmcp.com/mcp`. -Finally, you can copy the contents of any page as markdown by pressing "Cmd+C" (or "Ctrl+C" on Windows) on your keyboard. +In fact, you can use FastMCP to search the FastMCP docs: + +```python +import asyncio +from fastmcp import Client + +async def main(): + async with Client("https://gofastmcp.com/mcp") as client: + result = await client.call_tool( + name="SearchFastMcp", + arguments={"query": "deploy a FastMCP server"} + ) + print(result) + +asyncio.run(main()) +``` + +### Plain Text Formats + +The docs are also available in [llms.txt format](https://llmstxt.org/): +- [llms.txt](https://gofastmcp.com/llms.txt) - A sitemap listing all documentation pages +- [llms-full.txt](https://gofastmcp.com/llms-full.txt) - The entire documentation in one file (may exceed context windows) + +Any page can be accessed as markdown by appending `.md` to the URL. For example, this page becomes `https://gofastmcp.com/getting-started/welcome.md`. + +You can also copy any page as markdown by pressing "Cmd+C" (or "Ctrl+C" on Windows) on your keyboard. diff --git a/docs/integrations/aws-cognito.mdx b/docs/integrations/aws-cognito.mdx new file mode 100644 index 000000000..d89f6a1be --- /dev/null +++ b/docs/integrations/aws-cognito.mdx @@ -0,0 +1,322 @@ +--- +title: AWS Cognito OAuth 🤝 FastMCP +sidebarTitle: AWS Cognito +description: Secure your FastMCP server with AWS Cognito user pools +icon: aws +tag: NEW +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +This guide shows you how to secure your FastMCP server using **AWS Cognito user pools**. Since AWS Cognito doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge AWS Cognito's traditional OAuth with MCP's authentication requirements. It also includes robust JWT token validation, ensuring enterprise-grade authentication. + +## Configuration + +### Prerequisites + +Before you begin, you will need: +1. An **[AWS Account](https://aws.amazon.com/)** with access to create AWS Cognito user pools +2. Basic familiarity with AWS Cognito concepts (user pools, app clients) +3. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) + +### Step 1: Create an AWS Cognito User Pool and App Client + +Set up AWS Cognito user pool with an app client to get the credentials needed for authentication: + + + + Go to the **[AWS Cognito Console](https://console.aws.amazon.com/cognito/)** and ensure you're in your desired AWS region. + + Select **"User pools"** from the side navigation (click on the hamburger icon at the top left in case you don't see any), and click **"Create user pool"** to create a new user pool. + + + + AWS Cognito now provides a streamlined setup experience: + + 1. **Application type**: Select **"Traditional web application"** (this is the correct choice for FastMCP server-side authentication) + 2. **Name your application**: Enter a descriptive name (e.g., `FastMCP Server`) + + The traditional web application type automatically configures: + - Server-side authentication with client secrets + - Authorization code grant flow + - Appropriate security settings for confidential clients + + + Choose "Traditional web application" rather than SPA, Mobile app, or Machine-to-machine options. This ensures proper OAuth 2.0 configuration for FastMCP. + + + + + AWS will guide you through configuration options: + + - **Sign-in identifiers**: Choose how users will sign in (email, username, or phone) + - **Required attributes**: Select any additional user information you need + - **Return URL**: Add your callback URL (e.g., `http://localhost:8000/auth/callback` for development) + + + The simplified interface handles most OAuth security settings automatically based on your application type selection. + + + + + Review your configuration and click **"Create user pool"**. + + After creation, you'll see your user pool details. Save these important values: + - **User pool ID** (format: `eu-central-1_XXXXXXXXX`) + - **Client ID** (found under → "Applications" → "App clients" in the side navigation → \ → "App client information") + - **Client Secret** (found under → "Applications" → "App clients" in the side navigation → \ → "App client information") + + + The user pool ID and app client credentials are all you need for FastMCP configuration. + + + + + Under "Login pages" in your app client's settings, you can double check and adjust the OAuth configuration: + + - **Allowed callback URLs**: Add your server URL + `/auth/callback` (e.g., `http://localhost:8000/auth/callback`) + - **Allowed sign-out URLs**: Optional, for logout functionality + - **OAuth 2.0 grant types**: Ensure "Authorization code grant" is selected + - **OpenID Connect scopes**: Select scopes your application needs (e.g., `openid`, `email`, `profile`) + + + For local development, you can use `http://localhost` URLs. For production, you must use HTTPS. + + + + + Navigate to **"Branding" → "Domain"** in the side navigation to find or configure Your AWS Cognito domain: + + **Option 1: Use Auto-Generated Domain** + - If AWS has already created a domain automatically, note the **domain prefix** (the part before `.auth.region.amazoncognito.com`) + - This prefix is what you'll use in your FastMCP configuration + + **Option 2: Create a Custom Domain Prefix** + - If no domain exists or you want a better name, delete the existing domain and create a new one using the **"Actions"** menu + - Under **"Domain"** → **"Cognito domain"** in the **"Create Cognito domain"** dialog, enter a meaningful prefix (e.g., `my-app`) that is available in the AWS region you are in + - Just note the **domain prefix** you entered (e.g., `my-fastmcp-app`) - this is what you'll use in your FastMCP configuration + + + The FastMCP AWS Cognito provider automatically constructs the full domain from your prefix and region, simplifying configuration. + + + + + After setup, you'll have: + + - **User Pool ID**: Format like `eu-central-1_XXXXXXXXX` + - **Client ID**: Your application's client identifier + - **Client Secret**: Generated client secret (keep secure) + - **Domain Prefix**: The prefix of Your AWS Cognito domain + - **AWS Region**: Where Your AWS Cognito user pool is located + + + Store these credentials securely. Never commit them to version control. Use environment variables or AWS Secrets Manager in production. + + + + +### Step 2: FastMCP Configuration + +Create your FastMCP server using the `AWSCognitoProvider`, which handles AWS Cognito's JWT tokens and user claims automatically: + +```python server.py +from fastmcp import FastMCP +from fastmcp.server.auth.providers.aws import AWSCognitoProvider +from fastmcp.server.dependencies import get_access_token + +# The AWSCognitoProvider handles JWT validation and user claims +auth_provider = AWSCognitoProvider( + user_pool_id="eu-central-1_XXXXXXXXX", # Your AWS Cognito user pool ID + aws_region="eu-central-1", # AWS region (defaults to eu-central-1) + client_id="your-app-client-id", # Your app client ID + client_secret="your-app-client-secret", # Your app client Secret + base_url="http://localhost:8000", # Must match your callback URL + # redirect_path="/auth/callback" # Default value, customize if needed +) + +mcp = FastMCP(name="AWS Cognito Secured App", auth=auth_provider) + +# Add a protected tool to test authentication +@mcp.tool +async def get_access_token_claims() -> dict: + """Get the authenticated user's access token claims.""" + token = get_access_token() + return { + "sub": token.claims.get("sub"), + "username": token.claims.get("username"), + "cognito:groups": token.claims.get("cognito:groups", []), + } +``` + +## Testing + +### Running the Server + +Start your FastMCP server with HTTP transport to enable OAuth flows: + +```bash +fastmcp run server.py --transport http --port 8000 +``` + +Your server is now running and protected by AWS Cognito OAuth authentication. + +### Testing with a Client + +Create a test client that authenticates with Your AWS Cognito-protected server: + +```python test_client.py +from fastmcp import Client +import asyncio + +async def main(): + # The client will automatically handle AWS Cognito OAuth + async with Client("http://localhost:8000/mcp/", auth="oauth") as client: + # First-time connection will open AWS Cognito login in your browser + print("✓ Authenticated with AWS Cognito!") + + # Test the protected tool + print("Calling protected tool: get_access_token_claims") + result = await client.call_tool("get_access_token_claims") + user_data = result.data + print("Available access token claims:") + print(f"- sub: {user_data.get('sub', 'N/A')}") + print(f"- username: {user_data.get('username', 'N/A')}") + print(f"- cognito:groups: {user_data.get('cognito:groups', [])}") + +if __name__ == "__main__": + asyncio.run(main()) +``` + +When you run the client for the first time: +1. Your browser will open to AWS Cognito's hosted UI login page +2. After you sign in (or sign up), you'll be redirected back to your MCP server +3. The client receives the JWT token and can make authenticated requests + + +The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache. + + +## Environment Variables + +For production deployments, use environment variables instead of hardcoding credentials. + +### Provider Selection + +Setting this environment variable allows the AWS Cognito provider to be used automatically without explicitly instantiating it in code. + + + +Set to `fastmcp.server.auth.providers.aws.AWSCognitoProvider` to use AWS Cognito authentication. + + + +### AWS Cognito-Specific Configuration + +These environment variables provide default values for the AWS Cognito provider, whether it's instantiated manually or configured via `FASTMCP_SERVER_AUTH`. + + + +Your AWS Cognito user pool ID (e.g., `eu-central-1_XXXXXXXXX`) + + + +AWS region where your AWS Cognito user pool is located + + + +Your AWS Cognito app client ID + + + +Your AWS Cognito app client secret + + + +Public URL of your FastMCP server for OAuth callbacks + + + +One of the redirect paths configured in your AWS Cognito app client + + + +Comma-, space-, or JSON-separated list of required OAuth scopes (e.g., `openid email` or `["openid","email","profile"]`) + + + +Example `.env` file: +```bash +# Use the AWS Cognito provider +FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.aws.AWSCognitoProvider + +# AWS Cognito credentials +FASTMCP_SERVER_AUTH_AWS_COGNITO_USER_POOL_ID=eu-central-1_XXXXXXXXX +FASTMCP_SERVER_AUTH_AWS_COGNITO_AWS_REGION=eu-central-1 +FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_ID=your-app-client-id +FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_SECRET=your-app-client-secret +FASTMCP_SERVER_AUTH_AWS_COGNITO_BASE_URL=https://your-server.com +FASTMCP_SERVER_AUTH_AWS_COGNITO_REQUIRED_SCOPES=openid,email,profile +``` + +With environment variables set, your server code simplifies to: + +```python server.py +from fastmcp import FastMCP +from fastmcp.server.dependencies import get_access_token + +# Authentication is automatically configured from environment +mcp = FastMCP(name="AWS Cognito Secured App") + +@mcp.tool +async def get_access_token_claims() -> dict: + """Get the authenticated user's access token claims.""" + token = get_access_token() + return { + "sub": token.claims.get("sub"), + "username": token.claims.get("username"), + "cognito:groups": token.claims.get("cognito:groups", []), + } +``` + +## Features + +### JWT Token Validation + +The AWS Cognito provider includes robust JWT token validation: + +- **Signature Verification**: Validates tokens against AWS Cognito's public keys (JWKS) +- **Expiration Checking**: Automatically rejects expired tokens +- **Issuer Validation**: Ensures tokens come from your specific AWS Cognito user pool +- **Scope Enforcement**: Verifies required OAuth scopes are present + +### User Claims and Groups + +Access rich user information from AWS Cognito JWT tokens: + +```python +from fastmcp.server.dependencies import get_access_token + +@mcp.tool +async def admin_only_tool() -> str: + """A tool only available to admin users.""" + token = get_access_token() + user_groups = token.claims.get("cognito:groups", []) + + if "admin" not in user_groups: + raise ValueError("This tool requires admin access") + + return "Admin access granted!" +``` + +### Enterprise Integration + +Perfect for enterprise environments with: + +- **Single Sign-On (SSO)**: Integrate with corporate identity providers +- **Multi-Factor Authentication (MFA)**: Leverage AWS Cognito's built-in MFA +- **User Groups**: Role-based access control through AWS Cognito groups +- **Custom Attributes**: Access custom user attributes defined in your AWS Cognito user pool +- **Compliance**: Meet enterprise security and compliance requirements \ No newline at end of file diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx index 2ceefe482..2d2595a86 100644 --- a/docs/integrations/azure.mdx +++ b/docs/integrations/azure.mdx @@ -10,7 +10,7 @@ import { VersionBadge } from "/snippets/version-badge.mdx" -This guide shows you how to secure your FastMCP server using **Azure OAuth** (Microsoft Entra ID). Since Azure doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge Azure's traditional OAuth with MCP's authentication requirements. +This guide shows you how to secure your FastMCP server using **Azure OAuth** (Microsoft Entra ID). Since Azure doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge Azure's traditional OAuth with MCP's authentication requirements. FastMCP validates Azure JWTs against your application's client_id. ## Configuration @@ -49,8 +49,39 @@ Create an App registration in Azure Portal to get the credentials needed for aut If you want to use a custom callback path (e.g., `/auth/azure/callback`), make sure to set the same path in both your Azure App registration and the `redirect_path` parameter when configuring the AzureProvider. + + - **Expose an API**: Configure your Application ID URI and define scopes + - Go to **Expose an API** in the App registration sidebar. + - Click **Set** next to "Application ID URI" and choose one of: + - Keep the default `api://{client_id}` + - Set a custom value, following the supported formats (see [Identifier URI restrictions](https://learn.microsoft.com/en-us/entra/identity-platform/identifier-uri-restrictions)) + - Click **Add a scope** and create a scope your app will require, for example: + - Scope name: `read` (or `write`, etc.) + - Admin consent display name/description: as appropriate for your org + - Who can consent: as needed (Admins only or Admins and users) + + - **Configure Access Token Version**: Ensure your app uses access token v2 + - Go to **Manifest** in the App registration sidebar. + - Find the `requestedAccessTokenVersion` property and set it to `2`: + ```json + "api": { + "requestedAccessTokenVersion": 2 + } + ``` + - Click **Save** at the top of the manifest editor. + + + Access token v2 is required for FastMCP's Azure integration to work correctly. If this is not set, you may encounter authentication errors. + + + + In FastMCP's `AzureProvider`, set `identifier_uri` to your Application ID URI (optional; defaults to `api://{client_id}`) and set `required_scopes` to the unprefixed scope names (e.g., `read`, `write`). During authorization, FastMCP automatically prefixes scopes with your `identifier_uri`. + + + + After registration, navigate to **Certificates & secrets** in your app's settings. @@ -91,7 +122,11 @@ auth_provider = AzureProvider( client_secret="your-client-secret", # Your Azure App Client Secret tenant_id="08541b6e-646d-43de-a0eb-834e6713d6d5", # Your Azure Tenant ID (REQUIRED) base_url="http://localhost:8000", # Must match your App registration - required_scopes=["User.Read", "email", "openid", "profile"], # Microsoft Graph permissions + required_scopes=["your-scope"], # Name of scope created when configuring your App + # identifier_uri defaults to api://{client_id} + # identifier_uri="api://your-api-id", + # Optional: request additional upstream scopes in the authorize request + # additional_authorize_scopes=["User.Read", "offline_access", "openid", "email"], # redirect_path="/auth/callback" # Default value, customize if needed ) @@ -215,12 +250,16 @@ Public URL of your FastMCP server for OAuth callbacks Redirect path configured in your Azure App registration - -Comma-, space-, or JSON-separated list of required Microsoft Graph scopes + +Comma-, space-, or JSON-separated list of required scopes for your API. These are validated on tokens and used as defaults if the client does not request specific scopes. - -HTTP request timeout for Microsoft Graph API calls + +Comma-, space-, or JSON-separated list of additional scopes to include in the authorization request without prefixing. Use this to request upstream scopes such as Microsoft Graph permissions. These are not used for token validation. + + + +Application ID URI used to prefix scopes during authorization. @@ -234,7 +273,11 @@ FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID=835f09b6-0f0f-40cc-85cb-f32c5829a149 FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET=your-client-secret-here FASTMCP_SERVER_AUTH_AZURE_TENANT_ID=08541b6e-646d-43de-a0eb-834e6713d6d5 FASTMCP_SERVER_AUTH_AZURE_BASE_URL=https://your-server.com -FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES=User.Read,email,profile +FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES=read,write +# Optional custom API configuration +# FASTMCP_SERVER_AUTH_AZURE_IDENTIFIER_URI=api://your-api-id +# Request additional upstream scopes (optional) +# FASTMCP_SERVER_AUTH_AZURE_ADDITIONAL_AUTHORIZE_SCOPES=User.Read,Mail.Read ``` With environment variables set, your server code simplifies to: diff --git a/docs/integrations/chatgpt.mdx b/docs/integrations/chatgpt.mdx index 2b552fe3b..18028c69d 100644 --- a/docs/integrations/chatgpt.mdx +++ b/docs/integrations/chatgpt.mdx @@ -1,159 +1,157 @@ --- title: ChatGPT 🤝 FastMCP sidebarTitle: ChatGPT -description: Connect FastMCP servers to ChatGPT Deep Research +description: Connect FastMCP servers to ChatGPT in Chat and Deep Research modes icon: message-smile - +tag: NEW --- -ChatGPT supports MCP servers through remote HTTP connections, allowing you to extend ChatGPT's capabilities with custom tools and knowledge from your FastMCP servers. - - -MCP integration with ChatGPT is currently limited to **Deep Research** functionality and is not available for general chat. This feature is available for ChatGPT Pro, Team, Enterprise, and Edu users. - +ChatGPT supports MCP servers through remote HTTP connections in two modes: **Chat mode** for interactive conversations and **Deep Research mode** for comprehensive information retrieval. -OpenAI's official MCP documentation and examples are built with **FastMCP v2**! Check out their [simple Deep Research-style MCP server example](https://github.com/openai/sample-deep-research-mcp) for a quick reference similar to the one in this document, or their [more complete Deep Research example](https://github.com/openai/openai-cookbook/tree/main/examples/deep_research_api/how_to_build_a_deep_research_mcp_server) from the OpenAI Cookbook, which includes vector search and more. +**Developer Mode Required for Chat Mode**: To use MCP servers in regular ChatGPT conversations, you must first enable Developer Mode in your ChatGPT settings. This feature is available for ChatGPT Pro, Team, Enterprise, and Edu users. -## Deep Research + +OpenAI's official MCP documentation and examples are built with **FastMCP v2**! Learn more from their [MCP documentation](https://platform.openai.com/docs/mcp) and [Developer Mode guide](https://platform.openai.com/docs/guides/developer-mode). + -ChatGPT's Deep Research feature requires MCP servers to be internet-accessible HTTP endpoints with **exactly two specific tools**: +## Build a Server -- **`search`**: For searching through your resources and returning matching IDs -- **`fetch`**: For retrieving the full content of specific resources by ID +First, let's create a simple FastMCP server: - -If your server doesn't implement both `search` and `fetch` tools with the correct signatures, ChatGPT will show the error: "This MCP server doesn't implement our specification". Both tools are required. - - -### Tool Descriptions Matter - -Since ChatGPT needs to understand how to use your tools effectively, **write detailed tool descriptions**. The description teaches ChatGPT how to form queries, what parameters to use, and what to expect from your data. Poor descriptions lead to poor search results. - -### Create a Server - -A Deep Research-compatible server must implement these two required tools: - -- **`search(query: str)`** - Takes a query of any kind and returns matching record IDs -- **`fetch(id: str)`** - Takes an ID and returns the record - -**Critical**: Write detailed docstrings for both tools. These descriptions teach ChatGPT how to use your tools effectively. Poor descriptions lead to poor search results. - -The `search` tool should take a query (of any kind!) and return IDs. The `fetch` tool should take an ID and return the record. - -Here's a reference server implementation you can adapt (see also [OpenAI's sample server](https://github.com/openai/sample-deep-research-mcp) for comparison): - -```python server.py [expandable] -import json -from pathlib import Path -from dataclasses import dataclass +```python server.py from fastmcp import FastMCP +import random -@dataclass -class Record: - id: str - title: str - text: str - metadata: dict +mcp = FastMCP("Demo Server") -def create_server( - records_path: Path | str, - name: str | None = None, - instructions: str | None = None, -) -> FastMCP: - """Create a FastMCP server that can search and fetch records from a JSON file.""" - records = json.loads(Path(records_path).read_text()) - - RECORDS = [Record(**r) for r in records] - LOOKUP = {r.id: r for r in RECORDS} - - mcp = FastMCP(name=name or "Deep Research MCP", instructions=instructions) - - @mcp.tool() - async def search(query: str): - """ - Simple unranked keyword search across title, text, and metadata. - Searches for any of the query terms in the record content. - Returns a list of matching record IDs for ChatGPT to fetch. - """ - toks = query.lower().split() - ids = [] - for r in RECORDS: - record_txt = " ".join( - [r.title, r.text, " ".join(r.metadata.values())] - ).lower() - if any(t in record_txt for t in toks): - ids.append(r.id) - - return {"ids": ids} - - @mcp.tool() - async def fetch(id: str): - """ - Fetch a record by ID. - Returns the complete record data for ChatGPT to analyze and cite. - """ - if id not in LOOKUP: - raise ValueError(f"Unknown record ID: {id}") - return LOOKUP[id] - - return mcp +@mcp.tool +def roll_dice(sides: int = 6) -> int: + """Roll a dice with the specified number of sides.""" + return random.randint(1, sides) if __name__ == "__main__": - mcp = create_server("path/to/records.json") mcp.run(transport="http", port=8000) ``` -### Deploy the Server +### Deploy Your Server -Your server must be deployed to a public URL in order for ChatGPT to access it. - -For development, you can use tools like `ngrok` to temporarily expose a locally-running server to the internet. We'll do that for this example (you may need to install `ngrok` and create a free account), but you can use any other method to deploy your server. - -Assuming you saved the above code as `server.py`, you can run the following two commands in two separate terminals to deploy your server and expose it to the internet: +Your server must be accessible from the internet. For development, use `ngrok`: -```bash FastMCP server +```bash Terminal 1 python server.py ``` -```bash ngrok +```bash Terminal 2 ngrok http 8000 ``` - -This exposes your unauthenticated server to the internet. Only run this command in a safe environment if you understand the risks. - +Note your public URL (e.g., `https://abc123.ngrok.io`) for the next steps. -### Connect to ChatGPT +## Chat Mode -Replace `https://your-server-url.com` with the actual URL of your server (such as your ngrok URL). +Chat mode lets you use MCP tools directly in ChatGPT conversations. See [OpenAI's Developer Mode guide](https://platform.openai.com/docs/guides/developer-mode) for the latest requirements. + +### Add to ChatGPT + +#### 1. Enable Developer Mode 1. Open ChatGPT and go to **Settings** → **Connectors** -2. Click **Add custom connector** -3. Enter your server details: - - **Name**: Library Catalog - - **URL**: Your server URL, including the path. - - **Note**: Ensure your URL includes the correct path for the transport you’re using. The defaults are /sse/ for SSE (e.g., https://abc123.ngrok.io/sse/) and /mcp/ for HTTP (e.g., https://abc123.ngrok.io/mcp/). - - **Description**: A library catalog for searching and retrieving books +2. Under **Advanced**, toggle **Developer Mode** to enabled -#### Test the Connection +#### 2. Create Connector -1. Start a new chat in ChatGPT -2. Click **Tools** → **Run deep research** -3. Select your **Library Catalog** connector as a source -4. Ask questions like: - - "Search for Python programming books" - - "Find books about AI and machine learning" - - "Show me books by the Python Software Foundation" +1. In **Settings** → **Connectors**, click **Create** +2. Enter: + - **Name**: Your server name + - **Server URL**: `https://your-server.ngrok.io/mcp/` +3. Check **I trust this provider** +4. Add authentication if needed +5. Click **Create** -ChatGPT will use your server's search and fetch tools to find relevant information and cite the sources in its response. + +**Without Developer Mode**: If you don't have search/fetch tools, ChatGPT will reject the server. With Developer Mode enabled, you don't need search/fetch tools for Chat mode. + -### Troubleshooting +#### 3. Use in Chat -#### "This MCP server doesn't implement our specification" +1. Start a new chat +2. Click the **+** button → **More** → **Developer Mode** +3. **Enable your MCP server connector** (required - the connector must be explicitly added to each chat) +4. Now you can use your tools: +Example usage: +- "Roll a 20-sided dice" +- "Roll dice" (uses default 6 sides) + + +The connector must be explicitly enabled in each chat session through Developer Mode. Once added, it remains active for the entire conversation. + + +### Skip Confirmations + +Use `annotations={"readOnlyHint": True}` to skip confirmation prompts for read-only tools: + +```python +@mcp.tool(annotations={"readOnlyHint": True}) +def get_status() -> str: + """Check system status.""" + return "All systems operational" + +@mcp.tool() # No annotation - ChatGPT may ask for confirmation +def delete_item(id: str) -> str: + """Delete an item.""" + return f"Deleted {id}" +``` + +## Deep Research Mode + +Deep Research mode provides systematic information retrieval with citations. See [OpenAI's MCP documentation](https://platform.openai.com/docs/mcp) for the latest Deep Research specifications. + + +**Search and Fetch Required**: Without Developer Mode, ChatGPT will reject any server that doesn't have both `search` and `fetch` tools. Even in Developer Mode, Deep Research only uses these two tools. + + +### Tool Implementation + +Deep Research tools must follow this pattern: + +```python +@mcp.tool() +def search(query: str) -> dict: + """ + Search for records matching the query. + Must return {"ids": [list of string IDs]} + """ + # Your search logic + matching_ids = ["id1", "id2", "id3"] + return {"ids": matching_ids} + +@mcp.tool() +def fetch(id: str) -> dict: + """ + Fetch a complete record by ID. + Return the full record data for ChatGPT to analyze. + """ + # Your fetch logic + return { + "id": id, + "title": "Record Title", + "content": "Full record content...", + "metadata": {"author": "Jane Doe", "date": "2024"} + } +``` + +### Using Deep Research + +1. Ensure your server is added to ChatGPT's connectors (same as Chat mode) +2. Start a new chat +3. Click **+** → **Deep Research** +4. Select your MCP server as a source +5. Ask research questions + +ChatGPT will use your `search` and `fetch` tools to find and cite relevant information. -If you get this error, it most likely means that your server doesn't implement the required tools (`search` and `fetch`). To correct it, ensure that your server meets the service requirements. \ No newline at end of file diff --git a/docs/integrations/descope.mdx b/docs/integrations/descope.mdx new file mode 100644 index 000000000..edca62b3a --- /dev/null +++ b/docs/integrations/descope.mdx @@ -0,0 +1,154 @@ +--- +title: Descope 🤝 FastMCP +sidebarTitle: Descope +description: Secure your FastMCP server with Descope +icon: shield-check +tag: NEW +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +This guide shows you how to secure your FastMCP server using [**Descope**](https://www.descope.com), a complete authentication and user management solution. This integration uses the [**Remote OAuth**](/servers/auth/remote-oauth) pattern, where Descope handles user login and your FastMCP server validates the tokens. + +## Configuration + +### Prerequisites + +Before you begin, you will need: +1. To [sign up](https://www.descope.com/sign-up) for a Free Forever Descope account +2. Your **Project ID** from the [Descope Console](https://app.descope.com/settings/project) +3. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:3000`) + +### Step 1: Configure Descope + + + + 1. Go to the [Inbound Apps page](https://app.descope.com/apps/inbound) of the Descope Console + 2. Click **DCR Settings** + 3. Enable **Dynamic Client Registration (DCR)** + 4. Define allowed scopes + + + DCR is required for FastMCP clients to automatically register with your authentication server. + + + + + Save your Project ID from [Project Settings](https://app.descope.com/settings/project): + ``` + Project ID: P2abc...123 + ``` + + + +### Step 2: Environment Setup + +Create a `.env` file with your Descope configuration: + +```bash +DESCOPE_PROJECT_ID=P2abc...123 # Your Descope Project ID +DESCOPE_BASE_URL=https://api.descope.com # Descope API URL +SERVER_URL=http://localhost:3000 # Your server's base URL +``` + + +You can find your project's Descope Base URL in the [Multi-Region Support Guide](https://docs.descope.com/management/project-settings/multi-regional). + + +### Step 3: FastMCP Configuration + +Create your FastMCP server file and use the DescopeProvider to handle all the OAuth integration automatically: + +```python server.py +from fastmcp import FastMCP +from fastmcp.server.auth.providers.descope import DescopeProvider + +# The DescopeProvider automatically discovers Descope endpoints +# and configures JWT token validation +auth_provider = DescopeProvider( + project_id=DESCOPE_PROJECT_ID, # Your Descope Project ID + base_url=SERVER_URL, # Your server's public URL + descope_base_url=DESCOPE_BASE_URL, # Descope API base URL +) + +# Create FastMCP server with auth +mcp = FastMCP(name="My Descope Protected Server", auth=auth_provider) + +``` + +## Testing + +To test your server, you can use the `fastmcp` CLI to run it locally. Assuming you've saved the above code to `server.py` (after replacing the `project_id`, `base_url`, and `descope_base_url` with your actual values!), you can run the following command: + +```bash +fastmcp run server.py --transport http --port 8000 +``` + +Now, you can use a FastMCP client to test that you can reach your server after authenticating: + +```python +from fastmcp import Client +import asyncio + +async def main(): + async with Client("http://localhost:8000/mcp/", auth="oauth") as client: + assert await client.ping() + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Environment Variables + + +For production deployments, use environment variables instead of hardcoding credentials. + +### Provider Selection + +Setting this environment variable allows the Descope provider to be used automatically without explicitly instantiating it in code. + + + +Set to `fastmcp.server.auth.providers.descope.DescopeProvider` to use Descope authentication. + + + +### Descope-Specific Configuration + +These environment variables provide default values for the Descope provider, whether it's instantiated manually or configured via `FASTMCP_SERVER_AUTH`. + + + +Your Descope Project ID from the [Descope Console](https://app.descope.com/settings/project) + + + +Public URL of your FastMCP server (e.g., `https://your-server.com` or `http://localhost:8000` for development) + + + +Descope API base URL for your [region/environment](https://docs.descope.com/management/project-settings/multi-regional) + + + +Example `.env` file: +```bash +# Use the Descope provider +FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.descope.DescopeProvider + +# Descope configuration +FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_PROJECT_ID=P2abc...123 +FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_BASE_URL=https://your-server.com +FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_DESCOPE_BASE_URL=https://api.descope.com +``` + +With environment variables set, your server code simplifies to: + +```python server.py +from fastmcp import FastMCP + +# Authentication is automatically configured from environment +mcp = FastMCP(name="My Descope Protected Server") +``` \ No newline at end of file diff --git a/docs/integrations/fastapi.mdx b/docs/integrations/fastapi.mdx index 77e6427b6..53bd9be9c 100644 --- a/docs/integrations/fastapi.mdx +++ b/docs/integrations/fastapi.mdx @@ -341,7 +341,7 @@ app.mount("/analytics", mcp_app) ## Offering an LLM-Friendly API -A common pattern is to generate an MCP server from your FastAPI app and mount it back into the same application. This provides an LLM-optimized interface alongside your regular API: +A common pattern is to generate an MCP server from your FastAPI app and serve both interfaces from the same application. This provides an LLM-optimized interface alongside your regular API: ```python # Assumes the FastAPI app from above is already defined @@ -354,13 +354,19 @@ mcp = FastMCP.from_fastapi(app=app, name="E-commerce MCP") # 2. Create the MCP's ASGI app mcp_app = mcp.http_app(path='/mcp') -# 3. Mount it back into your FastAPI app -app = FastAPI(title="E-commerce API", lifespan=mcp_app.lifespan) -app.mount("/llm", mcp_app) +# 3. Create a new FastAPI app that combines both sets of routes +combined_app = FastAPI( + title="E-commerce API with MCP", + routes=[ + *mcp_app.routes, # MCP routes + *app.routes, # Original API routes + ], + lifespan=mcp_app.lifespan, +) # Now you have: # - Regular API: http://localhost:8000/products -# - LLM-friendly MCP: http://localhost:8000/llm/mcp/ +# - LLM-friendly MCP: http://localhost:8000/mcp/ # Both served from the same FastAPI application! ``` diff --git a/docs/integrations/scalekit.mdx b/docs/integrations/scalekit.mdx new file mode 100644 index 000000000..89de64ef1 --- /dev/null +++ b/docs/integrations/scalekit.mdx @@ -0,0 +1,187 @@ +--- +title: Scalekit 🤝 FastMCP +sidebarTitle: Scalekit +description: Secure your FastMCP server with Scalekit +icon: shield-check +tag: NEW +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + + +Install auth stack to your FastMCP server with [Scalekit](https://scalekit.com) using the [Remote OAuth](/servers/auth/remote-oauth) pattern: Scalekit handles user authentication, and the MCP server validates issued tokens. + +## Configuration + +### Prerequisites + +Before you begin + +1. Get a [Scalekit account](https://app.scalekit.com/) and grab API credentials such as **Client ID**, **Client Secret** and **Environment URL** from _Dashboard > Developers > Settings_. +2. Have your FastMCP server's endpoint ready (can be localhost for development, e.g., `http://localhost:8000/mcp`) + +### Step 1: Configure MCP server in Scalekit environment + + + + +In your Scalekit dashboard: + 1. Open the **MCP Servers** section, then select **Create new server** + 2. Enter server details: a name, a resource identifier, and the desired MCP client authentication settings + 3. Save, then copy the **Resource ID** (for example, res_92015146095) + +In your FastMCP project's `.env`: + +```sh +SCALEKIT_ENVIRONMENT_URL= +SCALEKIT_CLIENT_ID= # skc_7008EXAMPLE46 +SCALEKIT_RESOURCE_ID= # res_926EXAMPLE5878 +MCP_URL=http://localhost:8000/mcp +``` + + + + +### Step 2: Add auth to FastMCP server + +Create your FastMCP server file and use the ScalekitProvider to handle all the OAuth integration automatically: + +```python server.py +from fastmcp import FastMCP +from fastmcp.server.auth.providers.scalekit import ScalekitProvider + +# Discovers Scalekit endpoints and set up JWT token validation +auth_provider = ScalekitProvider( + environment_url=SCALEKIT_ENVIRONMENT_URL, # Scalekit environment URL + client_id=SCALEKIT_CLIENT_ID, # OAuth client ID + resource_id=SCALEKIT_RESOURCE_ID, # Resource server ID + mcp_url=SERVER_URL, # Is also aud claim +) + +# Create FastMCP server with auth +mcp = FastMCP(name="My Scalekit Protected Server", auth=auth_provider) + +@mcp.tool +def auth_status() -> dict: + """Show Scalekit authentication status.""" + # Extract user claims from the JWT + return { + "message": "This tool requires authentication via Scalekit", + "authenticated": True, + "provider": "Scalekit" + } + +``` + +## Testing + +### Start the MCP server + +```sh +uv run python server.py +``` + +Use any MCP client (for example, mcp-inspector, Claude, VS Code, or Windsurf) to connect to the running serve. Verify that authentication succeeds and requests are authorized as expected. + +### Provider selection + +Setting this environment variable allows the Scalekit provider to be used automatically without explicitly instantiating it in code. + + + +Set to `fastmcp.server.auth.providers.scalekit.ScalekitProvider` to use Scalekit authentication. + + + +### Scalekit-specific configuration + +These environment variables provide default values for the Scalekit provider, whether it's instantiated manually or configured via `FASTMCP_SERVER_AUTH`. + + + +Your Scalekit environment URL from the Admin Portal (e.g., `https://your-env.scalekit.com`) + + + +Your Scalekit OAuth application client ID from the Applications section + + + +Your Scalekit resource server ID from the Resources section + + + +Public URL of your FastMCP server (e.g., `https://your-server.com` or `http://localhost:8000/mcp` for development) + + + +Example `.env`: + +```bash +# Use the Scalekit provider +FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.scalekit.ScalekitProvider + +# Scalekit configuration +FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_ENVIRONMENT_URL=https://your-env.scalekit.com +FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_CLIENT_ID=skc_123 +FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_RESOURCE_ID=res_456 +FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_MCP_URL=https://your-server.com/mcp +``` + +With environment variables set, your server code simplifies to: + +```python server.py +from fastmcp import FastMCP + +# Authentication is automatically configured from environment +mcp = FastMCP(name="My Scalekit Protected Server") + +@mcp.tool +def protected_action() -> str: + """A tool that requires authentication.""" + return "Access granted via Scalekit!" +``` + +## Capabilities + +Scalekit supports OAuth 2.1 with Dynamic Client Registration for MCP clients and enterprise SSO, and provides built‑in JWT validation and security controls. + +**OAuth 2.1/DCR**: clients self‑register, use PKCE, and work with the Remote OAuth pattern without pre‑provisioned credentials. + +**Validation and SSO**: tokens are verified (keys, RS256, issuer, audience, expiry), and SAML, OIDC, OAuth 2.0, ADFS, Azure AD, and Google Workspace are supported; use HTTPS in production and review auth logs as needed. + +## Debugging + +Enable detailed logging to troubleshoot authentication issues: + +```python +import logging +logging.basicConfig(level=logging.DEBUG) +``` + +### Token inspection + +You can inspect JWT tokens in your tools to understand the user context: + +```python +from fastmcp.server.context import request_ctx +import jwt + +@mcp.tool +def inspect_token() -> dict: + """Inspect the current JWT token claims.""" + context = request_ctx.get() + + # Extract token from Authorization header + if hasattr(context, 'request') and hasattr(context.request, 'headers'): + auth_header = context.request.headers.get('authorization', '') + if auth_header.startswith('Bearer '): + token = auth_header[7:] + # Decode without verification (already verified by provider) + claims = jwt.decode(token, options={"verify_signature": False}) + return claims + + return {"error": "No token found"} +``` diff --git a/docs/patterns/cli.mdx b/docs/patterns/cli.mdx index 66aeac978..f157e9c2d 100644 --- a/docs/patterns/cli.mdx +++ b/docs/patterns/cli.mdx @@ -116,7 +116,7 @@ def hello() -> str: You can run it with: ```bash -fastmcp run server.py:custom_name +fastmcp run server.py:my_server ``` #### Factory Function diff --git a/docs/patterns/tool-transformation.mdx b/docs/patterns/tool-transformation.mdx index 5f274fd75..730996d51 100644 --- a/docs/patterns/tool-transformation.mdx +++ b/docs/patterns/tool-transformation.mdx @@ -548,7 +548,7 @@ Provide your own schema that differs from the parent. The tool must return data **Remove Output Schema** ```python -Tool.from_tool(parent_tool, output_schema=False) +Tool.from_tool(parent_tool, output_schema=None) ``` Removes the output schema declaration. Automatic structured content still works for object-like returns (dict, dataclass, Pydantic models) but primitive types won't be structured. @@ -566,8 +566,139 @@ Use a transform function returning `ToolResult` for complete control over both c Tool transformation is a flexible feature that supports many powerful patterns. Here are a few common use cases to give you ideas. +### Exposing Client Methods as Tools + +A powerful use case for tool transformation is exposing methods from existing Python clients (GitHub clients, API clients, database clients, etc.) directly as MCP tools. This pattern eliminates boilerplate wrapper functions and treats tools as annotations around client methods. + +**Without Tool Transformation**, you typically create wrapper functions that duplicate annotations: + +```python +async def get_repository( + owner: Annotated[str, "The owner of the repository."], + repo: Annotated[str, "The name of the repository."], +) -> Repository: + """Get basic information about a GitHub repository.""" + return await github_client.get_repository(owner=owner, repo=repo) +``` + +**With Tool Transformation**, you can wrap the client method directly: + +```python +from fastmcp import FastMCP +from fastmcp.tools import Tool +from fastmcp.tools.tool_transform import ArgTransform + +mcp = FastMCP("GitHub Tools") + +# Wrap a client method directly as a tool +get_repo_tool = Tool.from_tool( + tool=Tool.from_function(fn=github_client.get_repository), + description="Get basic information about a GitHub repository.", + transform_args={ + "owner": ArgTransform(description="The owner of the repository."), + "repo": ArgTransform(description="The name of the repository."), + } +) + +mcp.add_tool(get_repo_tool) +``` + +This pattern keeps the implementation in your client and treats the tool as an annotation layer, avoiding duplicate code. + +#### Hiding Client-Specific Arguments + +Client methods often have internal parameters (debug flags, auth tokens, rate limit settings) that shouldn't be exposed to LLMs. Use `hide=True` with a default value to handle these automatically: + +```python +get_issues_tool = Tool.from_tool( + tool=Tool.from_function(fn=github_client.get_issues), + description="Get issues from a GitHub repository.", + transform_args={ + "owner": ArgTransform(description="The owner of the repository."), + "repo": ArgTransform(description="The name of the repository."), + "limit": ArgTransform(description="Maximum number of issues to return."), + # Hide internal parameters + "include_debug_info": ArgTransform(hide=True, default=False), + "error_on_not_found": ArgTransform(hide=True, default=True), + } +) + +mcp.add_tool(get_issues_tool) +``` + +The LLM only sees `owner`, `repo`, and `limit`. Internal parameters are supplied automatically. + +#### Reusable Argument Patterns + +When wrapping multiple client methods, you can define reusable argument transformations. This scales well for larger tool sets and keeps annotations consistent: + +```python +from fastmcp import FastMCP +from fastmcp.tools import Tool +from fastmcp.tools.tool_transform import ArgTransform + +mcp = FastMCP("GitHub Tools") + +# Define reusable argument patterns +OWNER_ARG = ArgTransform(description="The repository owner.") +REPO_ARG = ArgTransform(description="The repository name.") +LIMIT_ARG = ArgTransform(description="Maximum number of items to return.") +HIDE_ERROR = ArgTransform(hide=True, default=True) + +def create_github_tools(client): + """Create tools from GitHub client methods with shared argument patterns.""" + + owner_repo_args = { + "owner": OWNER_ARG, + "repo": REPO_ARG, + } + + error_args = { + "error_on_not_found": HIDE_ERROR, + } + + return [ + Tool.from_tool( + tool=Tool.from_function(fn=client.get_repository), + description="Get basic information about a GitHub repository.", + transform_args={**owner_repo_args, **error_args} + ), + Tool.from_tool( + tool=Tool.from_function(fn=client.get_issue), + description="Get a specific issue from a repository.", + transform_args={ + **owner_repo_args, + "issue_number": ArgTransform(description="The issue number."), + "limit_comments": LIMIT_ARG, + **error_args, + } + ), + Tool.from_tool( + tool=Tool.from_function(fn=client.get_pull_request), + description="Get a specific pull request from a repository.", + transform_args={ + **owner_repo_args, + "pull_request_number": ArgTransform(description="The PR number."), + "limit_comments": LIMIT_ARG, + **error_args, + } + ), + ] + +# Add all tools to the server +for tool in create_github_tools(github_client): + mcp.add_tool(tool) +``` + +This pattern provides several benefits: + +- **No duplicate implementation**: Logic stays in the client +- **Consistent annotations**: Reusable argument patterns ensure consistency +- **Easy maintenance**: Update the client, not wrapper functions +- **Scalable**: Easily add new tools by wrapping additional client methods + ### Adapting Remote or Generated Tools -This is one of the most common reasons to use tool transformation. Tools from remote servers (via a [proxy](/servers/proxy)) or generated from an [OpenAPI spec](/integrations/openapi) are often too generic for direct use by an LLM. You can use transformation to create a simpler, more intuitive version for your specific needs. +This is one of the most common reasons to use tool transformation. Tools from remote MCP servers (via a [proxy](/servers/proxy)) or generated from an [OpenAPI spec](/integrations/openapi) are often too generic for direct use by an LLM. You can use transformation to create a simpler, more intuitive version for your specific needs. ### Chaining Transformations You can chain transformations by using an already transformed tool as the parent for a new transformation. This lets you build up complex behaviors in layers, for example, first renaming arguments, and then adding validation logic to the renamed tool. diff --git a/docs/public/schemas/fastmcp.json/latest.json b/docs/public/schemas/fastmcp.json/latest.json index e027cf967..aa1f59ce4 100644 --- a/docs/public/schemas/fastmcp.json/latest.json +++ b/docs/public/schemas/fastmcp.json/latest.json @@ -250,6 +250,7 @@ "requirements": { "anyOf": [ { + "format": "path", "type": "string" }, { @@ -267,6 +268,7 @@ "project": { "anyOf": [ { + "format": "path", "type": "string" }, { @@ -285,6 +287,7 @@ "anyOf": [ { "items": { + "format": "path", "type": "string" }, "type": "array" diff --git a/docs/public/schemas/fastmcp.json/v1.json b/docs/public/schemas/fastmcp.json/v1.json index e027cf967..aa1f59ce4 100644 --- a/docs/public/schemas/fastmcp.json/v1.json +++ b/docs/public/schemas/fastmcp.json/v1.json @@ -250,6 +250,7 @@ "requirements": { "anyOf": [ { + "format": "path", "type": "string" }, { @@ -267,6 +268,7 @@ "project": { "anyOf": [ { + "format": "path", "type": "string" }, { @@ -285,6 +287,7 @@ "anyOf": [ { "items": { + "format": "path", "type": "string" }, "type": "array" diff --git a/docs/python-sdk/fastmcp-cli-cli.mdx b/docs/python-sdk/fastmcp-cli-cli.mdx index 2e482fc7c..a6f4ee13b 100644 --- a/docs/python-sdk/fastmcp-cli-cli.mdx +++ b/docs/python-sdk/fastmcp-cli-cli.mdx @@ -74,7 +74,7 @@ fastmcp run server.py -- --config config.json --debug - `server_spec`: Python file, object specification (file\:obj), config file, URL, or None to auto-detect -### `inspect` +### `inspect` ```python inspect(server_spec: str | None = None) -> None @@ -105,7 +105,7 @@ fastmcp inspect # auto-detect fastmcp.json - `server_spec`: Python file to inspect, optionally with \:object suffix, or fastmcp.json -### `prepare` +### `prepare` ```python prepare(config_path: Annotated[str | None, cyclopts.Parameter(help='Path to fastmcp.json configuration file')] = None, output_dir: Annotated[str | None, cyclopts.Parameter(help='Directory to create the persistent environment in')] = None, skip_source: Annotated[bool, cyclopts.Parameter(help='Skip source preparation (e.g., git clone)')] = False) -> None diff --git a/docs/python-sdk/fastmcp-cli-install-gemini_cli.mdx b/docs/python-sdk/fastmcp-cli-install-gemini_cli.mdx index 1e2a647dd..7cbf60153 100644 --- a/docs/python-sdk/fastmcp-cli-install-gemini_cli.mdx +++ b/docs/python-sdk/fastmcp-cli-install-gemini_cli.mdx @@ -54,7 +54,7 @@ Install FastMCP server in Gemini CLI. - True if installation was successful, False otherwise -### `gemini_cli_command` +### `gemini_cli_command` ```python gemini_cli_command(server_spec: str) -> None diff --git a/docs/python-sdk/fastmcp-client-auth-oauth.mdx b/docs/python-sdk/fastmcp-client-auth-oauth.mdx index a5781a6aa..cc3dd7cf5 100644 --- a/docs/python-sdk/fastmcp-client-auth-oauth.mdx +++ b/docs/python-sdk/fastmcp-client-auth-oauth.mdx @@ -13,7 +13,7 @@ sidebarTitle: oauth default_cache_dir() -> Path ``` -### `check_if_auth_required` +### `check_if_auth_required` ```python check_if_auth_required(mcp_url: str, httpx_kwargs: dict[str, Any] | None = None) -> bool @@ -47,11 +47,12 @@ File-based token storage implementation for OAuth credentials and tokens. Implements the mcp.client.auth.TokenStorage protocol. Each instance is tied to a specific server URL for proper token isolation. +Uses JSONFileStorage internally for consistent file handling. **Methods:** -#### `get_base_url` +#### `get_base_url` ```python get_base_url(url: str) -> str @@ -60,16 +61,7 @@ get_base_url(url: str) -> str Extract the base URL (scheme + host) from a URL. -#### `get_cache_key` - -```python -get_cache_key(self) -> str -``` - -Generate a safe filesystem key from the server's base URL. - - -#### `get_tokens` +#### `get_tokens` ```python get_tokens(self) -> OAuthToken | None @@ -78,7 +70,7 @@ get_tokens(self) -> OAuthToken | None Load tokens from file storage. -#### `set_tokens` +#### `set_tokens` ```python set_tokens(self, tokens: OAuthToken) -> None @@ -87,7 +79,7 @@ set_tokens(self, tokens: OAuthToken) -> None Save tokens to file storage. -#### `get_client_info` +#### `get_client_info` ```python get_client_info(self) -> OAuthClientInformationFull | None @@ -96,7 +88,7 @@ get_client_info(self) -> OAuthClientInformationFull | None Load client information from file storage. -#### `set_client_info` +#### `set_client_info` ```python set_client_info(self, client_info: OAuthClientInformationFull) -> None @@ -105,7 +97,7 @@ set_client_info(self, client_info: OAuthClientInformationFull) -> None Save client information to file storage. -#### `clear` +#### `clear` ```python clear(self) -> None @@ -113,8 +105,11 @@ clear(self) -> None Clear all cached data for this server. +Note: This is a synchronous method for backward compatibility. +Uses direct file operations instead of async storage methods. -#### `clear_all` + +#### `clear_all` ```python clear_all(cls, cache_dir: Path | None = None) -> None @@ -123,7 +118,7 @@ clear_all(cls, cache_dir: Path | None = None) -> None Clear all cached data for all servers. -### `OAuth` +### `OAuth` OAuth client provider for MCP servers with browser-based authentication. @@ -134,7 +129,7 @@ a browser for user authorization and running a local callback server. **Methods:** -#### `redirect_handler` +#### `redirect_handler` ```python redirect_handler(self, authorization_url: str) -> None @@ -143,7 +138,7 @@ redirect_handler(self, authorization_url: str) -> None Open browser for authorization, with pre-flight check for invalid client. -#### `callback_handler` +#### `callback_handler` ```python callback_handler(self) -> tuple[str, str | None] @@ -152,7 +147,7 @@ callback_handler(self) -> tuple[str, str | None] Handle OAuth callback and return (auth_code, state). -#### `async_auth_flow` +#### `async_auth_flow` ```python async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response] diff --git a/docs/python-sdk/fastmcp-client-client.mdx b/docs/python-sdk/fastmcp-client-client.mdx index cfe65c37b..e089c8201 100644 --- a/docs/python-sdk/fastmcp-client-client.mdx +++ b/docs/python-sdk/fastmcp-client-client.mdx @@ -374,7 +374,7 @@ containing the prompt messages and any additional metadata. #### `complete_mcp` ```python -complete_mcp(self, ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference, argument: dict[str, str]) -> mcp.types.CompleteResult +complete_mcp(self, ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference, argument: dict[str, str], context_arguments: dict[str, Any] | None = None) -> mcp.types.CompleteResult ``` Send a completion request and return the complete MCP protocol result. @@ -382,6 +382,8 @@ Send a completion request and return the complete MCP protocol result. **Args:** - `ref`: The reference to complete. - `argument`: Arguments to pass to the completion request. +- `context_arguments`: Optional context arguments to +include with the completion request. Defaults to None. **Returns:** - mcp.types.CompleteResult: The complete response object from the protocol, @@ -391,10 +393,10 @@ containing the completion and any additional metadata. - `RuntimeError`: If called while the client is not connected. -#### `complete` +#### `complete` ```python -complete(self, ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference, argument: dict[str, str]) -> mcp.types.Completion +complete(self, ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference, argument: dict[str, str], context_arguments: dict[str, Any] | None = None) -> mcp.types.Completion ``` Send a completion request to the server. @@ -402,6 +404,8 @@ Send a completion request to the server. **Args:** - `ref`: The reference to complete. - `argument`: Arguments to pass to the completion request. +- `context_arguments`: Optional context arguments to +include with the completion request. Defaults to None. **Returns:** - mcp.types.Completion: The completion object. @@ -410,7 +414,7 @@ Send a completion request to the server. - `RuntimeError`: If called while the client is not connected. -#### `list_tools_mcp` +#### `list_tools_mcp` ```python list_tools_mcp(self) -> mcp.types.ListToolsResult @@ -426,7 +430,7 @@ containing the list of tools and any additional metadata. - `RuntimeError`: If called while the client is not connected. -#### `list_tools` +#### `list_tools` ```python list_tools(self) -> list[mcp.types.Tool] @@ -441,7 +445,7 @@ Retrieve a list of tools available on the server. - `RuntimeError`: If called while the client is not connected. -#### `call_tool_mcp` +#### `call_tool_mcp` ```python call_tool_mcp(self, name: str, arguments: dict[str, Any], progress_handler: ProgressHandler | None = None, timeout: datetime.timedelta | float | int | None = None) -> mcp.types.CallToolResult @@ -466,7 +470,7 @@ containing the tool result and any additional metadata. - `RuntimeError`: If called while the client is not connected. -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None, timeout: datetime.timedelta | float | int | None = None, progress_handler: ProgressHandler | None = None, raise_on_error: bool = True) -> CallToolResult @@ -496,10 +500,10 @@ raw result object. - `RuntimeError`: If called while the client is not connected. -#### `generate_name` +#### `generate_name` ```python generate_name(cls, name: str | None = None) -> str ``` -### `CallToolResult` +### `CallToolResult` diff --git a/docs/python-sdk/fastmcp-server-auth-auth.mdx b/docs/python-sdk/fastmcp-server-auth-auth.mdx index 72525e8b3..d95e116d2 100644 --- a/docs/python-sdk/fastmcp-server-auth-auth.mdx +++ b/docs/python-sdk/fastmcp-server-auth-auth.mdx @@ -7,13 +7,13 @@ sidebarTitle: auth ## Classes -### `AccessToken` +### `AccessToken` AccessToken that includes all JWT claims. -### `AuthProvider` +### `AuthProvider` Base class for all FastMCP authentication providers. @@ -26,7 +26,7 @@ custom authentication routes. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -43,7 +43,7 @@ All auth providers must implement token verification. - AccessToken object if valid, None if invalid or expired -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None, mcp_endpoint: Any | None = None) -> list[Route] @@ -65,7 +65,7 @@ Each provider is responsible for creating whatever routes it needs: - List of routes for this provider, including protected MCP endpoints if provided -#### `get_middleware` +#### `get_middleware` ```python get_middleware(self) -> list diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx index 4117f6afb..df2a262ba 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx @@ -26,7 +26,7 @@ production use with enterprise identity providers. ## Classes -### `ProxyDCRClient` +### `ProxyDCRClient` Client for DCR proxy with configurable redirect URI validation. @@ -56,7 +56,7 @@ arise from accepting arbitrary redirect URIs. **Methods:** -#### `validate_redirect_uri` +#### `validate_redirect_uri` ```python validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl @@ -70,7 +70,7 @@ This is essential for cached token scenarios where the client may reconnect with a different port. -### `OAuthProxy` +### `OAuthProxy` OAuth provider that presents a DCR-compliant interface while proxying to non-DCR IDPs. @@ -181,7 +181,7 @@ Handles provider-specific requirements: **Methods:** -#### `get_client` +#### `get_client` ```python get_client(self, client_id: str) -> OAuthClientInformationFull | None @@ -193,7 +193,7 @@ provided to the DCR client during registration, not the upstream client ID. For unregistered clients, returns None (which will raise an error in the SDK). -#### `register_client` +#### `register_client` ```python register_client(self, client_info: OAuthClientInformationFull) -> None @@ -207,7 +207,7 @@ redirect URI will likely be localhost or unknown to the proxied IDP. The proxied IDP only knows about this server's fixed redirect URI. -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str @@ -222,7 +222,7 @@ This implements the DCR-compliant proxy pattern: 4. Redirect to IdP with our fixed callback URL and proxy's PKCE -#### `load_authorization_code` +#### `load_authorization_code` ```python load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> AuthorizationCode | None @@ -234,7 +234,7 @@ Look up our client code and return authorization code object with PKCE challenge for validation. -#### `exchange_authorization_code` +#### `exchange_authorization_code` ```python exchange_authorization_code(self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode) -> OAuthToken @@ -246,7 +246,7 @@ For the DCR-compliant proxy flow, we return the IdP tokens that were obtained during the IdP callback exchange. PKCE validation is handled by the MCP framework. -#### `load_refresh_token` +#### `load_refresh_token` ```python load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None @@ -255,7 +255,7 @@ load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) Load refresh token from local storage. -#### `exchange_refresh_token` +#### `exchange_refresh_token` ```python exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]) -> OAuthToken @@ -264,7 +264,7 @@ exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: Exchange refresh token for new access token using authlib. -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None @@ -276,7 +276,7 @@ Delegates to the JWT verifier which handles signature validation, expiration checking, and claims validation using the upstream JWKS. -#### `revoke_token` +#### `revoke_token` ```python revoke_token(self, token: AccessToken | RefreshToken) -> None @@ -288,7 +288,7 @@ Removes tokens from local storage and attempts to revoke them with the upstream server if a revocation endpoint is configured. -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None, mcp_endpoint: Any | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx new file mode 100644 index 000000000..f20b2dcdf --- /dev/null +++ b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx @@ -0,0 +1,82 @@ +--- +title: oidc_proxy +sidebarTitle: oidc_proxy +--- + +# `fastmcp.server.auth.oidc_proxy` + + +OIDC Proxy Provider for FastMCP. + +This provider acts as a transparent proxy to an upstream OIDC compliant Authorization +Server. It leverages the OAuthProxy class to handle Dynamic Client Registration and +forwarding of all OAuth flows. + +This implementation is based on: + OpenID Connect Discovery 1.0 - https://openid.net/specs/openid-connect-discovery-1_0.html + OAuth 2.0 Authorization Server Metadata - https://datatracker.ietf.org/doc/html/rfc8414 + + +## Classes + +### `OIDCConfiguration` + + +OIDC Configuration. + + +**Methods:** + +#### `get_oidc_configuration` + +```python +get_oidc_configuration(cls, config_url: AnyHttpUrl) -> Self +``` + +Get the OIDC configuration for the specified config URL. + +**Args:** +- `config_url`: The OIDC config URL +- `strict`: The strict flag for the configuration +- `timeout_seconds`: HTTP request timeout in seconds + + +### `OIDCProxy` + + +OAuth provider that wraps OAuthProxy to provide configuration via an OIDC configuration URL. + +This provider makes it easier to add OAuth protection for any upstream provider +that is OIDC compliant. + + +**Methods:** + +#### `get_oidc_configuration` + +```python +get_oidc_configuration(self, config_url: AnyHttpUrl, strict: bool | None, timeout_seconds: int | None) -> OIDCConfiguration +``` + +Gets the OIDC configuration for the specified configuration URL. + +**Args:** +- `config_url`: The OIDC configuration URL +- `strict`: The strict flag for the configuration +- `timeout_seconds`: HTTP request timeout in seconds + + +#### `get_token_verifier` + +```python +get_token_verifier(self) -> TokenVerifier +``` + +Creates the token verifier for the specified OIDC configuration and arguments. + +**Args:** +- `algorithm`: Optional token verifier algorithm +- `audience`: Optional token verifier audience +- `required_scopes`: Optional token verifier required_scopes +- `timeout_seconds`: HTTP request timeout in seconds + diff --git a/docs/python-sdk/fastmcp-server-auth-providers-auth0.mdx b/docs/python-sdk/fastmcp-server-auth-providers-auth0.mdx new file mode 100644 index 000000000..344f29b64 --- /dev/null +++ b/docs/python-sdk/fastmcp-server-auth-providers-auth0.mdx @@ -0,0 +1,47 @@ +--- +title: auth0 +sidebarTitle: auth0 +--- + +# `fastmcp.server.auth.providers.auth0` + + +Auth0 OAuth provider for FastMCP. + +This module provides a complete Auth0 integration that's ready to use with +just the configuration URL, client ID, client secret, audience, and base URL. + +Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.auth.providers.auth0 import Auth0Provider + + # Simple Auth0 OAuth protection + auth = Auth0Provider( + config_url="https://auth0.config.url", + client_id="your-auth0-client-id", + client_secret="your-auth0-client-secret", + audience="your-auth0-api-audience", + base_url="http://localhost:8000", + ) + + mcp = FastMCP("My Protected Server", auth=auth) + ``` + + +## Classes + +### `Auth0ProviderSettings` + + +Settings for Auth0 OIDC provider. + + +### `Auth0Provider` + + +An Auth0 provider implementation for FastMCP. + +This provider is a complete Auth0 integration that's ready to use with +just the configuration URL, client ID, client secret, audience, and base URL. + diff --git a/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx b/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx new file mode 100644 index 000000000..290951a5a --- /dev/null +++ b/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx @@ -0,0 +1,88 @@ +--- +title: aws +sidebarTitle: aws +--- + +# `fastmcp.server.auth.providers.aws` + + +AWS Cognito OAuth provider for FastMCP. + +This module provides a complete AWS Cognito OAuth integration that's ready to use +with a user pool ID, domain prefix, client ID and client secret. It handles all +the complexity of AWS Cognito's OAuth flow, token validation, and user management. + +Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.auth.providers.aws_cognito import AWSCognitoProvider + + # Simple AWS Cognito OAuth protection + auth = AWSCognitoProvider( + user_pool_id="your-user-pool-id", + aws_region="eu-central-1", + client_id="your-cognito-client-id", + client_secret="your-cognito-client-secret" + ) + + mcp = FastMCP("My Protected Server", auth=auth) + ``` + + +## Classes + +### `AWSCognitoProviderSettings` + + +Settings for AWS Cognito OAuth provider. + + +### `AWSCognitoTokenVerifier` + + +Token verifier that filters claims to Cognito-specific subset. + + +**Methods:** + +#### `verify_token` + +```python +verify_token(self, token: str) -> AccessToken | None +``` + +Verify token and filter claims to Cognito-specific subset. + + +### `AWSCognitoProvider` + + +Complete AWS Cognito OAuth provider for FastMCP. + +This provider makes it trivial to add AWS Cognito OAuth protection to any +FastMCP server using OIDC Discovery. Just provide your Cognito User Pool details, +client credentials, and a base URL, and you're ready to go. + +Features: +- Automatic OIDC Discovery from AWS Cognito User Pool +- Automatic JWT token validation via Cognito's public keys +- Cognito-specific claim filtering (sub, username, cognito:groups) +- Support for Cognito User Pools + + +**Methods:** + +#### `get_token_verifier` + +```python +get_token_verifier(self) -> TokenVerifier +``` + +Creates a Cognito-specific token verifier with claim filtering. + +**Args:** +- `algorithm`: Optional token verifier algorithm +- `audience`: Optional token verifier audience +- `required_scopes`: Optional token verifier required_scopes +- `timeout_seconds`: HTTP request timeout in seconds + diff --git a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx index d1fb83d83..463e5f731 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx @@ -14,13 +14,13 @@ using the OAuth Proxy pattern for non-DCR OAuth flows. ## Classes -### `AzureProviderSettings` +### `AzureProviderSettings` Settings for Azure OAuth provider. -### `AzureTokenVerifier` +### `AzureTokenVerifier` Token verifier for Azure OAuth tokens. @@ -31,7 +31,7 @@ to get user information and validate the token. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -40,7 +40,7 @@ verify_token(self, token: str) -> AccessToken | None Verify Azure OAuth token by calling Microsoft Graph API. -### `AzureProvider` +### `AzureProvider` Azure (Microsoft Entra) OAuth provider for FastMCP. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-descope.mdx b/docs/python-sdk/fastmcp-server-auth-providers-descope.mdx new file mode 100644 index 000000000..2064a25b1 --- /dev/null +++ b/docs/python-sdk/fastmcp-server-auth-providers-descope.mdx @@ -0,0 +1,62 @@ +--- +title: descope +sidebarTitle: descope +--- + +# `fastmcp.server.auth.providers.descope` + + +Descope authentication provider for FastMCP. + +This module provides DescopeProvider - a complete authentication solution that integrates +with Descope's OAuth 2.1 and OpenID Connect services, supporting Dynamic Client Registration (DCR) +for seamless MCP client authentication. + + +## Classes + +### `DescopeProviderSettings` + +### `DescopeProvider` + + +Descope metadata provider for DCR (Dynamic Client Registration). + +This provider implements Descope integration using metadata forwarding. +This is the recommended approach for Descope DCR +as it allows Descope to handle the OAuth flow directly while FastMCP acts +as a resource server. + +IMPORTANT SETUP REQUIREMENTS: + +1. Enable Dynamic Client Registration in Descope Console: + - Go to the [Inbound Apps page](https://app.descope.com/apps/inbound) of the Descope Console + - Click **DCR Settings** + - Enable **Dynamic Client Registration (DCR)** + - Define allowed scopes + +2. Note your Project ID: + - Save your Project ID from [Project Settings](https://app.descope.com/settings/project) + - Example: P2abc...123 + +For detailed setup instructions, see: +https://docs.descope.com/identity-federation/inbound-apps/creating-inbound-apps#method-2-dynamic-client-registration-dcr + + +**Methods:** + +#### `get_routes` + +```python +get_routes(self, mcp_path: str | None = None, mcp_endpoint: Any | None = None) -> list[Route] +``` + +Get OAuth routes including Descope authorization server metadata forwarding. + +This returns the standard protected resource routes plus an authorization server +metadata endpoint that forwards Descope's OAuth metadata to clients. + +**Args:** +- `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") +- `mcp_endpoint`: The MCP endpoint handler to protect with auth + diff --git a/docs/python-sdk/fastmcp-server-auth-providers-github.mdx b/docs/python-sdk/fastmcp-server-auth-providers-github.mdx index 39ad189b0..e72034495 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-github.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-github.mdx @@ -29,13 +29,13 @@ Example: ## Classes -### `GitHubProviderSettings` +### `GitHubProviderSettings` Settings for GitHub OAuth provider. -### `GitHubTokenVerifier` +### `GitHubTokenVerifier` Token verifier for GitHub OAuth tokens. @@ -46,7 +46,7 @@ by calling GitHub's API to check if they're valid and get user info. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -55,7 +55,7 @@ verify_token(self, token: str) -> AccessToken | None Verify GitHub OAuth token by calling GitHub API. -### `GitHubProvider` +### `GitHubProvider` Complete GitHub OAuth provider for FastMCP. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-google.mdx b/docs/python-sdk/fastmcp-server-auth-providers-google.mdx index 307246b15..20d3a63e9 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-google.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-google.mdx @@ -29,13 +29,13 @@ Example: ## Classes -### `GoogleProviderSettings` +### `GoogleProviderSettings` Settings for Google OAuth provider. -### `GoogleTokenVerifier` +### `GoogleTokenVerifier` Token verifier for Google OAuth tokens. @@ -46,7 +46,7 @@ by calling Google's tokeninfo API to check if they're valid and get user info. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -55,7 +55,7 @@ verify_token(self, token: str) -> AccessToken | None Verify Google OAuth token by calling Google's tokeninfo API. -### `GoogleProvider` +### `GoogleProvider` Complete Google OAuth provider for FastMCP. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx b/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx new file mode 100644 index 000000000..f1b9a5df0 --- /dev/null +++ b/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx @@ -0,0 +1,64 @@ +--- +title: scalekit +sidebarTitle: scalekit +--- + +# `fastmcp.server.auth.providers.scalekit` + + +Scalekit authentication provider for FastMCP. + +This module provides ScalekitProvider - a complete authentication solution that integrates +with Scalekit's OAuth 2.1 and OpenID Connect services, supporting Resource Server +authentication for seamless MCP client authentication. + + +## Classes + +### `ScalekitProviderSettings` + +### `ScalekitProvider` + + +Scalekit resource server provider for OAuth 2.1 authentication. + +This provider implements Scalekit integration using resource server pattern. +FastMCP acts as a protected resource server that validates access tokens issued +by Scalekit's authorization server. + +IMPORTANT SETUP REQUIREMENTS: + +1. Create an MCP Server in Scalekit Dashboard: + - Go to your [Scalekit Dashboard](https://app.scalekit.com/) + - Navigate to MCP Servers section + - Register a new MCP Server with appropriate scopes + - Ensure the Resource Identifier matches exactly what you configure as MCP URL + - Note the Resource ID + +2. Environment Configuration: + - Set SCALEKIT_ENVIRONMENT_URL (e.g., https://your-env.scalekit.com) + - Set SCALEKIT_CLIENT_ID from your OAuth application + - Set SCALEKIT_RESOURCE_ID from your created resource + - Set MCP_URL to your FastMCP server's public URL + +For detailed setup instructions, see: +https://docs.scalekit.com/mcp/overview/ + + +**Methods:** + +#### `get_routes` + +```python +get_routes(self, mcp_path: str | None = None, mcp_endpoint: Any | None = None) -> list[Route] +``` + +Get OAuth routes including Scalekit authorization server metadata forwarding. + +This returns the standard protected resource routes plus an authorization server +metadata endpoint that forwards Scalekit's OAuth metadata to clients. + +**Args:** +- `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") +- `mcp_endpoint`: The MCP endpoint handler to protect with auth + diff --git a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx index 3132111f1..a4ac73e02 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx @@ -18,13 +18,13 @@ Choose based on your WorkOS setup and authentication requirements. ## Classes -### `WorkOSProviderSettings` +### `WorkOSProviderSettings` Settings for WorkOS OAuth provider. -### `WorkOSTokenVerifier` +### `WorkOSTokenVerifier` Token verifier for WorkOS OAuth tokens. @@ -35,7 +35,7 @@ the /oauth2/userinfo endpoint to check validity and get user info. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -44,7 +44,7 @@ verify_token(self, token: str) -> AccessToken | None Verify WorkOS OAuth token by calling userinfo endpoint. -### `WorkOSProvider` +### `WorkOSProvider` Complete WorkOS OAuth provider for FastMCP. @@ -65,9 +65,9 @@ Setup Requirements: 4. Note your Client ID and Client Secret -### `AuthKitProviderSettings` +### `AuthKitProviderSettings` -### `AuthKitProvider` +### `AuthKitProvider` AuthKit metadata provider for DCR (Dynamic Client Registration). @@ -93,7 +93,7 @@ https://workos.com/docs/authkit/mcp/integrating/token-verification **Methods:** -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None, mcp_endpoint: Any | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-context.mdx b/docs/python-sdk/fastmcp-server-context.mdx index bbc14d0ee..f828d513f 100644 --- a/docs/python-sdk/fastmcp-server-context.mdx +++ b/docs/python-sdk/fastmcp-server-context.mdx @@ -7,7 +7,7 @@ sidebarTitle: context ## Functions -### `set_context` +### `set_context` ```python set_context(context: Context) -> Generator[Context, None, None] @@ -15,7 +15,7 @@ set_context(context: Context) -> Generator[Context, None, None] ## Classes -### `LogData` +### `LogData` Data object for passing log arguments to client-side handlers. @@ -24,7 +24,7 @@ This provides an interface to match the Python standard library logging, for compatibility with structured logging. -### `Context` +### `Context` Context object providing access to MCP capabilities. @@ -36,18 +36,18 @@ To use context in a tool function, add a parameter with the Context type annotat ```python @server.tool -def my_tool(x: int, ctx: Context) -> str: +async def my_tool(x: int, ctx: Context) -> str: # Log messages to the client - ctx.info(f"Processing {x}") - ctx.debug("Debug info") - ctx.warning("Warning message") - ctx.error("Error message") + await ctx.info(f"Processing {x}") + await ctx.debug("Debug info") + await ctx.warning("Warning message") + await ctx.error("Error message") # Report progress - ctx.report_progress(50, 100, "Processing") + await ctx.report_progress(50, 100, "Processing") # Access resources - data = ctx.read_resource("resource://data") + data = await ctx.read_resource("resource://data") # Get request info request_id = ctx.request_id @@ -72,7 +72,7 @@ The context is optional - tools that don't need it can omit the parameter. **Methods:** -#### `fastmcp` +#### `fastmcp` ```python fastmcp(self) -> FastMCP @@ -81,7 +81,7 @@ fastmcp(self) -> FastMCP Get the FastMCP instance. -#### `request_context` +#### `request_context` ```python request_context(self) -> RequestContext[ServerSession, Any, Request] @@ -92,7 +92,7 @@ Access to the underlying request context. If called outside of a request context, this will raise a ValueError. -#### `report_progress` +#### `report_progress` ```python report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None @@ -105,7 +105,7 @@ Report progress for the current operation. - `total`: Optional total value e.g. 100 -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents] @@ -120,7 +120,7 @@ Read a resource by URI. - The resource content as either text or bytes -#### `log` +#### `log` ```python log(self, message: str, level: LoggingLevel | None = None, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -136,7 +136,7 @@ Send a log message to the client. - `extra`: Optional mapping for additional arguments -#### `client_id` +#### `client_id` ```python client_id(self) -> str | None @@ -145,7 +145,7 @@ client_id(self) -> str | None Get the client ID if available. -#### `request_id` +#### `request_id` ```python request_id(self) -> str @@ -154,7 +154,7 @@ request_id(self) -> str Get the unique ID for this request. -#### `session_id` +#### `session_id` ```python session_id(self) -> str @@ -171,7 +171,7 @@ the same client session. - for other transports. -#### `session` +#### `session` ```python session(self) -> ServerSession @@ -180,7 +180,7 @@ session(self) -> ServerSession Access to the underlying session for advanced usage. -#### `debug` +#### `debug` ```python debug(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -189,7 +189,7 @@ debug(self, message: str, logger_name: str | None = None, extra: Mapping[str, An Send a debug log message. -#### `info` +#### `info` ```python info(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -198,7 +198,7 @@ info(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any Send an info log message. -#### `warning` +#### `warning` ```python warning(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -207,7 +207,7 @@ warning(self, message: str, logger_name: str | None = None, extra: Mapping[str, Send a warning log message. -#### `error` +#### `error` ```python error(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -216,7 +216,7 @@ error(self, message: str, logger_name: str | None = None, extra: Mapping[str, An Send an error log message. -#### `list_roots` +#### `list_roots` ```python list_roots(self) -> list[Root] @@ -225,7 +225,7 @@ list_roots(self) -> list[Root] List the roots available to the server, as indicated by the client. -#### `send_tool_list_changed` +#### `send_tool_list_changed` ```python send_tool_list_changed(self) -> None @@ -234,7 +234,7 @@ send_tool_list_changed(self) -> None Send a tool list changed notification to the client. -#### `send_resource_list_changed` +#### `send_resource_list_changed` ```python send_resource_list_changed(self) -> None @@ -243,7 +243,7 @@ send_resource_list_changed(self) -> None Send a resource list changed notification to the client. -#### `send_prompt_list_changed` +#### `send_prompt_list_changed` ```python send_prompt_list_changed(self) -> None @@ -252,10 +252,10 @@ send_prompt_list_changed(self) -> None Send a prompt list changed notification to the client. -#### `sample` +#### `sample` ```python -sample(self, messages: str | list[str | SamplingMessage], system_prompt: str | None = None, include_context: IncludeContext | None = None, temperature: float | None = None, max_tokens: int | None = None, model_preferences: ModelPreferences | str | list[str] | None = None) -> ContentBlock +sample(self, messages: str | Sequence[str | SamplingMessage], system_prompt: str | None = None, include_context: IncludeContext | None = None, temperature: float | None = None, max_tokens: int | None = None, model_preferences: ModelPreferences | str | list[str] | None = None) -> TextContent | ImageContent | AudioContent ``` Send a sampling request to the client and await the response. @@ -265,25 +265,25 @@ completion from the client. The client must be appropriately configured, or the request will error. -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: None) -> AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: type[T]) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[str]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: type[T] | list[str] | None = None) -> AcceptedElicitation[T] | AcceptedElicitation[dict[str, Any]] | AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation @@ -312,7 +312,7 @@ type or dataclass or BaseModel. If it is a primitive type, an object schema with a single "value" field will be generated. -#### `get_http_request` +#### `get_http_request` ```python get_http_request(self) -> Request @@ -321,7 +321,7 @@ get_http_request(self) -> Request Get the active starlette request. -#### `set_state` +#### `set_state` ```python set_state(self, key: str, value: Any) -> None @@ -330,7 +330,7 @@ set_state(self, key: str, value: Any) -> None Set a value in the context state. -#### `get_state` +#### `get_state` ```python get_state(self, key: str) -> Any diff --git a/docs/python-sdk/fastmcp-server-middleware-logging.mdx b/docs/python-sdk/fastmcp-server-middleware-logging.mdx index 853725a01..fd6ee041c 100644 --- a/docs/python-sdk/fastmcp-server-middleware-logging.mdx +++ b/docs/python-sdk/fastmcp-server-middleware-logging.mdx @@ -22,18 +22,15 @@ The default serializer for Payloads in the logging middleware. ## Classes -### `LoggingMiddleware` +### `BaseLoggingMiddleware` -Middleware that provides comprehensive request and response logging. - -Logs all MCP messages with configurable detail levels. Useful for debugging, -monitoring, and understanding server usage patterns. +Base class for logging middleware. **Methods:** -#### `on_message` +#### `on_message` ```python on_message(self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]) -> Any @@ -42,7 +39,16 @@ on_message(self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]) Log all messages. -### `StructuredLoggingMiddleware` +### `LoggingMiddleware` + + +Middleware that provides comprehensive request and response logging. + +Logs all MCP messages with configurable detail levels. Useful for debugging, +monitoring, and understanding server usage patterns. + + +### `StructuredLoggingMiddleware` Middleware that provides structured JSON logging for better log analysis. @@ -50,14 +56,3 @@ Middleware that provides structured JSON logging for better log analysis. Outputs structured logs that are easier to parse and analyze with log aggregation tools like ELK stack, Splunk, or cloud logging services. - -**Methods:** - -#### `on_message` - -```python -on_message(self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]) -> Any -``` - -Log structured message information. - diff --git a/docs/python-sdk/fastmcp-server-middleware-middleware.mdx b/docs/python-sdk/fastmcp-server-middleware-middleware.mdx index 806acaecc..a530af4b1 100644 --- a/docs/python-sdk/fastmcp-server-middleware-middleware.mdx +++ b/docs/python-sdk/fastmcp-server-middleware-middleware.mdx @@ -7,7 +7,7 @@ sidebarTitle: middleware ## Functions -### `make_middleware_wrapper` +### `make_middleware_wrapper` ```python make_middleware_wrapper(middleware: Middleware, call_next: CallNext[T, R]) -> CallNext[T, R] @@ -21,9 +21,9 @@ passed to other functions that expect a call_next function. ## Classes -### `CallNext` +### `CallNext` -### `MiddlewareContext` +### `MiddlewareContext` Unified context for all middleware operations. @@ -31,13 +31,13 @@ Unified context for all middleware operations. **Methods:** -#### `copy` +#### `copy` ```python copy(self, **kwargs: Any) -> MiddlewareContext[T] ``` -### `Middleware` +### `Middleware` Base class for FastMCP middleware with dispatching hooks. @@ -45,61 +45,61 @@ Base class for FastMCP middleware with dispatching hooks. **Methods:** -#### `on_message` +#### `on_message` ```python on_message(self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]) -> Any ``` -#### `on_request` +#### `on_request` ```python on_request(self, context: MiddlewareContext[mt.Request], call_next: CallNext[mt.Request, Any]) -> Any ``` -#### `on_notification` +#### `on_notification` ```python on_notification(self, context: MiddlewareContext[mt.Notification], call_next: CallNext[mt.Notification, Any]) -> Any ``` -#### `on_call_tool` +#### `on_call_tool` ```python on_call_tool(self, context: MiddlewareContext[mt.CallToolRequestParams], call_next: CallNext[mt.CallToolRequestParams, ToolResult]) -> ToolResult ``` -#### `on_read_resource` +#### `on_read_resource` ```python -on_read_resource(self, context: MiddlewareContext[mt.ReadResourceRequestParams], call_next: CallNext[mt.ReadResourceRequestParams, mt.ReadResourceResult]) -> mt.ReadResourceResult +on_read_resource(self, context: MiddlewareContext[mt.ReadResourceRequestParams], call_next: CallNext[mt.ReadResourceRequestParams, list[ReadResourceContents]]) -> list[ReadResourceContents] ``` -#### `on_get_prompt` +#### `on_get_prompt` ```python on_get_prompt(self, context: MiddlewareContext[mt.GetPromptRequestParams], call_next: CallNext[mt.GetPromptRequestParams, mt.GetPromptResult]) -> mt.GetPromptResult ``` -#### `on_list_tools` +#### `on_list_tools` ```python on_list_tools(self, context: MiddlewareContext[mt.ListToolsRequest], call_next: CallNext[mt.ListToolsRequest, list[Tool]]) -> list[Tool] ``` -#### `on_list_resources` +#### `on_list_resources` ```python on_list_resources(self, context: MiddlewareContext[mt.ListResourcesRequest], call_next: CallNext[mt.ListResourcesRequest, list[Resource]]) -> list[Resource] ``` -#### `on_list_resource_templates` +#### `on_list_resource_templates` ```python on_list_resource_templates(self, context: MiddlewareContext[mt.ListResourceTemplatesRequest], call_next: CallNext[mt.ListResourceTemplatesRequest, list[ResourceTemplate]]) -> list[ResourceTemplate] ``` -#### `on_list_prompts` +#### `on_list_prompts` ```python on_list_prompts(self, context: MiddlewareContext[mt.ListPromptsRequest], call_next: CallNext[mt.ListPromptsRequest, list[Prompt]]) -> list[Prompt] diff --git a/docs/python-sdk/fastmcp-server-server.mdx b/docs/python-sdk/fastmcp-server-server.mdx index dcaf8e12f..20b58f460 100644 --- a/docs/python-sdk/fastmcp-server-server.mdx +++ b/docs/python-sdk/fastmcp-server-server.mdx @@ -10,7 +10,7 @@ FastMCP - A more ergonomic interface for MCP servers. ## Functions -### `default_lifespan` +### `default_lifespan` ```python default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any] @@ -26,7 +26,7 @@ Default lifespan context manager that does nothing. - An empty context object -### `add_resource_prefix` +### `add_resource_prefix` ```python add_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'path'] | None = None) -> str @@ -64,7 +64,7 @@ add_resource_prefix("resource:///absolute/path", "prefix") - `ValueError`: If the URI doesn't match the expected protocol\://path format -### `remove_resource_prefix` +### `remove_resource_prefix` ```python remove_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'path'] | None = None) -> str @@ -103,7 +103,7 @@ remove_resource_prefix("resource://prefix//absolute/path", "prefix") - `ValueError`: If the URI doesn't match the expected protocol\://path format -### `has_resource_prefix` +### `has_resource_prefix` ```python has_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'path'] | None = None) -> bool @@ -143,28 +143,34 @@ False ## Classes -### `FastMCP` +### `FastMCP` **Methods:** -#### `settings` +#### `settings` ```python settings(self) -> Settings ``` -#### `name` +#### `name` ```python name(self) -> str ``` -#### `instructions` +#### `instructions` ```python instructions(self) -> str | None ``` +#### `instructions` + +```python +instructions(self, value: str | None) -> None +``` + #### `version` ```python @@ -503,8 +509,8 @@ def get_weather(city: str) -> str: return f"Weather for {city}" @server.resource("resource://{city}/weather") -def get_weather_with_context(city: str, ctx: Context) -> str: - ctx.info(f"Fetching weather for {city}") +async def get_weather_with_context(city: str, ctx: Context) -> str: + await ctx.info(f"Fetching weather for {city}") return f"Weather for {city}" @server.resource("resource://{city}/weather") @@ -583,8 +589,8 @@ Decorator to register a prompt. ] @server.prompt() - def analyze_with_context(table_name: str, ctx: Context) -> list[Message]: - ctx.info(f"Analyzing table {table_name}") + async def analyze_with_context(table_name: str, ctx: Context) -> list[Message]: + await ctx.info(f"Analyzing table {table_name}") schema = read_table_schema(table_name) return [ { @@ -595,7 +601,7 @@ Decorator to register a prompt. ] @server.prompt("custom_name") - def analyze_file(path: str) -> list[Message]: + async def analyze_file(path: str) -> list[Message]: content = await read_file(path) return [ { @@ -622,13 +628,17 @@ Decorator to register a prompt. #### `run_stdio_async` ```python -run_stdio_async(self, show_banner: bool = True) -> None +run_stdio_async(self, show_banner: bool = True, log_level: str | None = None) -> None ``` Run the server using stdio transport. +**Args:** +- `show_banner`: Whether to display the server banner +- `log_level`: Log level for the server -#### `run_http_async` + +#### `run_http_async` ```python run_http_async(self, show_banner: bool = True, transport: Literal['http', 'streamable-http', 'sse'] = 'http', host: str | None = None, port: int | None = None, log_level: str | None = None, path: str | None = None, uvicorn_config: dict[str, Any] | None = None, middleware: list[ASGIMiddleware] | None = None, stateless_http: bool | None = None) -> None @@ -647,7 +657,7 @@ Run the server using HTTP transport. - `stateless_http`: Whether to use stateless HTTP (defaults to settings.stateless_http) -#### `run_sse_async` +#### `run_sse_async` ```python run_sse_async(self, host: str | None = None, port: int | None = None, log_level: str | None = None, path: str | None = None, uvicorn_config: dict[str, Any] | None = None) -> None @@ -656,7 +666,7 @@ run_sse_async(self, host: str | None = None, port: int | None = None, log_level: Run the server using SSE transport. -#### `sse_app` +#### `sse_app` ```python sse_app(self, path: str | None = None, message_path: str | None = None, middleware: list[ASGIMiddleware] | None = None) -> StarletteWithLifespan @@ -670,7 +680,7 @@ Create a Starlette app for the SSE server. - `middleware`: A list of middleware to apply to the app -#### `streamable_http_app` +#### `streamable_http_app` ```python streamable_http_app(self, path: str | None = None, middleware: list[ASGIMiddleware] | None = None) -> StarletteWithLifespan @@ -683,7 +693,7 @@ Create a Starlette app for the StreamableHTTP server. - `middleware`: A list of middleware to apply to the app -#### `http_app` +#### `http_app` ```python http_app(self, path: str | None = None, middleware: list[ASGIMiddleware] | None = None, json_response: bool | None = None, stateless_http: bool | None = None, transport: Literal['http', 'streamable-http', 'sse'] = 'http') -> StarletteWithLifespan @@ -700,13 +710,13 @@ Create a Starlette app using the specified HTTP transport. - A Starlette application configured with the specified transport -#### `run_streamable_http_async` +#### `run_streamable_http_async` ```python run_streamable_http_async(self, host: str | None = None, port: int | None = None, log_level: str | None = None, path: str | None = None, uvicorn_config: dict[str, Any] | None = None) -> None ``` -#### `mount` +#### `mount` ```python mount(self, server: FastMCP[LifespanResultT], prefix: str | None = None, as_proxy: bool | None = None) -> None @@ -760,7 +770,7 @@ automatically determined based on whether the server has a custom lifespan - `prompt_separator`: Deprecated. Separator character for prompt names. -#### `import_server` +#### `import_server` ```python import_server(self, server: FastMCP[LifespanResultT], prefix: str | None = None, tool_separator: str | None = None, resource_separator: str | None = None, prompt_separator: str | None = None) -> None @@ -801,7 +811,7 @@ applied using the protocol\://prefix/path format - `prompt_separator`: Deprecated. Separator for prompt names. -#### `from_openapi` +#### `from_openapi` ```python from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, route_maps: list[RouteMap] | list[RouteMapNew] | None = None, route_map_fn: OpenAPIRouteMapFn | OpenAPIRouteMapFnNew | None = None, mcp_component_fn: OpenAPIComponentFn | OpenAPIComponentFnNew | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, **settings: Any) -> FastMCPOpenAPI | FastMCPOpenAPINew @@ -810,7 +820,7 @@ from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, route Create a FastMCP server from an OpenAPI specification. -#### `from_fastapi` +#### `from_fastapi` ```python from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | list[RouteMapNew] | None = None, route_map_fn: OpenAPIRouteMapFn | OpenAPIRouteMapFnNew | None = None, mcp_component_fn: OpenAPIComponentFn | OpenAPIComponentFnNew | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> FastMCPOpenAPI | FastMCPOpenAPINew @@ -819,7 +829,7 @@ from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] Create a FastMCP server from a FastAPI application. -#### `as_proxy` +#### `as_proxy` ```python as_proxy(cls, backend: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -833,7 +843,7 @@ instance or any value accepted as the `transport` argument of `fastmcp.client.Client` constructor. -#### `from_client` +#### `from_client` ```python from_client(cls, client: Client[ClientTransportT], **settings: Any) -> FastMCPProxy @@ -842,10 +852,10 @@ from_client(cls, client: Client[ClientTransportT], **settings: Any) -> FastMCPPr Create a FastMCP proxy server from a FastMCP client. -#### `generate_name` +#### `generate_name` ```python generate_name(cls, name: str | None = None) -> str ``` -### `MountedServer` +### `MountedServer` diff --git a/docs/python-sdk/fastmcp-settings.mdx b/docs/python-sdk/fastmcp-settings.mdx index 185e05ab3..6f14415ec 100644 --- a/docs/python-sdk/fastmcp-settings.mdx +++ b/docs/python-sdk/fastmcp-settings.mdx @@ -7,7 +7,7 @@ sidebarTitle: settings ## Classes -### `ExtendedEnvSettingsSource` +### `ExtendedEnvSettingsSource` A special EnvSettingsSource that allows for multiple env var prefixes to be used. @@ -17,17 +17,17 @@ Raises a deprecation warning if the old `FASTMCP_SERVER_` prefix is used. **Methods:** -#### `get_field_value` +#### `get_field_value` ```python get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool] ``` -### `ExtendedSettingsConfigDict` +### `ExtendedSettingsConfigDict` -### `ExperimentalSettings` +### `ExperimentalSettings` -### `Settings` +### `Settings` FastMCP settings. @@ -35,7 +35,7 @@ FastMCP settings. **Methods:** -#### `get_setting` +#### `get_setting` ```python get_setting(self, attr: str) -> Any @@ -45,7 +45,7 @@ Get a setting. If the setting contains one or more `__`, it will be treated as a nested setting. -#### `set_setting` +#### `set_setting` ```python set_setting(self, attr: str, value: Any) -> None @@ -55,13 +55,13 @@ Set a setting. If the setting contains one or more `__`, it will be treated as a nested setting. -#### `settings_customise_sources` +#### `settings_customise_sources` ```python settings_customise_sources(cls, settings_cls: type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource) -> tuple[PydanticBaseSettingsSource, ...] ``` -#### `settings` +#### `settings` ```python settings(self) -> Self @@ -71,8 +71,14 @@ This property is for backwards compatibility with FastMCP < 2.8.0, which accessed fastmcp.settings.settings -#### `normalize_log_level` +#### `normalize_log_level` ```python normalize_log_level(cls, v) ``` + +#### `server_auth_class` + +```python +server_auth_class(self) -> AuthProvider | None +``` diff --git a/docs/python-sdk/fastmcp-utilities-json_schema.mdx b/docs/python-sdk/fastmcp-utilities-json_schema.mdx index b782a2668..a436dda8e 100644 --- a/docs/python-sdk/fastmcp-utilities-json_schema.mdx +++ b/docs/python-sdk/fastmcp-utilities-json_schema.mdx @@ -7,7 +7,7 @@ sidebarTitle: json_schema ## Functions -### `compress_schema` +### `compress_schema` ```python compress_schema(schema: dict, prune_params: list[str] | None = None, prune_defs: bool = True, prune_additional_properties: bool = True, prune_titles: bool = False) -> dict diff --git a/docs/python-sdk/fastmcp-utilities-logging.mdx b/docs/python-sdk/fastmcp-utilities-logging.mdx index 90564927c..f139db07a 100644 --- a/docs/python-sdk/fastmcp-utilities-logging.mdx +++ b/docs/python-sdk/fastmcp-utilities-logging.mdx @@ -10,7 +10,7 @@ Logging utilities for FastMCP. ## Functions -### `get_logger` +### `get_logger` ```python get_logger(name: str) -> logging.Logger @@ -26,10 +26,10 @@ Get a logger nested under FastMCP namespace. - a configured logger instance -### `configure_logging` +### `configure_logging` ```python -configure_logging(level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] | int = 'INFO', logger: logging.Logger | None = None, enable_rich_tracebacks: bool = True, **rich_kwargs: Any) -> None +configure_logging(level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] | int = 'INFO', logger: logging.Logger | None = None, enable_rich_tracebacks: bool | None = None, **rich_kwargs: Any) -> None ``` @@ -40,3 +40,19 @@ Configure logging for FastMCP. - `level`: the log level to use - `rich_kwargs`: the parameters to use for creating RichHandler + +### `temporary_log_level` + +```python +temporary_log_level(level: str | None, logger: logging.Logger | None = None, enable_rich_tracebacks: bool | None = None, **rich_kwargs: Any) +``` + + +Context manager to temporarily set log level and restore it afterwards. + +**Args:** +- `level`: The temporary log level to set (e.g., "DEBUG", "INFO") +- `logger`: Optional logger to configure (defaults to FastMCP logger) +- `enable_rich_tracebacks`: Whether to enable rich tracebacks +- `**rich_kwargs`: Additional parameters for RichHandler + diff --git a/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config.mdx index 51409f3d7..391759bc0 100644 --- a/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config.mdx +++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config.mdx @@ -15,7 +15,7 @@ command-line arguments. ## Functions -### `generate_schema` +### `generate_schema` ```python generate_schema(output_path: Path | str | None = None) -> dict[str, Any] | None diff --git a/docs/python-sdk/fastmcp-utilities-storage.mdx b/docs/python-sdk/fastmcp-utilities-storage.mdx new file mode 100644 index 000000000..0d0ecd322 --- /dev/null +++ b/docs/python-sdk/fastmcp-utilities-storage.mdx @@ -0,0 +1,158 @@ +--- +title: storage +sidebarTitle: storage +--- + +# `fastmcp.utilities.storage` + + +Key-value storage utilities for persistent data management. + +## Classes + +### `KVStorage` + + +Protocol for key-value storage of JSON data. + + +**Methods:** + +#### `get` + +```python +get(self, key: str) -> dict[str, Any] | None +``` + +Get a JSON dict by key. + + +#### `set` + +```python +set(self, key: str, value: dict[str, Any]) -> None +``` + +Store a JSON dict by key. + + +#### `delete` + +```python +delete(self, key: str) -> None +``` + +Delete a value by key. + + +### `JSONFileStorage` + + +File-based key-value storage for JSON data with automatic metadata tracking. + +Each key-value pair is stored as a separate JSON file on disk. +Keys are sanitized to be filesystem-safe. + +The storage automatically wraps all data with metadata: +- timestamp: Timestamp when the entry was last written + +**Args:** +- `cache_dir`: Directory for storing JSON files + + +**Methods:** + +#### `get` + +```python +get(self, key: str) -> dict[str, Any] | None +``` + +Get a JSON dict from storage by key. + +**Args:** +- `key`: The key to retrieve + +**Returns:** +- The stored dict or None if not found + + +#### `set` + +```python +set(self, key: str, value: dict[str, Any]) -> None +``` + +Store a JSON dict with metadata. + +**Args:** +- `key`: The key to store under +- `value`: The dict to store + + +#### `delete` + +```python +delete(self, key: str) -> None +``` + +Delete a value from storage. + +**Args:** +- `key`: The key to delete + + +#### `cleanup_old_entries` + +```python +cleanup_old_entries(self, max_age_seconds: int = 30 * 24 * 60 * 60) -> int +``` + +Remove entries older than the specified age. + +Uses the timestamp field to determine age. + +**Args:** +- `max_age_seconds`: Maximum age in seconds (default 30 days) + +**Returns:** +- Number of entries removed + + +### `InMemoryStorage` + + +In-memory key-value storage for JSON data. + +Simple dict-based storage that doesn't persist across restarts. +Useful for testing or environments where file storage isn't available. + + +**Methods:** + +#### `get` + +```python +get(self, key: str) -> dict[str, Any] | None +``` + +Get a JSON dict from memory by key. + + +#### `set` + +```python +set(self, key: str, value: dict[str, Any]) -> None +``` + +Store a JSON dict in memory. + + +#### `delete` + +```python +delete(self, key: str) -> None +``` + +Delete a value from memory. + diff --git a/docs/python-sdk/fastmcp-utilities-tests.mdx b/docs/python-sdk/fastmcp-utilities-tests.mdx index ce6d3d761..669755575 100644 --- a/docs/python-sdk/fastmcp-utilities-tests.mdx +++ b/docs/python-sdk/fastmcp-utilities-tests.mdx @@ -43,7 +43,7 @@ not pickleable, so we need a function that creates and runs one. - The server URL. -### `caplog_for_fastmcp` +### `caplog_for_fastmcp` ```python caplog_for_fastmcp(caplog) @@ -55,7 +55,7 @@ Context manager to capture logs from FastMCP loggers even when propagation is di ## Classes -### `HeadlessOAuth` +### `HeadlessOAuth` OAuth provider that bypasses browser interaction for testing. @@ -66,7 +66,7 @@ instead of opening a browser and running a callback server. Useful for automated **Methods:** -#### `redirect_handler` +#### `redirect_handler` ```python redirect_handler(self, authorization_url: str) -> None @@ -75,7 +75,7 @@ redirect_handler(self, authorization_url: str) -> None Make HTTP request to authorization URL and store response for callback handler. -#### `callback_handler` +#### `callback_handler` ```python callback_handler(self) -> tuple[str, str | None] diff --git a/docs/servers/auth/authentication.mdx b/docs/servers/auth/authentication.mdx index 9c75a776c..9b5583002 100644 --- a/docs/servers/auth/authentication.mdx +++ b/docs/servers/auth/authentication.mdx @@ -97,7 +97,7 @@ This example configures token validation against a JWT issuer. The `JWTVerifier` ### RemoteAuthProvider -`RemoteAuthProvider` enables authentication with identity providers that **support Dynamic Client Registration (DCR)**, such as WorkOS AuthKit. With DCR, MCP clients can automatically register themselves with the identity provider and obtain credentials without any manual configuration. +`RemoteAuthProvider` enables authentication with identity providers that **support Dynamic Client Registration (DCR)**, such as Descope and WorkOS AuthKit. With DCR, MCP clients can automatically register themselves with the identity provider and obtain credentials without any manual configuration. This class combines token validation with OAuth discovery metadata. It extends `TokenVerifier` functionality by adding OAuth 2.0 protected resource endpoints that advertise your authentication requirements. MCP clients examine these endpoints to understand which identity providers you trust and how to obtain valid tokens. @@ -127,7 +127,7 @@ This example uses WorkOS AuthKit as the external identity provider. The `AuthKit -`OAuthProxy` enables authentication with OAuth providers that **don't support Dynamic Client Registration (DCR)**, such as GitHub, Google, Azure, and most traditional enterprise identity systems. +`OAuthProxy` enables authentication with OAuth providers that **don't support Dynamic Client Registration (DCR)**, such as GitHub, Google, Azure, AWS, and most traditional enterprise identity systems. When identity providers require manual app registration and fixed credentials, `OAuthProxy` bridges the gap. It presents a DCR-compliant interface to MCP clients (accepting any registration request) while using your pre-registered credentials with the upstream provider. The proxy handles the complexity of callback forwarding, enabling dynamic client callbacks to work with providers that require fixed redirect URIs. @@ -256,9 +256,9 @@ This approach simplifies deployment pipelines and follows twelve-factor app prin The authentication approach you choose depends on your existing infrastructure, security requirements, and operational constraints. -**For OAuth providers without DCR support (GitHub, Google, Azure, most enterprise systems), use OAuth Proxy.** These providers require manual app registration through their developer consoles. OAuth Proxy bridges the gap by presenting a DCR-compliant interface to MCP clients while using your fixed credentials with the provider. The proxy's callback forwarding pattern enables dynamic client ports to work with providers that require fixed redirect URIs. +**For OAuth providers without DCR support (GitHub, Google, Azure, AWS, most enterprise systems), use OAuth Proxy.** These providers require manual app registration through their developer consoles. OAuth Proxy bridges the gap by presenting a DCR-compliant interface to MCP clients while using your fixed credentials with the provider. The proxy's callback forwarding pattern enables dynamic client ports to work with providers that require fixed redirect URIs. -**For identity providers with DCR support (WorkOS AuthKit, modern auth platforms), use RemoteAuthProvider.** These providers allow clients to dynamically register and obtain credentials without manual configuration. This enables the fully automated authentication flow that MCP is designed for, providing the best user experience and simplest implementation. +**For identity providers with DCR support (Descope, WorkOS AuthKit, modern auth platforms), use RemoteAuthProvider.** These providers allow clients to dynamically register and obtain credentials without manual configuration. This enables the fully automated authentication flow that MCP is designed for, providing the best user experience and simplest implementation. **Token validation works well when you already have authentication infrastructure that issues structured tokens.** If your organization already uses JWT-based systems, API gateways, or enterprise SSO that can generate tokens, this approach integrates seamlessly while keeping your MCP server focused on its core functionality. The simplicity comes from leveraging existing investment in authentication infrastructure. diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index 1e31a33e7..a5109ea0a 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -10,12 +10,20 @@ import { VersionBadge } from "/snippets/version-badge.mdx"; -OAuth Proxy enables FastMCP servers to authenticate with OAuth providers that **don't support Dynamic Client Registration (DCR)**. This includes virtually all traditional OAuth providers: GitHub, Google, Azure, Discord, Facebook, and most enterprise identity systems. For providers that do support DCR (like WorkOS AuthKit), use [`RemoteAuthProvider`](/servers/auth/remote-oauth) instead. +OAuth Proxy enables FastMCP servers to authenticate with OAuth providers that **don't support Dynamic Client Registration (DCR)**. This includes virtually all traditional OAuth providers: GitHub, Google, Azure, AWS, Discord, Facebook, and most enterprise identity systems. For providers that do support DCR (like Descope and WorkOS AuthKit), use [`RemoteAuthProvider`](/servers/auth/remote-oauth) instead. MCP clients expect to register automatically and obtain credentials on the fly, but traditional providers require manual app registration through their developer consoles. OAuth Proxy bridges this gap by presenting a DCR-compliant interface to MCP clients while using your pre-registered credentials with the upstream provider. When a client attempts to register, the proxy returns your fixed credentials. When a client initiates authorization, the proxy handles the complexity of callback forwarding—storing the client's dynamic callback URL, using its own fixed callback with the provider, then forwarding back to the client after token exchange. This approach enables any MCP client (whether using random localhost ports or fixed URLs like Claude.ai) to authenticate with any traditional OAuth provider, all while maintaining full OAuth 2.1 and PKCE security. + + For providers that support OIDC discovery (Auth0, Google with OIDC + configuration, Azure AD), consider using [`OIDC + Proxy`](/servers/auth/oidc-proxy) for automatic configuration. OIDC Proxy + extends OAuth Proxy to automatically discover endpoints from the provider's + `/.well-known/openid-configuration` URL, simplifying setup. + + ## Implementation ### Provider Setup Requirements @@ -24,7 +32,7 @@ Before using OAuth Proxy, you need to register your application with your OAuth 1. **Register your application** in the provider's developer console (GitHub Settings, Google Cloud Console, Azure Portal, etc.) 2. **Configure the redirect URI** as your FastMCP server URL plus your chosen callback path: - - Default: `https://your-server.com/auth/callback` + - Default: `https://your-server.com/auth/callback` - Custom: `https://your-server.com/your/custom/path` (if you set `redirect_path`) - Development: `http://localhost:8000/auth/callback` 3. **Obtain your credentials**: Client ID and Client Secret @@ -68,7 +76,7 @@ auth = OAuthProxy( # Your FastMCP server's public URL base_url="https://your-server.com", - + # Optional: customize the callback path (default is "/auth/callback") # redirect_path="/custom/callback", ) @@ -84,7 +92,8 @@ mcp = FastMCP(name="My Server", auth=auth) - URL of your OAuth provider's token endpoint (e.g., `https://github.com/login/oauth/access_token`) + URL of your OAuth provider's token endpoint (e.g., + `https://github.com/login/oauth/access_token`) @@ -96,7 +105,8 @@ mcp = FastMCP(name="My Server", auth=auth) - A [`TokenVerifier`](/servers/auth/token-verification) instance to validate the provider's tokens + A [`TokenVerifier`](/servers/auth/token-verification) instance to validate the + provider's tokens @@ -104,7 +114,8 @@ mcp = FastMCP(name="My Server", auth=auth) - Path for OAuth callbacks. Must match the redirect URI configured in your OAuth application + Path for OAuth callbacks. Must match the redirect URI configured in your OAuth + application @@ -120,32 +131,39 @@ mcp = FastMCP(name="My Server", auth=auth) - Whether to forward PKCE (Proof Key for Code Exchange) to the upstream OAuth provider. When enabled and the client uses PKCE, the proxy generates its own PKCE parameters to send upstream while separately validating the client's PKCE. This ensures end-to-end PKCE security at both layers (client-to-proxy and proxy-to-upstream). - - `True` (default): Forward PKCE for providers that support it (Google, Azure, GitHub, etc.) - - `False`: Disable only if upstream provider doesn't support PKCE + Whether to forward PKCE (Proof Key for Code Exchange) to the upstream OAuth + provider. When enabled and the client uses PKCE, the proxy generates its own + PKCE parameters to send upstream while separately validating the client's + PKCE. This ensures end-to-end PKCE security at both layers (client-to-proxy + and proxy-to-upstream). - `True` (default): Forward PKCE for providers that + support it (Google, Azure, AWS, GitHub, etc.) - `False`: Disable only if upstream + provider doesn't support PKCE - Token endpoint authentication method for the upstream OAuth server. Controls how the proxy authenticates when exchanging authorization codes and refresh tokens with the upstream provider. - - `"client_secret_basic"`: Send credentials in Authorization header (most common) - - `"client_secret_post"`: Send credentials in request body (required by some providers) - - `"none"`: No authentication (for public clients) - - `None` (default): Uses authlib's default (typically `"client_secret_basic"`) - - Set this if your provider requires a specific authentication method and the default doesn't work. + Token endpoint authentication method for the upstream OAuth server. Controls + how the proxy authenticates when exchanging authorization codes and refresh + tokens with the upstream provider. - `"client_secret_basic"`: Send credentials + in Authorization header (most common) - `"client_secret_post"`: Send + credentials in request body (required by some providers) - `"none"`: No + authentication (for public clients) - `None` (default): Uses authlib's default + (typically `"client_secret_basic"`) Set this if your provider requires a + specific authentication method and the default doesn't work. - List of allowed redirect URI patterns for MCP clients. Patterns support wildcards (e.g., `"http://localhost:*"`, `"https://*.example.com/*"`). - - `None` (default): All redirect URIs allowed (for MCP/DCR compatibility) - - Empty list `[]`: No redirect URIs allowed - - Custom list: Only matching patterns allowed - - These patterns apply to MCP client loopback redirects, NOT the upstream OAuth app redirect URI. + List of allowed redirect URI patterns for MCP clients. Patterns support + wildcards (e.g., `"http://localhost:*"`, `"https://*.example.com/*"`). - + `None` (default): All redirect URIs allowed (for MCP/DCR compatibility) - + Empty list `[]`: No redirect URIs allowed - Custom list: Only matching + patterns allowed These patterns apply to MCP client loopback redirects, NOT + the upstream OAuth app redirect URI. - List of all possible valid scopes for the OAuth provider. These are advertised to clients through the `/.well-known` endpoints. Defaults to `required_scopes` from your TokenVerifier if not specified. + List of all possible valid scopes for the OAuth provider. These are advertised + to clients through the `/.well-known` endpoints. Defaults to `required_scopes` + from your TokenVerifier if not specified. @@ -161,13 +179,27 @@ mcp = FastMCP(name="My Server", auth=auth) Additional parameters to forward to the upstream token endpoint during code exchange and token refresh. Useful for provider-specific requirements during token operations. - - For example, some providers require additional context during token exchange: - ```python - extra_token_params={"audience": "https://api.example.com"} - ``` - - These parameters are included in all token requests to the upstream provider. + +For example, some providers require additional context during token exchange: + +```python +extra_token_params={"audience": "https://api.example.com"} +``` + +These parameters are included in all token requests to the upstream provider. + + + + + Storage backend for persisting OAuth client registrations. By default, clients are automatically persisted to disk in `~/.config/fastmcp/oauth-proxy-clients/`, allowing them to survive server restarts as long as the filesystem remains accessible. This means MCP clients only need to register once and can reconnect seamlessly after your server restarts. + +```python +from fastmcp.utilities.storage import InMemoryStorage + +# Use in-memory storage for testing (clients lost on restart) +auth = OAuthProxy(..., client_storage=InMemoryStorage()) +``` + @@ -185,21 +217,21 @@ auth = OAuthProxy( upstream_token_endpoint="https://your-domain.auth0.com/oauth/token", upstream_client_id="your-auth0-client-id", upstream_client_secret="your-auth0-client-secret", - + # Auth0 requires audience for JWT tokens extra_authorize_params={ "audience": "https://your-api-identifier.com" }, extra_token_params={ - "audience": "https://your-api-identifier.com" + "audience": "https://your-api-identifier.com" }, - + token_verifier=JWTVerifier( jwks_uri="https://your-domain.auth0.com/.well-known/jwks.json", issuer="https://your-domain.auth0.com/", audience="https://your-api-identifier.com" ), - + base_url="https://your-server.com" ) ``` diff --git a/docs/servers/auth/oidc-proxy.mdx b/docs/servers/auth/oidc-proxy.mdx index 9b7b29ad7..e08d0c358 100644 --- a/docs/servers/auth/oidc-proxy.mdx +++ b/docs/servers/auth/oidc-proxy.mdx @@ -10,7 +10,7 @@ import { VersionBadge } from "/snippets/version-badge.mdx"; -OIDC Proxy enables FastMCP servers to authenticate with OIDC providers that **don't support Dynamic Client Registration (DCR)** out of the box. This includes OAuth providers like: Auth0, Google, Azure, etc. For providers that do support DCR (like WorkOS AuthKit), use [`RemoteAuthProvider`](/servers/auth/remote-oauth) instead. +OIDC Proxy enables FastMCP servers to authenticate with OIDC providers that **don't support Dynamic Client Registration (DCR)** out of the box. This includes OAuth providers like: Auth0, Google, Azure, AWS, etc. For providers that do support DCR (like WorkOS AuthKit), use [`RemoteAuthProvider`](/servers/auth/remote-oauth) instead. The OIDC Proxy is built upon [`OAuthProxy`](/servers/auth/oauth-proxy) so it has all the same functionality under the covers. @@ -79,28 +79,33 @@ mcp = FastMCP(name="My Server", auth=auth) Public URL of your FastMCP server (e.g., `https://your-server.com`) - - Strict flag for configuration validation + + Strict flag for configuration validation. When True, requires all OIDC + mandatory fields. - - Audience from your registered OAuth application + + Audience parameter for OIDC providers that require it (e.g., Auth0). This is + typically your API identifier. - - HTTP request timeout in seconds + + HTTP request timeout in seconds for fetching OIDC configuration - - The algorithm for the token verifier + + JWT algorithm to use for token verification (e.g., "RS256"). If not specified, + uses the provider's default. - - The required scopes for the token verifier + + List of OAuth scopes to request from the provider. These are automatically + included in authorization requests. - Path for OAuth callbacks. Must match the redirect URI configured in your OAuth application + Path for OAuth callbacks. Must match the redirect URI configured in your OAuth + application @@ -109,7 +114,8 @@ mcp = FastMCP(name="My Server", auth=auth) - Empty list `[]`: No redirect URIs allowed - Custom list: Only matching patterns allowed - These patterns apply to MCP client loopback redirects, NOT the upstream OAuth app redirect URI. +These patterns apply to MCP client loopback redirects, NOT the upstream OAuth app redirect URI. + @@ -119,7 +125,20 @@ mcp = FastMCP(name="My Server", auth=auth) - `"none"`: No authentication (for public clients) - `None` (default): Uses authlib's default (typically `"client_secret_basic"`) - Set this if your provider requires a specific authentication method and the default doesn't work. +Set this if your provider requires a specific authentication method and the default doesn't work. + + + + + Storage backend for persisting OAuth client registrations. By default, clients are automatically persisted to disk in `~/.config/fastmcp/oidc-proxy-clients/`, allowing them to survive server restarts as long as the filesystem remains accessible. This means MCP clients only need to register once and can reconnect seamlessly after your server restarts. + +```python +from fastmcp.utilities.storage import InMemoryStorage + +# Use in-memory storage for testing (clients lost on restart) +auth = OIDCProxy(..., client_storage=InMemoryStorage()) +``` + diff --git a/docs/servers/auth/remote-oauth.mdx b/docs/servers/auth/remote-oauth.mdx index 99d5b3121..b01aef315 100644 --- a/docs/servers/auth/remote-oauth.mdx +++ b/docs/servers/auth/remote-oauth.mdx @@ -1,7 +1,7 @@ --- title: Remote OAuth sidebarTitle: Remote OAuth -description: Integrate your FastMCP server with external identity providers like WorkOS, Auth0, and corporate SSO systems. +description: Integrate your FastMCP server with external identity providers like Descope, WorkOS, Auth0, and corporate SSO systems. icon: camera-cctv tag: NEW --- @@ -14,8 +14,8 @@ Remote OAuth integration allows your FastMCP server to leverage external identit **When to use RemoteAuthProvider vs OAuth Proxy:** -- **RemoteAuthProvider**: For providers WITH Dynamic Client Registration (WorkOS AuthKit, modern OIDC providers) -- **OAuth Proxy**: For providers WITHOUT Dynamic Client Registration (GitHub, Google, Azure, Discord, etc.) +- **RemoteAuthProvider**: For providers WITH Dynamic Client Registration (Descope, WorkOS AuthKit, modern OIDC providers) +- **OAuth Proxy**: For providers WITHOUT Dynamic Client Registration (GitHub, Google, Azure, AWS, Discord, etc.) RemoteAuthProvider requires DCR support for fully automated client registration and authentication. @@ -29,7 +29,7 @@ RemoteAuthProvider works with identity providers that support **Dynamic Client R | **Client Registration** | Automatic via API | Manual in provider console | | **Credentials** | Dynamic per client | Fixed app credentials | | **Configuration** | Zero client config | Pre-shared credentials | -| **Examples** | WorkOS AuthKit, modern OIDC | GitHub, Google, Azure | +| **Examples** | Descope, WorkOS AuthKit, modern OIDC | GitHub, Google, Azure | | **FastMCP Class** | `RemoteAuthProvider` | [`OAuthProxy`](/servers/auth/oauth-proxy) | If your provider doesn't support DCR (most traditional OAuth providers), you'll need to use [`OAuth Proxy`](/servers/auth/oauth-proxy) instead, which bridges the gap between MCP's DCR expectations and fixed OAuth credentials. diff --git a/docs/servers/composition.mdx b/docs/servers/composition.mdx index 370f072de..63ac1f773 100644 --- a/docs/servers/composition.mdx +++ b/docs/servers/composition.mdx @@ -41,6 +41,8 @@ FastMCP supports [MCP proxying](/servers/proxy), which allows you to mirror a lo You can also create proxies from configuration dictionaries that follow the MCPConfig schema, which is useful for quickly connecting to one or more remote servers. See the [Proxy Servers documentation](/servers/proxy#configuration-based-proxies) for details on configuration-based proxying. Note that MCPConfig follows an emerging standard and its format may evolve over time. +Prefixing rules for tools, prompts, resources, and templates are identical across importing, mounting, and proxies. + ## Importing (Static Composition) The `import_server()` method copies all components (tools, resources, templates, prompts) from one `FastMCP` instance (the *subserver*) into another (the *main server*). An optional `prefix` can be provided to avoid naming conflicts. If no prefix is provided, components are imported without modification. When multiple servers are imported with the same prefix (or no prefix), the most recently imported server's components take precedence. diff --git a/docs/servers/middleware.mdx b/docs/servers/middleware.mdx index 5c06db0f8..4919462b5 100644 --- a/docs/servers/middleware.mdx +++ b/docs/servers/middleware.mdx @@ -89,10 +89,12 @@ Note that the MCP SDK may perform additional operations like listing tools for c This hierarchy allows you to target your middleware logic with the right level of specificity. Use `on_message` for broad concerns like logging, `on_request` for authentication, and `on_call_tool` for tool-specific logic like performance monitoring. ### Available Hooks + - `on_message`: Called for all MCP messages (requests and notifications) - `on_request`: Called specifically for MCP requests (that expect responses) - `on_notification`: Called specifically for MCP notifications (fire-and-forget) + - `on_call_tool`: Called when tools are being executed - `on_read_resource`: Called when resources are being read - `on_get_prompt`: Called when prompts are being retrieved @@ -100,6 +102,11 @@ This hierarchy allows you to target your middleware logic with the right level o - `on_list_resources`: Called when listing available resources - `on_list_resource_templates`: Called when listing resource templates - `on_list_prompts`: Called when listing available prompts + +- `on_initialize`: Called when a client connects and initializes the session (returns `None`) + +The `on_initialize` hook receives the client's initialization request but **returns `None`** rather than a result. The initialization response is handled internally by the MCP protocol and cannot be modified by middleware. This hook is useful for client detection, logging connections, or initializing session state, but not for modifying the initialization handshake itself. + ## Component Access in Middleware diff --git a/docs/servers/proxy.mdx b/docs/servers/proxy.mdx index 82daccb5d..7cbb6d90c 100644 --- a/docs/servers/proxy.mdx +++ b/docs/servers/proxy.mdx @@ -245,11 +245,29 @@ config = { # Create a unified proxy to multiple servers composite_proxy = FastMCP.as_proxy(config, name="Composite Proxy") -# Tools and resources are accessible with prefixes: -# - weather_get_forecast, calendar_add_event -# - weather://weather/icons/sunny, calendar://calendar/events/today +# Tools, resources, prompts, and templates are accessible with prefixes: +# - Tools: weather_get_forecast, calendar_add_event +# - Prompts: weather_daily_summary, calendar_quick_add +# - Resources: weather://weather/icons/sunny, calendar://calendar/events/today +# - Templates: weather://weather/locations/{id}, calendar://calendar/events/{date} ``` +## Component Prefixing + +When proxying one or more servers, component names are prefixed the same way as with mounting and importing: + +- Tools: `{prefix}_{tool_name}` +- Prompts: `{prefix}_{prompt_name}` +- Resources: `protocol://{prefix}/path/to/resource` (default path format) +- Resource templates: `protocol://{prefix}/...` and template names are also prefixed + +These rules apply uniformly whether you: +- Mount a proxy on another server +- Create a multi-server proxy from an `MCPConfig` +- Use `FastMCP.as_proxy()` directly + +For resource URI prefix formats (path vs legacy protocol style) and configuration options, see Server Composition → Resource Prefix Formats. + ## Mirrored Components @@ -332,4 +350,3 @@ def custom_client_factory(): proxy = FastMCPProxy(client_factory=custom_client_factory) ``` - diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx index 1da6b088d..60d03a6c4 100644 --- a/docs/servers/resources.mdx +++ b/docs/servers/resources.mdx @@ -412,15 +412,15 @@ With these two templates defined, clients can request a variety of resources: - `repos://jlowin/fastmcp/info` → Returns info about the jlowin/fastmcp repository - `repos://prefecthq/prefect/info` → Returns info about the prefecthq/prefect repository -### Wildcard Parameters +### RFC 6570 URI Templates + + +FastMCP implements [RFC 6570 URI Templates](https://datatracker.ietf.org/doc/html/rfc6570) for resource templates, providing a standardized way to define parameterized URIs. This includes support for simple expansion, wildcard path parameters, and form-style query parameters. + +#### Wildcard Parameters - -Please note: FastMCP's support for wildcard parameters is an **extension** of the Model Context Protocol standard, which otherwise follows RFC 6570. Since all template processing happens in the FastMCP server, this should not cause any compatibility issues with other MCP implementations. - - - Resource templates support wildcard parameters that can match multiple path segments. While standard parameters (`{param}`) only match a single path segment and don't cross "/" boundaries, wildcard parameters (`{param*}`) can capture multiple segments including slashes. Wildcards capture all subsequent path segments *up until* the defined part of the URI template (whether literal or another parameter). This allows you to have multiple wildcard parameters in a single URI template. ```python {15, 23} @@ -448,7 +448,7 @@ def get_path_content(filepath: str) -> str: # Mixing standard and wildcard parameters @mcp.resource("repo://{owner}/{path*}/template.py") def get_template_file(owner: str, path: str) -> dict: - """Retrieves a file from a specific repository and path, but + """Retrieves a file from a specific repository and path, but only if the resource ends with `template.py`""" # Can match repo://jlowin/fastmcp/src/resources/template.py return { @@ -466,43 +466,88 @@ Wildcard parameters are useful when: Note that like regular parameters, each wildcard parameter must still be a named parameter in your function signature, and all required function parameters must appear in the URI template. -### Default Values +#### Query Parameters - + -When creating resource templates, FastMCP enforces two rules for the relationship between URI template parameters and function parameters: +FastMCP supports RFC 6570 form-style query parameters using the `{?param1,param2}` syntax. Query parameters provide a clean way to pass optional configuration to resources without cluttering the path. -1. **Required Function Parameters:** All function parameters without default values (required parameters) must appear in the URI template. -2. **URI Parameters:** All URI template parameters must exist as function parameters. - -However, function parameters with default values don't need to be included in the URI template. When a client requests a resource, FastMCP will: - -- Extract parameter values from the URI for parameters included in the template -- Use default values for any function parameters not in the URI template - -This allows for flexible API designs. For example, a simple search template with optional parameters: +Query parameters must be optional function parameters (have default values), while path parameters map to required function parameters. This enforces a clear separation: required data goes in the path, optional configuration in query params. ```python from fastmcp import FastMCP mcp = FastMCP(name="DataServer") -@mcp.resource("search://{query}") -def search_resources(query: str, max_results: int = 10, include_archived: bool = False) -> dict: - """Search for resources matching the query string.""" - # Only 'query' is required in the URI, the other parameters use their defaults - results = perform_search(query, limit=max_results, archived=include_archived) +# Basic query parameters +@mcp.resource("data://{id}{?format}") +def get_data(id: str, format: str = "json") -> str: + """Retrieve data in specified format.""" + if format == "xml": + return f"" + return f'{{"id": "{id}"}}' + +# Multiple query parameters with type coercion +@mcp.resource("api://{endpoint}{?version,limit,offset}") +def call_api(endpoint: str, version: int = 1, limit: int = 10, offset: int = 0) -> dict: + """Call API endpoint with pagination.""" return { - "query": query, - "max_results": max_results, - "include_archived": include_archived, - "results": results + "endpoint": endpoint, + "version": version, + "limit": limit, + "offset": offset, + "results": fetch_results(endpoint, version, limit, offset) } + +# Query parameters with wildcards +@mcp.resource("files://{path*}{?encoding,lines}") +def read_file(path: str, encoding: str = "utf-8", lines: int = 100) -> str: + """Read file with optional encoding and line limit.""" + return read_file_content(path, encoding, lines) ``` -With this template, clients can request `search://python` and the function will be called with `query="python", max_results=10, include_archived=False`. MCP Developers can still call the underlying `search_resources` function directly with more specific parameters. +**Example requests:** +- `data://123` → Uses default format `"json"` +- `data://123?format=xml` → Uses format `"xml"` +- `api://users?version=2&limit=50` → `version=2, limit=50, offset=0` +- `files://src/main.py?encoding=ascii&lines=50` → Custom encoding and line limit -You can also create multiple resource templates that provide different ways to access the same underlying data by manually applying decorators to a single function: +FastMCP automatically coerces query parameter string values to the correct types based on your function's type hints (`int`, `float`, `bool`, `str`). + +**Query parameters vs. hidden defaults:** + +Query parameters expose optional configuration to clients. To hide optional parameters from clients entirely (always use defaults), simply omit them from the URI template: + +```python +# Clients CAN override max_results via query string +@mcp.resource("search://{query}{?max_results}") +def search_configurable(query: str, max_results: int = 10) -> dict: + return {"query": query, "limit": max_results} + +# Clients CANNOT override max_results (not in URI template) +@mcp.resource("search://{query}") +def search_fixed(query: str, max_results: int = 10) -> dict: + return {"query": query, "limit": max_results} +``` + +### Template Parameter Rules + + + +FastMCP enforces these validation rules when creating resource templates: + +1. **Required function parameters** (no default values) must appear in the URI path template +2. **Query parameters** (specified with `{?param}` syntax) must be optional function parameters with default values +3. **All URI template parameters** (path and query) must exist as function parameters + +Optional function parameters (those with default values) can be: +- Included as query parameters (`{?param}`) - clients can override via query string +- Omitted from URI template - always uses default value, not exposed to clients +- Used in alternative path templates - enables multiple ways to access the same resource + +**Multiple templates for one function:** + +Create multiple resource templates that expose the same function through different URI patterns by manually applying decorators: ```python from fastmcp import FastMCP diff --git a/docs/servers/server.mdx b/docs/servers/server.mdx index cb27d80ac..1fc903659 100644 --- a/docs/servers/server.mdx +++ b/docs/servers/server.mdx @@ -344,6 +344,7 @@ Common global settings include: - **`mask_error_details`**: Whether to hide detailed error information from clients, set with `FASTMCP_MASK_ERROR_DETAILS` - **`resource_prefix_format`**: How to format resource prefixes ("path" or "protocol"), set with `FASTMCP_RESOURCE_PREFIX_FORMAT` - **`include_fastmcp_meta`**: Whether to include FastMCP metadata in component responses (default: True), set with `FASTMCP_INCLUDE_FASTMCP_META` +- **`env_file`**: Path to the environment file to load settings from (default: ".env"), set with `FASTMCP_ENV_FILE`. Useful when your project uses a `.env` file with syntax incompatible with python-dotenv ### Transport-Specific Configuration diff --git a/docs/updates.mdx b/docs/updates.mdx index 78888ecfe..d8862e20e 100644 --- a/docs/updates.mdx +++ b/docs/updates.mdx @@ -5,7 +5,53 @@ icon: "sparkles" tag: NEW --- - + + +FastMCP 2.12.4 adds comprehensive OIDC support and expands authentication options with AWS Cognito and Descope providers. The release also includes improvements to logging middleware, URL handling for nested resources, persistent OAuth client registration storage, and various fixes to the experimental OpenAPI parser. + +🔐 **OIDC Configuration** brings native support for OpenID Connect, enabling seamless integration with enterprise identity providers. + +🏢 **Enterprise Authentication** expands with AWS Cognito and Descope providers, broadening the authentication ecosystem. + +🛠️ **Improved Reliability** through enhanced URL handling, persistent OAuth storage, and numerous parser fixes based on community feedback. + + + + + +FastMCP 2.12.3 focuses on performance and developer experience improvements. This release includes optimized auth provider imports that reduce server startup time, enhanced OIDC authentication flows, and automatic inline snapshot creation for testing. + + + + + +Hotfix for streamable-http transport validation in fastmcp.json configuration files, resolving a parsing error when CLI arguments were merged against the configuration spec. + + + + + +FastMCP 2.12.1 strengthens OAuth proxy implementation with improved client storage reliability, PKCE forwarding, configurable token endpoint authentication methods, and expanded scope handling based on extensive community testing. + + + + - + - + str: + """Echo the provided message.""" + return message + + +if __name__ == "__main__": + mcp.run(transport="http", port=8000) diff --git a/examples/auth/aws_oauth/README.md b/examples/auth/aws_oauth/README.md new file mode 100644 index 000000000..9abff838c --- /dev/null +++ b/examples/auth/aws_oauth/README.md @@ -0,0 +1,47 @@ +# AWS Cognito OAuth Example + +Demonstrates FastMCP server protection with AWS Cognito OAuth. + +## Setup + +1. Create an AWS Cognito User Pool and App Client: + - Go to [AWS Cognito Console](https://console.aws.amazon.com/cognito/) + - Create a new User Pool or use an existing one + - Create an App Client in your User Pool + - Configure the App Client settings: + - Enable "Authorization code grant" flow + - Add Callback URL: `http://localhost:8000/auth/callback` + - Configure OAuth scopes (at minimum: `openid`) + - Note your User Pool ID, App Client ID, Client Secret, and Cognito Domain Prefix + +2. Set environment variables: + + ```bash + export FASTMCP_SERVER_AUTH_AWS_COGNITO_USER_POOL_ID="your-user-pool-id" + export FASTMCP_SERVER_AUTH_AWS_COGNITO_AWS_REGION="your-aws-region" + export FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_ID="your-app-client-id" + export FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_SECRET="your-app-client-secret" + ``` + + Or create a `.env` file: + + ```env + FASTMCP_SERVER_AUTH_AWS_COGNITO_USER_POOL_ID=your-user-pool-id + FASTMCP_SERVER_AUTH_AWS_COGNITO_AWS_REGION=your-aws-region + FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_ID=your-app-client-id + FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_SECRET=your-app-client-secret + ``` + +3. Run the server: + + ```bash + python server.py + ``` + +4. In another terminal, run the client: + + ```bash + python client.py + ``` + +The client will open your browser for AWS Cognito authentication. diff --git a/examples/auth/aws_oauth/client.py b/examples/auth/aws_oauth/client.py new file mode 100644 index 000000000..4043e6d4f --- /dev/null +++ b/examples/auth/aws_oauth/client.py @@ -0,0 +1,42 @@ +"""OAuth client example for connecting to FastMCP servers. + +This example demonstrates how to connect to an OAuth-protected FastMCP server. + +To run: + python client.py +""" + +import asyncio + +from fastmcp.client import Client + +SERVER_URL = "http://localhost:8000/mcp" + + +async def main(): + try: + async with Client(SERVER_URL, auth="oauth") as client: + assert await client.ping() + print("✅ Successfully authenticated!") + + tools = await client.list_tools() + print(f"🔧 Available tools ({len(tools)}):") + for tool in tools: + print(f" - {tool.name}: {tool.description}") + + # Test the protected tool + print("🔒 Calling protected tool: get_access_token_claims") + result = await client.call_tool("get_access_token_claims") + user_data = result.data + print("📄 Available access token claims:") + print(f" - sub: {user_data.get('sub', 'N/A')}") + print(f" - username: {user_data.get('username', 'N/A')}") + print(f" - cognito:groups: {user_data.get('cognito:groups', [])}") + + except Exception as e: + print(f"❌ Authentication failed: {e}") + raise + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/auth/aws_oauth/requirements.txt b/examples/auth/aws_oauth/requirements.txt new file mode 100644 index 000000000..9c7f15cd1 --- /dev/null +++ b/examples/auth/aws_oauth/requirements.txt @@ -0,0 +1,2 @@ +fastmcp +python-dotenv \ No newline at end of file diff --git a/examples/auth/aws_oauth/server.py b/examples/auth/aws_oauth/server.py new file mode 100644 index 000000000..dfe596a83 --- /dev/null +++ b/examples/auth/aws_oauth/server.py @@ -0,0 +1,59 @@ +"""AWS Cognito OAuth server example for FastMCP. + +This example demonstrates how to protect a FastMCP server with AWS Cognito. + +Required environment variables: +- FASTMCP_SERVER_AUTH_AWS_COGNITO_USER_POOL_ID: Your AWS Cognito User Pool ID +- FASTMCP_SERVER_AUTH_AWS_COGNITO_AWS_REGION: Your AWS region (optional, defaults to eu-central-1) +- FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_ID: Your Cognito app client ID +- FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_SECRET: Your Cognito app client secret + +To run: + python server.py +""" + +import logging +import os + +from dotenv import load_dotenv + +from fastmcp import FastMCP +from fastmcp.server.auth.providers.aws import AWSCognitoProvider +from fastmcp.server.dependencies import get_access_token + +logging.basicConfig(level=logging.DEBUG) + +load_dotenv(".env", override=True) + +auth = AWSCognitoProvider( + user_pool_id=os.getenv("FASTMCP_SERVER_AUTH_AWS_COGNITO_USER_POOL_ID") or "", + aws_region=os.getenv("FASTMCP_SERVER_AUTH_AWS_COGNITO_AWS_REGION") + or "eu-central-1", + client_id=os.getenv("FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_ID") or "", + client_secret=os.getenv("FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_SECRET") or "", + base_url="http://localhost:8000", + # redirect_path="/custom/callback" +) + +mcp = FastMCP("AWS Cognito OAuth Example Server", auth=auth) + + +@mcp.tool +def echo(message: str) -> str: + """Echo the provided message.""" + return message + + +@mcp.tool +async def get_access_token_claims() -> dict: + """Get the authenticated user's access token claims.""" + token = get_access_token() + return { + "sub": token.claims.get("sub"), + "username": token.claims.get("username"), + "cognito:groups": token.claims.get("cognito:groups", []), + } + + +if __name__ == "__main__": + mcp.run(transport="http", port=8000) diff --git a/examples/auth/scalekit_oauth/README.md b/examples/auth/scalekit_oauth/README.md new file mode 100644 index 000000000..63c555244 --- /dev/null +++ b/examples/auth/scalekit_oauth/README.md @@ -0,0 +1,54 @@ +# Scalekit OAuth Example + +Demonstrates FastMCP server protection with Scalekit OAuth. + +## Setup + +### 1. Configure MCP server in Scalekit environment + +**Create a Scalekit Account**: + +- Go to [Scalekit Dashboard](https://app.scalekit.com/) +- Navigate to **Developers** → **Settings** +- Copy your Environment URL, Client ID, and Client Secret + +**Register Your MCP Server**: + +- Go to **MCP Servers** → **Create New Server** +- Fill in your MCP server details +- Note the **Resource ID** (e.g., `res_123`) + +Create a `.env` file: + +```bash +# Required Scalekit credentials +SCALEKIT_ENVIRONMENT_URL= +SCALEKIT_CLIENT_ID= # skc_7008EXAMPLE46 +SCALEKIT_RESOURCE_ID= # res_926EXAMPLE5878 +MCP_URL=http://localhost:8000/mcp +``` + +### 2. Run the Example + +Start the server: + +```bash +# From this directory +uv run python server.py +``` + +The server will start on `http://localhost:8000/mcp` with Scalekit OAuth authentication enabled. + +Test with client: + +```bash +uv run python client.py +``` + +The `client.py` will: + +1. Attempt to connect to the server +2. Detect that OAuth authentication is required +3. Open a browser for Scalekit authentication +4. Complete the OAuth flow and connect to the server +5. Demonstrate calling authenticated tools diff --git a/examples/auth/scalekit_oauth/client.py b/examples/auth/scalekit_oauth/client.py new file mode 100644 index 000000000..4146b2c49 --- /dev/null +++ b/examples/auth/scalekit_oauth/client.py @@ -0,0 +1,41 @@ +"""OAuth client example for connecting to Scalekit-protected FastMCP servers. + +This example demonstrates how to connect to a Scalekit OAuth-protected FastMCP server. + +To run: + python client.py +""" + +import asyncio + +from fastmcp.client import Client + +SERVER_URL = "http://127.0.0.1:8000/mcp" + + +async def main(): + try: + async with Client(SERVER_URL, auth="oauth") as client: + assert await client.ping() + print("✅ Successfully authenticated with Scalekit!") + + tools = await client.list_tools() + print(f"🔧 Available tools ({len(tools)}):") + for tool in tools: + print(f" - {tool.name}: {tool.description}") + + # Test calling a tool + result = await client.call_tool("echo", {"message": "Hello from Scalekit!"}) + print(f"🎯 Echo result: {result}") + + # Test calling auth status tool + auth_status = await client.call_tool("auth_status", {}) + print(f"👤 Auth status: {auth_status}") + + except Exception as e: + print(f"❌ Authentication failed: {e}") + raise + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/auth/scalekit_oauth/server.py b/examples/auth/scalekit_oauth/server.py new file mode 100644 index 000000000..40da711e2 --- /dev/null +++ b/examples/auth/scalekit_oauth/server.py @@ -0,0 +1,48 @@ +"""Scalekit OAuth server example for FastMCP. + +This example demonstrates how to protect a FastMCP server with Scalekit OAuth. + +Required environment variables: +- SCALEKIT_ENVIRONMENT_URL: Your Scalekit environment URL (e.g., "https://your-env.scalekit.com") +- SCALEKIT_CLIENT_ID: Your Scalekit OAuth application client ID +- SCALEKIT_RESOURCE_ID: Your Scalekit resource ID + +To run: + python server.py +""" + +import os + +from fastmcp import FastMCP +from fastmcp.server.auth.providers.scalekit import ScalekitProvider + +auth = ScalekitProvider( + environment_url=os.getenv("SCALEKIT_ENVIRONMENT_URL") + or "https://your-env.scalekit.com", + client_id=os.getenv("SCALEKIT_CLIENT_ID") or "", + resource_id=os.getenv("SCALEKIT_RESOURCE_ID") or "", + mcp_url=os.getenv("MCP_URL", "http://localhost:8000/mcp"), +) + +mcp = FastMCP("Scalekit OAuth Example Server", auth=auth) + + +@mcp.tool +def echo(message: str) -> str: + """Echo the provided message.""" + return message + + +@mcp.tool +def auth_status() -> dict: + """Show Scalekit authentication status.""" + # In a real implementation, you would extract user info from the JWT token + return { + "message": "This tool requires authentication via Scalekit", + "authenticated": True, + "provider": "Scalekit", + } + + +if __name__ == "__main__": + mcp.run(transport="http", port=8000) diff --git a/examples/auth/workos_oauth/README.md b/examples/auth/workos_oauth/README.md index 359e5b904..62afa8df2 100644 --- a/examples/auth/workos_oauth/README.md +++ b/examples/auth/workos_oauth/README.md @@ -1,159 +1,27 @@ # WorkOS OAuth Example -This example demonstrates how to use the WorkOS OAuth provider with FastMCP servers. - -## Overview - -The WorkOS OAuth provider enables authentication using WorkOS User Management. It provides general OAuth2 authentication similar to GitHub or Google, with optional support for enterprise SSO connections. Unlike the AuthKit provider which uses DCR (Dynamic Client Registration), this provider works with traditional OAuth flows. +Demonstrates FastMCP server protection with WorkOS OAuth. ## Setup -### 1. WorkOS Configuration +1. Create a WorkOS application and copy your credentials: -1. **Create a WorkOS Application**: - - Go to [WorkOS Dashboard → Applications](https://dashboard.workos.com/applications) - - Create a new application or use an existing one - - Enable **User Management** for OAuth authentication - - Copy your `Client ID` and `API Key` (client secret) + ```bash + export WORKOS_CLIENT_ID="your-client-id" + export WORKOS_CLIENT_SECRET="your-client-secret" + export WORKOS_AUTHKIT_DOMAIN="https://your-app.authkit.app" + ``` -2. **Configure SSO Connection** (optional for enterprise SSO): - - Go to WorkOS Dashboard → Connections - - Set up your SSO connection (SAML, OIDC, or OAuth provider like Google/Microsoft) - - Note the `Organization ID` or `Connection ID` if using SSO +2. Run the server: -3. **Set Redirect URLs**: - - In your WorkOS application settings, add redirect URLs for your OAuth flow - - For this example: `http://localhost:8000/auth/callback` + ```bash + python server.py + ``` -### 2. Environment Variables +3. In another terminal, run the client: -Create a `.env` file in this directory: + ```bash + python client.py + ``` -```bash -# Required WorkOS credentials -WORKOS_CLIENT_ID=client_123 -WORKOS_API_KEY=sk_test_456 # Your WorkOS API key (client secret) - -# Server URL (optional, defaults to http://localhost:8000) -# WORKOS_BASE_URL=http://localhost:8000 - -# Optional: For enterprise SSO connections -# WORKOS_ORGANIZATION_ID=org_123 # Route to specific organization's SSO -# WORKOS_CONNECTION_ID=conn_456 # Route to specific SSO connection - -# Optional: Required scopes -# FASTMCP_SERVER_AUTH_WORKOS_REQUIRED_SCOPES=["profile", "email"] -``` - -### 3. Install Dependencies - -```bash -cd /Users/jlowin/Developer/fastmcp -uv sync -``` - -## Running the Example - -### Start the Server - -```bash -# From this directory -uv run python server.py -``` - -The server will start on `http://localhost:8000` with WorkOS OAuth authentication enabled. - -### Test with Client - -In another terminal: - -```bash -# From this directory -uv run python client.py -``` - -The client will: -1. Attempt to connect to the server -2. Detect that OAuth authentication is required -3. Open a browser for WorkOS authentication -4. Complete the OAuth flow and connect to the server -5. Demonstrate calling authenticated tools - -## How It Works - -### Authentication Flow - -1. **Client Request**: Client attempts to connect to FastMCP server -2. **Auth Challenge**: Server responds with `401 Unauthorized` and `WWW-Authenticate` header -3. **OAuth Discovery**: Client discovers OAuth endpoints from server metadata -4. **Authorization**: Client redirects user to WorkOS for authentication -5. **Callback**: WorkOS redirects back with authorization code -6. **Token Exchange**: Client exchanges code for access token -7. **API Calls**: Client uses access token for authenticated MCP requests - -### Server Components - -- **WorkOSProvider**: Validates tokens using WorkOS User Management API -- **Protected Resources**: MCP tools and resources require valid WorkOS tokens -- **OAuth Metadata**: Server advertises WorkOS as authorization server - -### Client Components - -- **OAuth Client**: Handles browser-based OAuth flow -- **Token Storage**: Caches tokens for future use -- **Automatic Auth**: Transparently handles authentication - -## Key Features - -- **SSO Integration**: Works with any WorkOS SSO connection -- **User Management**: Validates tokens against WorkOS User Management API -- **Token Caching**: Reuses tokens across sessions -- **Error Handling**: Graceful handling of auth failures and token expiration - -## Troubleshooting - -### Common Issues - -1. **"Invalid client" error**: Check CLIENT_ID and CLIENT_SECRET -2. **"Token validation failed"**: Check API_KEY and token scope -3. **"Redirect URI mismatch"**: Ensure redirect URL matches WorkOS settings -4. **Browser doesn't open**: Check firewall settings for localhost - -### Debug Mode - -Enable debug logging: - -```python -import logging -logging.basicConfig(level=logging.DEBUG) -``` - -### Token Inspection - -Check cached tokens: - -```bash -ls ~/.fastmcp/oauth-mcp-client-cache/ -``` - -Clear token cache: - -```python -from fastmcp.client.auth.oauth import FileTokenStorage -FileTokenStorage.clear_all() -``` - -## Security Notes - -- Never commit `.env` files with real credentials -- Use HTTPS in production -- Rotate API keys regularly -- Monitor WorkOS logs for unusual activity -- Set appropriate token expiration times - -## Next Steps - -- Explore WorkOS Directory Sync for user provisioning -- Set up multi-organization support -- Implement role-based access control -- Add custom scopes and claims validation \ No newline at end of file +The client will open your browser for WorkOS authentication. diff --git a/examples/mount_example.py b/examples/mount_example.py index 761689783..5e42b67a1 100644 --- a/examples/mount_example.py +++ b/examples/mount_example.py @@ -104,7 +104,7 @@ async def get_server_details(): print(f" - Imported from news app: {news_resources}") # Let's try to access resources using the prefixed URI - weather_data = await app._mcp_read_resource(uri="weather://weather/forecast") + weather_data = await app._read_resource_mcp(uri="weather://weather/forecast") print(f"\nWeather data from prefixed URI: {weather_data}") diff --git a/examples/serializer.py b/examples/serializer.py index 4ee5ca17a..ffd8ee117 100644 --- a/examples/serializer.py +++ b/examples/serializer.py @@ -21,7 +21,7 @@ def get_example_data() -> dict: async def example_usage(): - result = await server._mcp_call_tool("get_example_data", {}) + result = await server._call_tool_mcp("get_example_data", {}) print("Tool Result:") print(result) print("This is an example of using a custom serializer with FastMCP.") diff --git a/examples/smart_home/uv.lock b/examples/smart_home/uv.lock deleted file mode 100644 index 78a51efef..000000000 --- a/examples/smart_home/uv.lock +++ /dev/null @@ -1,657 +0,0 @@ -version = 1 -revision = 1 -requires-python = ">=3.12" - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, -] - -[[package]] -name = "anyio" -version = "4.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, -] - -[[package]] -name = "asttokens" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918 }, -] - -[[package]] -name = "certifi" -version = "2025.1.31" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, -] - -[[package]] -name = "click" -version = "8.1.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, -] - -[[package]] -name = "decorator" -version = "5.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 }, -] - -[[package]] -name = "dotenv" -version = "0.9.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "python-dotenv" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892 }, -] - -[[package]] -name = "executing" -version = "2.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702 }, -] - -[[package]] -name = "fastapi" -version = "0.115.12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164 }, -] - -[[package]] -name = "fastmcp" -version = "2.0.0" -source = { git = "https://github.com/jlowin/fastmcp.git#b6b23d6866249c29dbef7021d305ca62400162c4" } -dependencies = [ - { name = "dotenv" }, - { name = "fastapi" }, - { name = "mcp" }, - { name = "openapi-pydantic" }, - { name = "rich" }, - { name = "typer" }, - { name = "websockets" }, -] - -[[package]] -name = "h11" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, -] - -[[package]] -name = "httpcore" -version = "1.0.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9f/45/ad3e1b4d448f22c0cff4f5692f5ed0666658578e358b8d58a19846048059/httpcore-1.0.8.tar.gz", hash = "sha256:86e94505ed24ea06514883fd44d2bc02d90e77e7979c8eb71b90f41d364a1bad", size = 85385 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/8d/f052b1e336bb2c1fc7ed1aaed898aa570c0b61a09707b108979d9fc6e308/httpcore-1.0.8-py3-none-any.whl", hash = "sha256:5254cf149bcb5f75e9d1b2b9f729ea4a4b883d1ad7379fc632b727cec23674be", size = 78732 }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, -] - -[[package]] -name = "httpx-sse" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, -] - -[[package]] -name = "idna" -version = "3.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, -] - -[[package]] -name = "ipython" -version = "9.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/70/9a/6b8984bedc990f3a4aa40ba8436dea27e23d26a64527de7c2e5e12e76841/ipython-9.1.0.tar.gz", hash = "sha256:a47e13a5e05e02f3b8e1e7a0f9db372199fe8c3763532fe7a1e0379e4e135f16", size = 4373688 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/9d/4ff2adf55d1b6e3777b0303fdbe5b723f76e46cba4a53a32fe82260d2077/ipython-9.1.0-py3-none-any.whl", hash = "sha256:2df07257ec2f84a6b346b8d83100bcf8fa501c6e01ab75cd3799b0bb253b3d2a", size = 604053 }, -] - -[[package]] -name = "ipython-pygments-lexers" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074 }, -] - -[[package]] -name = "jedi" -version = "0.19.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "parso" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 }, -] - -[[package]] -name = "markdown-it-py" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, -] - -[[package]] -name = "matplotlib-inline" -version = "0.1.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899 }, -] - -[[package]] -name = "mcp" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "sse-starlette" }, - { name = "starlette" }, - { name = "uvicorn" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/95/d2/f587cb965a56e992634bebc8611c5b579af912b74e04eb9164bd49527d21/mcp-1.6.0.tar.gz", hash = "sha256:d9324876de2c5637369f43161cd71eebfd803df5a95e46225cab8d280e366723", size = 200031 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/30/20a7f33b0b884a9d14dd3aa94ff1ac9da1479fe2ad66dd9e2736075d2506/mcp-1.6.0-py3-none-any.whl", hash = "sha256:7bd24c6ea042dbec44c754f100984d186620d8b841ec30f1b19eda9b93a634d0", size = 76077 }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, -] - -[[package]] -name = "openapi-pydantic" -version = "0.5.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381 }, -] - -[[package]] -name = "parso" -version = "0.8.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650 }, -] - -[[package]] -name = "pexpect" -version = "4.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ptyprocess" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 }, -] - -[[package]] -name = "phue2" -version = "0.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c2/48/8dd58546dd9867e4e1d6ed912a3952e5aea5da49040fb61bd07df59b32d4/phue2-0.0.3.tar.gz", hash = "sha256:c05cb28286880b202da29b825c3124fa6c44119323930fd118a81c20f29be0f9", size = 71925 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/58/5d4a080926d1862fac5524ac15fea38fd7b3dcdfa7315734b850e6051c63/phue2-0.0.3-py3-none-any.whl", hash = "sha256:d2717bcfe1f8572e8b6fff305aa7b051b2dc7818eb9f1bff5eb0941f80bde8b2", size = 27535 }, -] - -[[package]] -name = "prompt-toolkit" -version = "3.0.50" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wcwidth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a1/e1/bd15cb8ffdcfeeb2bdc215de3c3cffca11408d829e4b8416dcfe71ba8854/prompt_toolkit-3.0.50.tar.gz", hash = "sha256:544748f3860a2623ca5cd6d2795e7a14f3d0e1c3c9728359013f79877fc89bab", size = 429087 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/ea/d836f008d33151c7a1f62caf3d8dd782e4d15f6a43897f64480c2b8de2ad/prompt_toolkit-3.0.50-py3-none-any.whl", hash = "sha256:9b6427eb19e479d98acff65196a307c555eb567989e6d88ebbb1b509d9779198", size = 387816 }, -] - -[[package]] -name = "ptyprocess" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 }, -] - -[[package]] -name = "pure-eval" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 }, -] - -[[package]] -name = "pydantic" -version = "2.11.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/10/2e/ca897f093ee6c5f3b0bee123ee4465c50e75431c3d5b6a3b44a47134e891/pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3", size = 785513 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/1d/407b29780a289868ed696d1616f4aad49d6388e5a77f567dcd2629dcd7b8/pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f", size = 443591 }, -] - -[[package]] -name = "pydantic-core" -version = "2.33.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/17/19/ed6a078a5287aea7922de6841ef4c06157931622c89c2a47940837b5eecd/pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df", size = 434395 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/ce/3cb22b07c29938f97ff5f5bb27521f95e2ebec399b882392deb68d6c440e/pydantic_core-2.33.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8", size = 2026640 }, - { url = "https://files.pythonhosted.org/packages/19/78/f381d643b12378fee782a72126ec5d793081ef03791c28a0fd542a5bee64/pydantic_core-2.33.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498", size = 1852649 }, - { url = "https://files.pythonhosted.org/packages/9d/2b/98a37b80b15aac9eb2c6cfc6dbd35e5058a352891c5cce3a8472d77665a6/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939", size = 1892472 }, - { url = "https://files.pythonhosted.org/packages/4e/d4/3c59514e0f55a161004792b9ff3039da52448f43f5834f905abef9db6e4a/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d", size = 1977509 }, - { url = "https://files.pythonhosted.org/packages/a9/b6/c2c7946ef70576f79a25db59a576bce088bdc5952d1b93c9789b091df716/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e", size = 2128702 }, - { url = "https://files.pythonhosted.org/packages/88/fe/65a880f81e3f2a974312b61f82a03d85528f89a010ce21ad92f109d94deb/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3", size = 2679428 }, - { url = "https://files.pythonhosted.org/packages/6f/ff/4459e4146afd0462fb483bb98aa2436d69c484737feaceba1341615fb0ac/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d", size = 2008753 }, - { url = "https://files.pythonhosted.org/packages/7c/76/1c42e384e8d78452ededac8b583fe2550c84abfef83a0552e0e7478ccbc3/pydantic_core-2.33.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b", size = 2114849 }, - { url = "https://files.pythonhosted.org/packages/00/72/7d0cf05095c15f7ffe0eb78914b166d591c0eed72f294da68378da205101/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39", size = 2069541 }, - { url = "https://files.pythonhosted.org/packages/b3/69/94a514066bb7d8be499aa764926937409d2389c09be0b5107a970286ef81/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a", size = 2239225 }, - { url = "https://files.pythonhosted.org/packages/84/b0/e390071eadb44b41f4f54c3cef64d8bf5f9612c92686c9299eaa09e267e2/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db", size = 2248373 }, - { url = "https://files.pythonhosted.org/packages/d6/b2/288b3579ffc07e92af66e2f1a11be3b056fe1214aab314748461f21a31c3/pydantic_core-2.33.1-cp312-cp312-win32.whl", hash = "sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda", size = 1907034 }, - { url = "https://files.pythonhosted.org/packages/02/28/58442ad1c22b5b6742b992ba9518420235adced665513868f99a1c2638a5/pydantic_core-2.33.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4", size = 1956848 }, - { url = "https://files.pythonhosted.org/packages/a1/eb/f54809b51c7e2a1d9f439f158b8dd94359321abcc98767e16fc48ae5a77e/pydantic_core-2.33.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea", size = 1903986 }, - { url = "https://files.pythonhosted.org/packages/7a/24/eed3466a4308d79155f1cdd5c7432c80ddcc4530ba8623b79d5ced021641/pydantic_core-2.33.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a", size = 2033551 }, - { url = "https://files.pythonhosted.org/packages/ab/14/df54b1a0bc9b6ded9b758b73139d2c11b4e8eb43e8ab9c5847c0a2913ada/pydantic_core-2.33.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266", size = 1852785 }, - { url = "https://files.pythonhosted.org/packages/fa/96/e275f15ff3d34bb04b0125d9bc8848bf69f25d784d92a63676112451bfb9/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3", size = 1897758 }, - { url = "https://files.pythonhosted.org/packages/b7/d8/96bc536e975b69e3a924b507d2a19aedbf50b24e08c80fb00e35f9baaed8/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a", size = 1986109 }, - { url = "https://files.pythonhosted.org/packages/90/72/ab58e43ce7e900b88cb571ed057b2fcd0e95b708a2e0bed475b10130393e/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516", size = 2129159 }, - { url = "https://files.pythonhosted.org/packages/dc/3f/52d85781406886c6870ac995ec0ba7ccc028b530b0798c9080531b409fdb/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764", size = 2680222 }, - { url = "https://files.pythonhosted.org/packages/f4/56/6e2ef42f363a0eec0fd92f74a91e0ac48cd2e49b695aac1509ad81eee86a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d", size = 2006980 }, - { url = "https://files.pythonhosted.org/packages/4c/c0/604536c4379cc78359f9ee0aa319f4aedf6b652ec2854953f5a14fc38c5a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4", size = 2120840 }, - { url = "https://files.pythonhosted.org/packages/1f/46/9eb764814f508f0edfb291a0f75d10854d78113fa13900ce13729aaec3ae/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde", size = 2072518 }, - { url = "https://files.pythonhosted.org/packages/42/e3/fb6b2a732b82d1666fa6bf53e3627867ea3131c5f39f98ce92141e3e3dc1/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e", size = 2248025 }, - { url = "https://files.pythonhosted.org/packages/5c/9d/fbe8fe9d1aa4dac88723f10a921bc7418bd3378a567cb5e21193a3c48b43/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd", size = 2254991 }, - { url = "https://files.pythonhosted.org/packages/aa/99/07e2237b8a66438d9b26482332cda99a9acccb58d284af7bc7c946a42fd3/pydantic_core-2.33.1-cp313-cp313-win32.whl", hash = "sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f", size = 1915262 }, - { url = "https://files.pythonhosted.org/packages/8a/f4/e457a7849beeed1e5defbcf5051c6f7b3c91a0624dd31543a64fc9adcf52/pydantic_core-2.33.1-cp313-cp313-win_amd64.whl", hash = "sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40", size = 1956626 }, - { url = "https://files.pythonhosted.org/packages/20/d0/e8d567a7cff7b04e017ae164d98011f1e1894269fe8e90ea187a3cbfb562/pydantic_core-2.33.1-cp313-cp313-win_arm64.whl", hash = "sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523", size = 1909590 }, - { url = "https://files.pythonhosted.org/packages/ef/fd/24ea4302d7a527d672c5be06e17df16aabfb4e9fdc6e0b345c21580f3d2a/pydantic_core-2.33.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d", size = 1812963 }, - { url = "https://files.pythonhosted.org/packages/5f/95/4fbc2ecdeb5c1c53f1175a32d870250194eb2fdf6291b795ab08c8646d5d/pydantic_core-2.33.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c", size = 1986896 }, - { url = "https://files.pythonhosted.org/packages/71/ae/fe31e7f4a62431222d8f65a3bd02e3fa7e6026d154a00818e6d30520ea77/pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18", size = 1931810 }, -] - -[[package]] -name = "pydantic-settings" -version = "2.8.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, -] - -[[package]] -name = "pygments" -version = "2.19.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, -] - -[[package]] -name = "python-dotenv" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 }, -] - -[[package]] -name = "rich" -version = "14.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 }, -] - -[[package]] -name = "ruff" -version = "0.11.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/71/5759b2a6b2279bb77fe15b1435b89473631c2cd6374d45ccdb6b785810be/ruff-0.11.5.tar.gz", hash = "sha256:cae2e2439cb88853e421901ec040a758960b576126dab520fa08e9de431d1bef", size = 3976488 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/db/6efda6381778eec7f35875b5cbefd194904832a1153d68d36d6b269d81a8/ruff-0.11.5-py3-none-linux_armv6l.whl", hash = "sha256:2561294e108eb648e50f210671cc56aee590fb6167b594144401532138c66c7b", size = 10103150 }, - { url = "https://files.pythonhosted.org/packages/44/f2/06cd9006077a8db61956768bc200a8e52515bf33a8f9b671ee527bb10d77/ruff-0.11.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac12884b9e005c12d0bd121f56ccf8033e1614f736f766c118ad60780882a077", size = 10898637 }, - { url = "https://files.pythonhosted.org/packages/18/f5/af390a013c56022fe6f72b95c86eb7b2585c89cc25d63882d3bfe411ecf1/ruff-0.11.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4bfd80a6ec559a5eeb96c33f832418bf0fb96752de0539905cf7b0cc1d31d779", size = 10236012 }, - { url = "https://files.pythonhosted.org/packages/b8/ca/b9bf954cfed165e1a0c24b86305d5c8ea75def256707f2448439ac5e0d8b/ruff-0.11.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0947c0a1afa75dcb5db4b34b070ec2bccee869d40e6cc8ab25aca11a7d527794", size = 10415338 }, - { url = "https://files.pythonhosted.org/packages/d9/4d/2522dde4e790f1b59885283f8786ab0046958dfd39959c81acc75d347467/ruff-0.11.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ad871ff74b5ec9caa66cb725b85d4ef89b53f8170f47c3406e32ef040400b038", size = 9965277 }, - { url = "https://files.pythonhosted.org/packages/e5/7a/749f56f150eef71ce2f626a2f6988446c620af2f9ba2a7804295ca450397/ruff-0.11.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6cf918390cfe46d240732d4d72fa6e18e528ca1f60e318a10835cf2fa3dc19f", size = 11541614 }, - { url = "https://files.pythonhosted.org/packages/89/b2/7d9b8435222485b6aac627d9c29793ba89be40b5de11584ca604b829e960/ruff-0.11.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:56145ee1478582f61c08f21076dc59153310d606ad663acc00ea3ab5b2125f82", size = 12198873 }, - { url = "https://files.pythonhosted.org/packages/00/e0/a1a69ef5ffb5c5f9c31554b27e030a9c468fc6f57055886d27d316dfbabd/ruff-0.11.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5f66f8f1e8c9fc594cbd66fbc5f246a8d91f916cb9667e80208663ec3728304", size = 11670190 }, - { url = "https://files.pythonhosted.org/packages/05/61/c1c16df6e92975072c07f8b20dad35cd858e8462b8865bc856fe5d6ccb63/ruff-0.11.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80b4df4d335a80315ab9afc81ed1cff62be112bd165e162b5eed8ac55bfc8470", size = 13902301 }, - { url = "https://files.pythonhosted.org/packages/79/89/0af10c8af4363304fd8cb833bd407a2850c760b71edf742c18d5a87bb3ad/ruff-0.11.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3068befab73620b8a0cc2431bd46b3cd619bc17d6f7695a3e1bb166b652c382a", size = 11350132 }, - { url = "https://files.pythonhosted.org/packages/b9/e1/ecb4c687cbf15164dd00e38cf62cbab238cad05dd8b6b0fc68b0c2785e15/ruff-0.11.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5da2e710a9641828e09aa98b92c9ebbc60518fdf3921241326ca3e8f8e55b8b", size = 10312937 }, - { url = "https://files.pythonhosted.org/packages/cf/4f/0e53fe5e500b65934500949361e3cd290c5ba60f0324ed59d15f46479c06/ruff-0.11.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ef39f19cb8ec98cbc762344921e216f3857a06c47412030374fffd413fb8fd3a", size = 9936683 }, - { url = "https://files.pythonhosted.org/packages/04/a8/8183c4da6d35794ae7f76f96261ef5960853cd3f899c2671961f97a27d8e/ruff-0.11.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b2a7cedf47244f431fd11aa5a7e2806dda2e0c365873bda7834e8f7d785ae159", size = 10950217 }, - { url = "https://files.pythonhosted.org/packages/26/88/9b85a5a8af21e46a0639b107fcf9bfc31da4f1d263f2fc7fbe7199b47f0a/ruff-0.11.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:81be52e7519f3d1a0beadcf8e974715b2dfc808ae8ec729ecfc79bddf8dbb783", size = 11404521 }, - { url = "https://files.pythonhosted.org/packages/fc/52/047f35d3b20fd1ae9ccfe28791ef0f3ca0ef0b3e6c1a58badd97d450131b/ruff-0.11.5-py3-none-win32.whl", hash = "sha256:e268da7b40f56e3eca571508a7e567e794f9bfcc0f412c4b607931d3af9c4afe", size = 10320697 }, - { url = "https://files.pythonhosted.org/packages/b9/fe/00c78010e3332a6e92762424cf4c1919065707e962232797d0b57fd8267e/ruff-0.11.5-py3-none-win_amd64.whl", hash = "sha256:6c6dc38af3cfe2863213ea25b6dc616d679205732dc0fb673356c2d69608f800", size = 11378665 }, - { url = "https://files.pythonhosted.org/packages/43/7c/c83fe5cbb70ff017612ff36654edfebec4b1ef79b558b8e5fd933bab836b/ruff-0.11.5-py3-none-win_arm64.whl", hash = "sha256:67e241b4314f4eacf14a601d586026a962f4002a475aa702c69980a38087aa4e", size = 10460287 }, -] - -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, -] - -[[package]] -name = "smart-home" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "fastmcp" }, - { name = "phue2" }, -] - -[package.dev-dependencies] -dev = [ - { name = "ipython" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "fastmcp", git = "https://github.com/jlowin/fastmcp.git" }, - { name = "phue2" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "ipython" }, - { name = "ruff" }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, -] - -[[package]] -name = "sse-starlette" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "starlette" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, -] - -[[package]] -name = "stack-data" -version = "0.6.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "asttokens" }, - { name = "executing" }, - { name = "pure-eval" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 }, -] - -[[package]] -name = "starlette" -version = "0.46.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/1b/52b27f2e13ceedc79a908e29eac426a63465a1a01248e5f24aa36a62aeb3/starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230", size = 2580102 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995 }, -] - -[[package]] -name = "traitlets" -version = "5.14.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359 }, -] - -[[package]] -name = "typer" -version = "0.15.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061 }, -] - -[[package]] -name = "typing-extensions" -version = "4.13.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 }, -] - -[[package]] -name = "uvicorn" -version = "0.34.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, -] - -[[package]] -name = "wcwidth" -version = "0.2.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, -] - -[[package]] -name = "websockets" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437 }, - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096 }, - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332 }, - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152 }, - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096 }, - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523 }, - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790 }, - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165 }, - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160 }, - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395 }, - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841 }, - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440 }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098 }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329 }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111 }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054 }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496 }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829 }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217 }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 }, -] diff --git a/pyproject.toml b/pyproject.toml index 845eeebb1..ec3d6b7a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,8 @@ dependencies = [ "pydantic[email]>=2.11.7", "pyperclip>=1.9.0", "openapi-core>=0.19.5", + "py-key-value-aio[disk,memory]>=0.2.1", + "websockets>=15.0.1", ] requires-python = ">=3.10" @@ -41,7 +43,6 @@ classifiers = [ ] [project.optional-dependencies] -websockets = ["websockets>=15.0.1"] openai = ["openai>=1.102.0"] caching = ["diskcache>=5.6.3", "cachetools>=6.2.0"] contrib-middleware-elasticsearch_cache = ["fastmcp[caching]", "elasticsearch>=8.15.0", "aiohttp>=3.11.10"] @@ -68,6 +69,7 @@ dev = [ "pytest-flakefinder", "pytest-httpx>=0.35.0", "pytest-report>=0.2.1", + "pytest-retry>=1.7.0", "pytest-timeout>=2.4.0", "pytest-xdist>=3.6.1", "ruff", @@ -105,7 +107,7 @@ asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" asyncio_default_test_loop_scope = "session" # filterwarnings = ["error::DeprecationWarning"] -timeout = 3 +timeout = 5 env = [ "FASTMCP_TEST_MODE=1", 'D:FASTMCP_LOG_LEVEL=DEBUG', @@ -122,6 +124,7 @@ testpaths = ["tests"] python_files = ["test_*.py", "*_test.py"] python_classes = ["Test*"] python_functions = ["test_*"] +addopts = ["--inline-snapshot=disable"] [tool.ty.src] include = ["src", "tests"] @@ -145,3 +148,6 @@ extend-select = ["I", "UP"] "__init__.py" = ["F401", "I001", "RUF013"] # allow imports not at the top of the file "src/fastmcp/__init__.py" = ["E402"] + +[tool.codespell] +ignore-words-list = "asend,shttp,te" diff --git a/src/fastmcp/cli/install/claude_code.py b/src/fastmcp/cli/install/claude_code.py index 5a9a98625..da475afb5 100644 --- a/src/fastmcp/cli/install/claude_code.py +++ b/src/fastmcp/cli/install/claude_code.py @@ -110,9 +110,9 @@ def install_claude_code( env_config = UVEnvironment( python=python_version, dependencies=(with_packages or []) + ["fastmcp"], - requirements=str(with_requirements) if with_requirements else None, - project=str(project) if project else None, - editable=[str(p) for p in with_editable] if with_editable else None, + requirements=with_requirements, + project=project, + editable=with_editable, ) # Build server spec from parsed components diff --git a/src/fastmcp/cli/install/claude_desktop.py b/src/fastmcp/cli/install/claude_desktop.py index 803cf5286..bd8cf395d 100644 --- a/src/fastmcp/cli/install/claude_desktop.py +++ b/src/fastmcp/cli/install/claude_desktop.py @@ -76,9 +76,9 @@ def install_claude_desktop( env_config = UVEnvironment( python=python_version, dependencies=(with_packages or []) + ["fastmcp"], - requirements=str(with_requirements) if with_requirements else None, - project=str(project) if project else None, - editable=[str(p) for p in with_editable] if with_editable else None, + requirements=with_requirements, + project=project, + editable=with_editable, ) # Build server spec from parsed components if server_object: diff --git a/src/fastmcp/cli/install/cursor.py b/src/fastmcp/cli/install/cursor.py index 8c13c2803..dd885e5ea 100644 --- a/src/fastmcp/cli/install/cursor.py +++ b/src/fastmcp/cli/install/cursor.py @@ -110,9 +110,9 @@ def install_cursor_workspace( env_config = UVEnvironment( python=python_version, dependencies=(with_packages or []) + ["fastmcp"], - requirements=str(with_requirements.resolve()) if with_requirements else None, - project=str(project.resolve()) if project else None, - editable=[str(p.resolve()) for p in with_editable] if with_editable else None, + requirements=with_requirements, + project=project, + editable=with_editable, ) # Build server spec from parsed components if server_object: @@ -180,9 +180,9 @@ def install_cursor( env_config = UVEnvironment( python=python_version, dependencies=(with_packages or []) + ["fastmcp"], - requirements=str(with_requirements.resolve()) if with_requirements else None, - project=str(project.resolve()) if project else None, - editable=[str(p.resolve()) for p in with_editable] if with_editable else None, + requirements=with_requirements, + project=project, + editable=with_editable, ) # Build server spec from parsed components if server_object: diff --git a/src/fastmcp/cli/install/gemini_cli.py b/src/fastmcp/cli/install/gemini_cli.py index c6c7c1b5d..8acaa413e 100644 --- a/src/fastmcp/cli/install/gemini_cli.py +++ b/src/fastmcp/cli/install/gemini_cli.py @@ -104,13 +104,12 @@ def install_gemini_cli( ) return False - # Build uv run command using Environment.build_uv_run_command() env_config = UVEnvironment( python=python_version, dependencies=(with_packages or []) + ["fastmcp"], - requirements=str(with_requirements) if with_requirements else None, - project=str(project) if project else None, - editable=[str(p) for p in with_editable] if with_editable else None, + requirements=with_requirements, + project=project, + editable=with_editable, ) # Build server spec from parsed components diff --git a/src/fastmcp/cli/install/mcp_json.py b/src/fastmcp/cli/install/mcp_json.py index 7dcb0c0da..95ccb3ca3 100644 --- a/src/fastmcp/cli/install/mcp_json.py +++ b/src/fastmcp/cli/install/mcp_json.py @@ -51,9 +51,9 @@ def install_mcp_json( env_config = UVEnvironment( python=python_version, dependencies=(with_packages or []) + ["fastmcp"], - requirements=str(with_requirements) if with_requirements else None, - project=str(project) if project else None, - editable=[str(p) for p in with_editable] if with_editable else None, + requirements=with_requirements, + project=project, + editable=with_editable, ) # Build server spec from parsed components if server_object: diff --git a/src/fastmcp/cli/run.py b/src/fastmcp/cli/run.py index f2712ca1c..5c4f386db 100644 --- a/src/fastmcp/cli/run.py +++ b/src/fastmcp/cli/run.py @@ -184,8 +184,8 @@ async def run_command( kwargs["port"] = port if path: kwargs["path"] = path - # Note: log_level is not currently supported by run_async - # TODO: Add log_level support to server.run_async + if log_level: + kwargs["log_level"] = log_level if not show_banner: kwargs["show_banner"] = False diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py index 18c4cb1b6..0e088953d 100644 --- a/src/fastmcp/client/auth/oauth.py +++ b/src/fastmcp/client/auth/oauth.py @@ -1,29 +1,28 @@ from __future__ import annotations import asyncio -import json +import time import webbrowser from asyncio import Future from collections.abc import AsyncGenerator -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Any, Literal +from typing import Any from urllib.parse import urlparse import anyio import httpx +from key_value.aio.adapters.pydantic import PydanticAdapter +from key_value.aio.protocols import AsyncKeyValue +from key_value.aio.stores.memory import MemoryStore from mcp.client.auth import OAuthClientProvider, TokenStorage from mcp.shared.auth import ( OAuthClientInformationFull, OAuthClientMetadata, + OAuthToken, ) -from mcp.shared.auth import ( - OAuthToken as OAuthToken, -) -from pydantic import AnyHttpUrl, BaseModel, TypeAdapter, ValidationError +from pydantic import AnyHttpUrl +from typing_extensions import override from uvicorn.server import Server -from fastmcp import settings as fastmcp_global_settings from fastmcp.client.oauth_callback import ( create_oauth_callback_server, ) @@ -41,161 +40,6 @@ class ClientNotFoundError(Exception): pass -class StoredToken(BaseModel): - """Token storage format with absolute expiry time.""" - - token_payload: OAuthToken - expires_at: datetime | None - - -# Create TypeAdapter at module level for efficient parsing -stored_token_adapter = TypeAdapter(StoredToken) - - -def default_cache_dir() -> Path: - return fastmcp_global_settings.home / "oauth-mcp-client-cache" - - -class FileTokenStorage(TokenStorage): - """ - File-based token storage implementation for OAuth credentials and tokens. - Implements the mcp.client.auth.TokenStorage protocol. - - Each instance is tied to a specific server URL for proper token isolation. - """ - - def __init__(self, server_url: str, cache_dir: Path | None = None): - """Initialize storage for a specific server URL.""" - self.server_url = server_url - self.cache_dir = cache_dir or default_cache_dir() - self.cache_dir.mkdir(exist_ok=True, parents=True) - - @staticmethod - def get_base_url(url: str) -> str: - """Extract the base URL (scheme + host) from a URL.""" - parsed = urlparse(url) - return f"{parsed.scheme}://{parsed.netloc}" - - def get_cache_key(self) -> str: - """Generate a safe filesystem key from the server's base URL.""" - base_url = self.get_base_url(self.server_url) - return ( - base_url.replace("://", "_") - .replace(".", "_") - .replace("/", "_") - .replace(":", "_") - ) - - def _get_file_path(self, file_type: Literal["client_info", "tokens"]) -> Path: - """Get the file path for the specified cache file type.""" - key = self.get_cache_key() - return self.cache_dir / f"{key}_{file_type}.json" - - async def get_tokens(self) -> OAuthToken | None: - """Load tokens from file storage.""" - path = self._get_file_path("tokens") - - try: - # Parse JSON and validate as StoredToken - stored = stored_token_adapter.validate_json(path.read_text()) - - # Check if token is expired - if stored.expires_at is not None: - now = datetime.now(timezone.utc) - if now >= stored.expires_at: - logger.debug( - f"Token expired for {self.get_base_url(self.server_url)}" - ) - return None - - # Recalculate expires_in to be correct relative to now - if stored.token_payload.expires_in is not None: - remaining = stored.expires_at - now - stored.token_payload.expires_in = max( - 0, int(remaining.total_seconds()) - ) - - return stored.token_payload - - except (FileNotFoundError, ValidationError) as e: - logger.debug( - f"Could not load tokens for {self.get_base_url(self.server_url)}: {e}" - ) - return None - - async def set_tokens(self, tokens: OAuthToken) -> None: - """Save tokens to file storage.""" - path = self._get_file_path("tokens") - - # Calculate absolute expiry time if expires_in is present - expires_at = None - if tokens.expires_in is not None: - expires_at = datetime.now(timezone.utc) + timedelta( - seconds=tokens.expires_in - ) - - # Create StoredToken and save using Pydantic serialization - stored = StoredToken(token_payload=tokens, expires_at=expires_at) - - path.write_text(stored.model_dump_json(indent=2)) - logger.debug(f"Saved tokens for {self.get_base_url(self.server_url)}") - - async def get_client_info(self) -> OAuthClientInformationFull | None: - """Load client information from file storage.""" - path = self._get_file_path("client_info") - try: - client_info = OAuthClientInformationFull.model_validate_json( - path.read_text() - ) - # Check if we have corresponding valid tokens - # If no tokens exist, the OAuth flow was incomplete and we should - # force a fresh client registration - tokens = await self.get_tokens() - if tokens is None: - logger.debug( - f"No tokens found for client info at {self.get_base_url(self.server_url)}. " - "OAuth flow may have been incomplete. Clearing client info to force fresh registration." - ) - # Clear the incomplete client info - client_info_path = self._get_file_path("client_info") - client_info_path.unlink(missing_ok=True) - return None - - return client_info - except (FileNotFoundError, json.JSONDecodeError, ValidationError) as e: - logger.debug( - f"Could not load client info for {self.get_base_url(self.server_url)}: {e}" - ) - return None - - async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: - """Save client information to file storage.""" - path = self._get_file_path("client_info") - path.write_text(client_info.model_dump_json(indent=2)) - logger.debug(f"Saved client info for {self.get_base_url(self.server_url)}") - - def clear(self) -> None: - """Clear all cached data for this server.""" - file_types: list[Literal["client_info", "tokens"]] = ["client_info", "tokens"] - for file_type in file_types: - path = self._get_file_path(file_type) - path.unlink(missing_ok=True) - logger.debug(f"Cleared OAuth cache for {self.get_base_url(self.server_url)}") - - @classmethod - def clear_all(cls, cache_dir: Path | None = None) -> None: - """Clear all cached data for all servers.""" - cache_dir = cache_dir or default_cache_dir() - if not cache_dir.exists(): - return - - file_types: list[Literal["client_info", "tokens"]] = ["client_info", "tokens"] - for file_type in file_types: - for file in cache_dir.glob(f"*_{file_type}.json"): - file.unlink(missing_ok=True) - logger.info("Cleared all OAuth client cache data.") - - async def check_if_auth_required( mcp_url: str, httpx_kwargs: dict[str, Any] | None = None ) -> bool: @@ -226,6 +70,70 @@ async def check_if_auth_required( return True +class TokenStorageAdapter(TokenStorage): + _server_url: str + _key_value_store: AsyncKeyValue + _storage_oauth_token: PydanticAdapter[OAuthToken] + _storage_client_info: PydanticAdapter[OAuthClientInformationFull] + + def __init__(self, async_key_value: AsyncKeyValue, server_url: str): + self._server_url = server_url + self._key_value_store = async_key_value + self._storage_oauth_token = PydanticAdapter[OAuthToken]( + default_collection="mcp-oauth-token", + key_value=async_key_value, + pydantic_model=OAuthToken, + raise_on_validation_error=True, + ) + self._storage_client_info = PydanticAdapter[OAuthClientInformationFull]( + default_collection="mcp-oauth-client-info", + key_value=async_key_value, + pydantic_model=OAuthClientInformationFull, + raise_on_validation_error=True, + ) + + def _get_token_cache_key(self) -> str: + return f"{self._server_url}/tokens" + + def _get_client_info_cache_key(self) -> str: + return f"{self._server_url}/client_info" + + async def clear(self) -> None: + await self._storage_oauth_token.delete(key=self._get_token_cache_key()) + await self._storage_client_info.delete(key=self._get_client_info_cache_key()) + + @override + async def get_tokens(self) -> OAuthToken | None: + return await self._storage_oauth_token.get(key=self._get_token_cache_key()) + + @override + async def set_tokens(self, tokens: OAuthToken) -> None: + await self._storage_oauth_token.put( + key=self._get_token_cache_key(), + value=tokens, + ttl=tokens.expires_in, + ) + + @override + async def get_client_info(self) -> OAuthClientInformationFull | None: + return await self._storage_client_info.get( + key=self._get_client_info_cache_key() + ) + + @override + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + ttl: int | None = None + + if client_info.client_secret_expires_at: + ttl = client_info.client_secret_expires_at - int(time.time()) + + await self._storage_client_info.put( + key=self._get_client_info_cache_key(), + value=client_info, + ttl=ttl, + ) + + class OAuth(OAuthClientProvider): """ OAuth client provider for MCP servers with browser-based authentication. @@ -239,7 +147,7 @@ class OAuth(OAuthClientProvider): mcp_url: str, scopes: str | list[str] | None = None, client_name: str = "FastMCP Client", - token_storage_cache_dir: Path | None = None, + token_storage: AsyncKeyValue | None = None, additional_client_metadata: dict[str, Any] | None = None, callback_port: int | None = None, ): @@ -251,7 +159,7 @@ class OAuth(OAuthClientProvider): scopes: OAuth scopes to request. Can be a space-separated string or a list of strings. client_name: Name for this client during registration - token_storage_cache_dir: Directory for FileTokenStorage + token_storage: An AsyncKeyValue-compatible token store, tokens are stored in memory if not provided additional_client_metadata: Extra fields for OAuthClientMetadata callback_port: Fixed port for OAuth callback (default: random available port) """ @@ -281,8 +189,10 @@ class OAuth(OAuthClientProvider): ) # Create server-specific token storage - storage = FileTokenStorage( - server_url=server_base_url, cache_dir=token_storage_cache_dir + token_storage = token_storage or MemoryStore() + + self.token_storage_adapter: TokenStorageAdapter = TokenStorageAdapter( + async_key_value=token_storage, server_url=server_base_url ) # Store server_base_url for use in callback_handler @@ -292,7 +202,7 @@ class OAuth(OAuthClientProvider): super().__init__( server_url=server_base_url, client_metadata=client_metadata, - storage=storage, + storage=self.token_storage_adapter, redirect_handler=self.redirect_handler, callback_handler=self.callback_handler, ) @@ -318,8 +228,8 @@ class OAuth(OAuthClientProvider): "OAuth client not found - cached credentials may be stale" ) - # For any non-redirect response, something is wrong - if response.status_code not in (302, 303, 307, 308): + # OAuth typically returns redirects, but some providers return 200 with HTML login pages + if response.status_code not in (200, 302, 303, 307, 308): raise RuntimeError( f"Unexpected authorization response: {response.status_code}" ) @@ -386,23 +296,7 @@ class OAuth(OAuthClientProvider): # Clear cached state and retry once self._initialized = False - - # Try to clear storage if it supports it - if hasattr(self.context.storage, "clear"): - try: - self.context.storage.clear() - except Exception as e: - logger.warning(f"Failed to clear OAuth storage cache: {e}") - # Can't retry without clearing cache, re-raise original error - raise ClientNotFoundError( - "OAuth client not found and cache could not be cleared" - ) from e - else: - logger.warning( - "Storage does not support clear() - cannot retry with fresh credentials" - ) - # Can't retry without clearing cache, re-raise original error - raise + await self.token_storage_adapter.clear() gen = super().async_auth_flow(request) response = None diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index bcbab30b0..c1957c277 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -155,38 +155,38 @@ class Client(Generic[ClientTransportT]): """ @overload - def __init__(self: Client[T], transport: T, *args, **kwargs) -> None: ... + def __init__(self: Client[T], transport: T, *args: Any, **kwargs: Any) -> None: ... @overload def __init__( self: Client[SSETransport | StreamableHttpTransport], transport: AnyUrl, - *args, - **kwargs, + *args: Any, + **kwargs: Any, ) -> None: ... @overload def __init__( self: Client[FastMCPTransport], transport: FastMCP | FastMCP1Server, - *args, - **kwargs, + *args: Any, + **kwargs: Any, ) -> None: ... @overload def __init__( self: Client[PythonStdioTransport | NodeStdioTransport], transport: Path, - *args, - **kwargs, + *args: Any, + **kwargs: Any, ) -> None: ... @overload def __init__( self: Client[MCPConfigTransport], transport: MCPConfig | dict[str, Any], - *args, - **kwargs, + *args: Any, + **kwargs: Any, ) -> None: ... @overload @@ -198,8 +198,8 @@ class Client(Generic[ClientTransportT]): | StreamableHttpTransport ], transport: str, - *args, - **kwargs, + *args: Any, + **kwargs: Any, ) -> None: ... def __init__( @@ -745,12 +745,15 @@ class Client(Generic[ClientTransportT]): self, ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference, argument: dict[str, str], + context_arguments: dict[str, Any] | None = None, ) -> mcp.types.CompleteResult: """Send a completion request and return the complete MCP protocol result. Args: ref (mcp.types.ResourceTemplateReference | mcp.types.PromptReference): The reference to complete. argument (dict[str, str]): Arguments to pass to the completion request. + context_arguments (dict[str, Any] | None, optional): Optional context arguments to + include with the completion request. Defaults to None. Returns: mcp.types.CompleteResult: The complete response object from the protocol, @@ -761,19 +764,24 @@ class Client(Generic[ClientTransportT]): """ logger.debug(f"[{self.name}] called complete: {ref}") - result = await self.session.complete(ref=ref, argument=argument) + result = await self.session.complete( + ref=ref, argument=argument, context_arguments=context_arguments + ) return result async def complete( self, ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference, argument: dict[str, str], + context_arguments: dict[str, Any] | None = None, ) -> mcp.types.Completion: """Send a completion request to the server. Args: ref (mcp.types.ResourceTemplateReference | mcp.types.PromptReference): The reference to complete. argument (dict[str, str]): Arguments to pass to the completion request. + context_arguments (dict[str, Any] | None, optional): Optional context arguments to + include with the completion request. Defaults to None. Returns: mcp.types.Completion: The completion object. @@ -781,7 +789,9 @@ class Client(Generic[ClientTransportT]): Raises: RuntimeError: If called while the client is not connected. """ - result = await self.complete_mcp(ref=ref, argument=argument) + result = await self.complete_mcp( + ref=ref, argument=argument, context_arguments=context_arguments + ) return result.completion # --- Tools --- diff --git a/src/fastmcp/client/oauth_callback.py b/src/fastmcp/client/oauth_callback.py index 8a92b9b08..c6da794e8 100644 --- a/src/fastmcp/client/oauth_callback.py +++ b/src/fastmcp/client/oauth_callback.py @@ -289,6 +289,7 @@ def create_oauth_callback_server( port=port, lifespan="off", log_level="warning", + ws="websockets-sansio", ) ) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 8492a5125..1016d35e0 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -583,15 +583,15 @@ class UvStdioTransport(StdioTransport): command: str, args: list[str] | None = None, module: bool = False, - project_directory: str | None = None, + project_directory: Path | None = None, python_version: str | None = None, with_packages: list[str] | None = None, - with_requirements: str | None = None, + with_requirements: Path | None = None, env_vars: dict[str, str] | None = None, keep_alive: bool | None = None, ): # Basic validation - if project_directory and not Path(project_directory).exists(): + if project_directory and not project_directory.exists(): raise NotADirectoryError( f"Project directory not found: {project_directory}" ) diff --git a/src/fastmcp/contrib/mcp_mixin/README.md b/src/fastmcp/contrib/mcp_mixin/README.md index e02d57cba..0742d7b6a 100644 --- a/src/fastmcp/contrib/mcp_mixin/README.md +++ b/src/fastmcp/contrib/mcp_mixin/README.md @@ -91,12 +91,12 @@ class MyComponent(MCPMixin): # prompt @mcp_prompt(name="A prompt") def prompt_method(self, name): - return f"Whats up {name}?" + return f"What's up {name}?" # disabled prompt @mcp_prompt(name="A prompt", enabled=False) def prompt_method(self, name): - return f"Whats up {name}?" + return f"What's up {name}?" mcp_server = FastMCP() component = MyComponent() diff --git a/src/fastmcp/experimental/utilities/openapi/schemas.py b/src/fastmcp/experimental/utilities/openapi/schemas.py index 234283fcb..101081b18 100644 --- a/src/fastmcp/experimental/utilities/openapi/schemas.py +++ b/src/fastmcp/experimental/utilities/openapi/schemas.py @@ -79,6 +79,7 @@ def _replace_ref_with_defs( Examples: - {"type": "object", "properties": {"$ref": "#/components/schemas/..."}} + - {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/..."}, "properties": {...}} - {"$ref": "#/components/schemas/..."} - {"items": {"$ref": "#/components/schemas/..."}} - {"anyOf": [{"$ref": "#/components/schemas/..."}]} @@ -117,6 +118,11 @@ def _replace_ref_with_defs( for section in ["anyOf", "allOf", "oneOf"]: for i, item in enumerate(schema.get(section, [])): schema[section][i] = _replace_ref_with_defs(item) + if additionalProperties := schema.get("additionalProperties"): + if not isinstance(additionalProperties, bool): + schema["additionalProperties"] = _replace_ref_with_defs( + additionalProperties + ) if info.get("description", description) and not schema.get("description"): schema["description"] = description return schema @@ -297,9 +303,11 @@ def _combine_schemas_and_map_params( # Convert refs if needed if convert_refs: - param_schema = _replace_ref_with_defs(param.schema_) + param_schema = _replace_ref_with_defs(param.schema_, param.description) else: - param_schema = param.schema_ + param_schema = param.schema_.copy() + if param.description and not param_schema.get("description"): + param_schema["description"] = param.description original_desc = param_schema.get("description", "") location_desc = f"({param.location.capitalize()} parameter)" if original_desc: @@ -324,9 +332,11 @@ def _combine_schemas_and_map_params( # Convert refs if needed if convert_refs: - param_schema = _replace_ref_with_defs(param.schema_) + param_schema = _replace_ref_with_defs(param.schema_, param.description) else: - param_schema = param.schema_ + param_schema = param.schema_.copy() + if param.description and not param_schema.get("description"): + param_schema["description"] = param.description # Don't make optional parameters nullable - they can simply be omitted # The OpenAPI specification doesn't require optional parameters to accept null values @@ -344,7 +354,7 @@ def _combine_schemas_and_map_params( if route.request_body.required: required.append("body") parameter_map["body"] = {"location": "body", "openapi_name": "body"} - else: + elif body_props: # Normal case: body has properties for prop_name, prop_schema in body_props.items(): properties[prop_name] = prop_schema @@ -357,6 +367,22 @@ def _combine_schemas_and_map_params( if route.request_body.required: required.extend(body_schema.get("required", [])) + else: + # Handle direct array/primitive schemas (like list[str] parameters from FastAPI) + # Use the schema title as parameter name, fall back to generic name + param_name = body_schema.get("title", "body").lower() + + # Clean the parameter name to be valid + import re + + param_name = re.sub(r"[^a-zA-Z0-9_]", "_", param_name) + if not param_name or param_name[0].isdigit(): + param_name = "body_data" + + properties[param_name] = body_schema + if route.request_body.required: + required.append(param_name) + parameter_map[param_name] = {"location": "body", "openapi_name": param_name} result = { "type": "object", diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index 91c5cf31f..2785d04df 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -4,7 +4,6 @@ from __future__ import annotations as _annotations import inspect import json -from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable, Sequence from typing import Any @@ -62,7 +61,7 @@ class PromptArgument(FastMCPBaseModel): ) -class Prompt(FastMCPComponent, ABC): +class Prompt(FastMCPComponent): """A prompt template that can be rendered with parameters.""" arguments: list[PromptArgument] | None = Field( @@ -139,13 +138,16 @@ class Prompt(FastMCPComponent, ABC): meta=meta, ) - @abstractmethod async def render( self, arguments: dict[str, Any] | None = None, ) -> list[PromptMessage]: - """Render the prompt with arguments.""" - raise NotImplementedError("Prompt.render() must be implemented by subclasses") + """Render the prompt with arguments. + + This method is not implemented in the base Prompt class and must be + implemented by subclasses. + """ + raise NotImplementedError("Subclasses must implement render()") class FunctionPrompt(Prompt): diff --git a/src/fastmcp/prompts/prompt_manager.py b/src/fastmcp/prompts/prompt_manager.py index ab1a944e2..56da9289c 100644 --- a/src/fastmcp/prompts/prompt_manager.py +++ b/src/fastmcp/prompts/prompt_manager.py @@ -46,21 +46,23 @@ class PromptManager: """Adds a mounted server as a source for prompts.""" self._mounted_servers.append(server) - async def _load_prompts(self, *, via_server: bool = False) -> dict[str, Prompt]: + async def _load_prompts( + self, *, apply_filtering: bool = False + ) -> dict[str, Prompt]: """ - The single, consolidated recursive method for fetching prompts. The 'via_server' + The single, consolidated recursive method for fetching prompts. The 'apply_filtering' parameter determines the communication path. - - via_server=False: Manager-to-manager path for complete, unfiltered inventory - - via_server=True: Server-to-server path for filtered MCP requests + - apply_filtering=False: Manager-to-manager path for complete, unfiltered inventory + - apply_filtering=True: Server-to-server path for filtered MCP requests """ all_prompts: dict[str, Prompt] = {} for mounted in self._mounted_servers: try: - if via_server: + if apply_filtering: # Use the server-to-server filtered path - child_results = await mounted.server._list_prompts() + child_results = await mounted.server._list_prompts_middleware() else: # Use the manager-to-manager unfiltered path child_results = await mounted.server._prompt_manager.list_prompts() @@ -104,13 +106,13 @@ class PromptManager: """ Gets the complete, unfiltered inventory of all prompts. """ - return await self._load_prompts(via_server=False) + return await self._load_prompts(apply_filtering=False) async def list_prompts(self) -> list[Prompt]: """ Lists all prompts, applying protocol filtering. """ - prompts_dict = await self._load_prompts(via_server=True) + prompts_dict = await self._load_prompts(apply_filtering=True) return list(prompts_dict.values()) def add_prompt_from_fn( @@ -196,7 +198,9 @@ class PromptManager: else: continue try: - return await mounted.server._get_prompt(prompt_key, arguments) + return await mounted.server._get_prompt_middleware( + prompt_key, arguments + ) except NotFoundError: continue diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index 3067fb2c7..d7f9d7177 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -2,7 +2,6 @@ from __future__ import annotations -import abc import inspect from collections.abc import Callable from typing import TYPE_CHECKING, Annotated, Any @@ -31,7 +30,7 @@ if TYPE_CHECKING: pass -class Resource(FastMCPComponent, abc.ABC): +class Resource(FastMCPComponent): """Base class for all resources.""" model_config = ConfigDict(validate_default=True) @@ -111,10 +110,13 @@ class Resource(FastMCPComponent, abc.ABC): raise ValueError("Either name or uri must be provided") return self - @abc.abstractmethod async def read(self) -> str | bytes: - """Read the resource content.""" - pass + """Read the resource content. + + This method is not implemented in the base Resource class and must be + implemented by subclasses. + """ + raise NotImplementedError("Subclasses must implement read()") def to_mcp_resource( self, diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py index c646c71aa..d66a49ccf 100644 --- a/src/fastmcp/resources/resource_manager.py +++ b/src/fastmcp/resources/resource_manager.py @@ -63,27 +63,31 @@ class ResourceManager: async def get_resources(self) -> dict[str, Resource]: """Get all registered resources, keyed by URI.""" - return await self._load_resources(via_server=False) + return await self._load_resources(apply_filtering=False) async def get_resource_templates(self) -> dict[str, ResourceTemplate]: """Get all registered templates, keyed by URI template.""" - return await self._load_resource_templates(via_server=False) + return await self._load_resource_templates(apply_filtering=False) - async def _load_resources(self, *, via_server: bool = False) -> dict[str, Resource]: + async def _load_resources( + self, *, apply_filtering: bool = False + ) -> dict[str, Resource]: """ - The single, consolidated recursive method for fetching resources. The 'via_server' + The single, consolidated recursive method for fetching resources. The 'apply_filtering' parameter determines the communication path. - - via_server=False: Manager-to-manager path for complete, unfiltered inventory - - via_server=True: Server-to-server path for filtered MCP requests + - apply_filtering=False: Manager-to-manager path for complete, unfiltered inventory + - apply_filtering=True: Server-to-server path for filtered MCP requests """ all_resources: dict[str, Resource] = {} for mounted in self._mounted_servers: try: - if via_server: + if apply_filtering: # Use the server-to-server filtered path - child_resources_list = await mounted.server._list_resources() + child_resources_list = ( + await mounted.server._list_resources_middleware() + ) child_resources = { resource.key: resource for resource in child_resources_list } @@ -123,22 +127,24 @@ class ResourceManager: return all_resources async def _load_resource_templates( - self, *, via_server: bool = False + self, *, apply_filtering: bool = False ) -> dict[str, ResourceTemplate]: """ - The single, consolidated recursive method for fetching templates. The 'via_server' + The single, consolidated recursive method for fetching templates. The 'apply_filtering' parameter determines the communication path. - - via_server=False: Manager-to-manager path for complete, unfiltered inventory - - via_server=True: Server-to-server path for filtered MCP requests + - apply_filtering=False: Manager-to-manager path for complete, unfiltered inventory + - apply_filtering=True: Server-to-server path for filtered MCP requests """ all_templates: dict[str, ResourceTemplate] = {} for mounted in self._mounted_servers: try: - if via_server: + if apply_filtering: # Use the server-to-server filtered path - child_templates = await mounted.server._list_resource_templates() + child_templates = ( + await mounted.server._list_resource_templates_middleware() + ) else: # Use the manager-to-manager unfiltered path child_templates = ( @@ -179,14 +185,14 @@ class ResourceManager: """ Lists all resources, applying protocol filtering. """ - resources_dict = await self._load_resources(via_server=True) + resources_dict = await self._load_resources(apply_filtering=True) return list(resources_dict.values()) async def list_resource_templates(self) -> list[ResourceTemplate]: """ Lists all templates, applying protocol filtering. """ - templates_dict = await self._load_resource_templates(via_server=True) + templates_dict = await self._load_resource_templates(apply_filtering=True) return list(templates_dict.values()) def add_resource_or_template_from_fn( @@ -492,7 +498,7 @@ class ResourceManager: continue try: - result = await mounted.server._read_resource(key) + result = await mounted.server._read_resource_middleware(key) return result[0].content except NotFoundError: continue diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index da18c7045..3e249c3d6 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -6,7 +6,7 @@ import inspect import re from collections.abc import Callable from typing import Any -from urllib.parse import unquote +from urllib.parse import parse_qs, unquote from mcp.types import Annotations from mcp.types import ResourceTemplate as MCPResourceTemplate @@ -26,8 +26,26 @@ from fastmcp.utilities.types import ( ) +def extract_query_params(uri_template: str) -> set[str]: + """Extract query parameter names from RFC 6570 {?param1,param2} syntax.""" + match = re.search(r"\{\?([^}]+)\}", uri_template) + if match: + return {p.strip() for p in match.group(1).split(",")} + return set() + + def build_regex(template: str) -> re.Pattern: - parts = re.split(r"(\{[^}]+\})", template) + """Build regex pattern for URI template, handling RFC 6570 syntax. + + Supports: + - {var} - simple path parameter + - {var*} - wildcard path parameter (captures multiple segments) + - {?var1,var2} - query parameters (ignored in path matching) + """ + # Remove query parameter syntax for path matching + template_without_query = re.sub(r"\{\?[^}]+\}", "", template) + + parts = re.split(r"(\{[^}]+\})", template_without_query) pattern = "" for part in parts: if part.startswith("{") and part.endswith("}"): @@ -43,11 +61,34 @@ def build_regex(template: str) -> re.Pattern: def match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None: + """Match URI against template and extract both path and query parameters. + + Supports RFC 6570 URI templates: + - Path params: {var}, {var*} + - Query params: {?var1,var2} + """ + # Split URI into path and query parts + uri_path, _, query_string = uri.partition("?") + + # Match path parameters regex = build_regex(uri_template) - match = regex.match(uri) - if match: - return {k: unquote(v) for k, v in match.groupdict().items()} - return None + match = regex.match(uri_path) + if not match: + return None + + params = {k: unquote(v) for k, v in match.groupdict().items()} + + # Extract query parameters if present in URI and template + if query_string: + query_param_names = extract_query_params(uri_template) + parsed_query = parse_qs(query_string) + + for name in query_param_names: + if name in parsed_query: + # Take first value if multiple provided + params[name] = parsed_query[name][0] # type: ignore[index] + + return params class ResourceTemplate(FastMCPComponent): @@ -206,6 +247,31 @@ class FunctionResourceTemplate(ResourceTemplate): if context_kwarg and context_kwarg not in kwargs: kwargs[context_kwarg] = get_context() + # Type coercion for query parameters (which arrive as strings) + # Get function signature for type hints + sig = inspect.signature(self.fn) + for param_name, param_value in list(kwargs.items()): + if param_name in sig.parameters and isinstance(param_value, str): + param = sig.parameters[param_name] + annotation = param.annotation + + # Skip if no annotation or annotation is str + if annotation is inspect.Parameter.empty or annotation is str: + continue + + # Handle common type coercions + try: + if annotation is int: + kwargs[param_name] = int(param_value) + elif annotation is float: + kwargs[param_name] = float(param_value) + elif annotation is bool: + # Handle boolean strings + kwargs[param_name] = param_value.lower() in ("true", "1", "yes") + except (ValueError, AttributeError): + # Let validate_call handle the error + pass + result = self.fn(**kwargs) if inspect.isawaitable(result): result = await result @@ -245,16 +311,19 @@ class FunctionResourceTemplate(ResourceTemplate): context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context) - # Validate that URI params match function params - uri_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template)) - if not uri_params: + # Extract path and query parameters from URI template + path_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template)) + query_params = extract_query_params(uri_template) + all_uri_params = path_params | query_params + + if not all_uri_params: raise ValueError("URI template must contain at least one parameter") func_params = set(sig.parameters.keys()) if context_kwarg: func_params.discard(context_kwarg) - # get the parameters that are required + # Get required and optional function parameters required_params = { p for p in func_params @@ -262,21 +331,37 @@ class FunctionResourceTemplate(ResourceTemplate): and sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD and p != context_kwarg } + optional_params = { + p + for p in func_params + if sig.parameters[p].default is not inspect.Parameter.empty + and sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD + and p != context_kwarg + } - # Check if required parameters are a subset of the URI parameters - if not required_params.issubset(uri_params): + # Validate RFC 6570 query parameters + # Query params must be optional (have defaults) + if query_params: + invalid_query_params = query_params - optional_params + if invalid_query_params: + raise ValueError( + f"Query parameters {invalid_query_params} must be optional function parameters with default values" + ) + + # Check if required parameters are a subset of the path parameters + if not required_params.issubset(path_params): raise ValueError( - f"Required function arguments {required_params} must be a subset of the URI parameters {uri_params}" + f"Required function arguments {required_params} must be a subset of the URI path parameters {path_params}" ) - # Check if the URI parameters are a subset of the function parameters (skip if **kwargs present) + # Check if all URI parameters are valid function parameters (skip if **kwargs present) if not any( param.kind == inspect.Parameter.VAR_KEYWORD for param in sig.parameters.values() ): - if not uri_params.issubset(func_params): + if not all_uri_params.issubset(func_params): raise ValueError( - f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}" + f"URI parameters {all_uri_params} must be a subset of the function arguments: {func_params}" ) description = description or inspect.getdoc(fn) diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py index 760c94d69..de545c7c2 100644 --- a/src/fastmcp/server/auth/auth.py +++ b/src/fastmcp/server/auth/auth.py @@ -1,13 +1,9 @@ from __future__ import annotations from typing import Any -from urllib.parse import urljoin from mcp.server.auth.middleware.auth_context import AuthContextMiddleware -from mcp.server.auth.middleware.bearer_auth import ( - BearerAuthBackend, - RequireAuthMiddleware, -) +from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend from mcp.server.auth.provider import ( AccessToken as _SDKAccessToken, ) @@ -82,7 +78,6 @@ class AuthProvider(TokenVerifierProtocol): def get_routes( self, mcp_path: str | None = None, - mcp_endpoint: Any | None = None, ) -> list[Route]: """Get the routes for this authentication provider. @@ -94,30 +89,13 @@ class AuthProvider(TokenVerifierProtocol): Args: mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - mcp_endpoint: The MCP endpoint handler to protect with auth + This is used to advertise the resource URL in metadata, but the + provider does not create the actual MCP endpoint route. Returns: - List of routes for this provider, including protected MCP endpoints if provided + List of routes for this provider (excluding the MCP endpoint itself) """ - - routes = [] - - # Add protected MCP endpoint if provided - if mcp_path and mcp_endpoint: - resource_metadata_url = self._get_resource_url( - "/.well-known/oauth-protected-resource" - ) - - routes.append( - Route( - mcp_path, - endpoint=RequireAuthMiddleware( - mcp_endpoint, self.required_scopes, resource_metadata_url - ), - ) - ) - - return routes + return [] def get_middleware(self) -> list: """Get HTTP application-level middleware for this auth provider. @@ -146,8 +124,9 @@ class AuthProvider(TokenVerifierProtocol): return None if path: - return AnyHttpUrl(urljoin(str(self.base_url), path)) - + prefix = str(self.base_url).rstrip("/") + suffix = path.lstrip("/") + return AnyHttpUrl(f"{prefix}/{suffix}") return self.base_url @@ -225,14 +204,13 @@ class RemoteAuthProvider(AuthProvider): def get_routes( self, mcp_path: str | None = None, - mcp_endpoint: Any | None = None, ) -> list[Route]: """Get OAuth routes for this provider. - Creates protected resource metadata routes and optionally wraps MCP endpoints with auth. + Creates protected resource metadata routes. """ - # Start with base routes (protected MCP endpoint) - routes = super().get_routes(mcp_path, mcp_endpoint) + # Start with base routes + routes = super().get_routes(mcp_path) # Get the resource URL based on the MCP path resource_url = self._get_resource_url(mcp_path) @@ -326,14 +304,12 @@ class OAuthProvider( def get_routes( self, mcp_path: str | None = None, - mcp_endpoint: Any | None = None, ) -> list[Route]: """Get OAuth authorization server routes and optional protected resource routes. This method creates the full set of OAuth routes including: - Standard OAuth authorization server routes (/.well-known/oauth-authorization-server, /authorize, /token, etc.) - Optional protected resource routes - - Protected MCP endpoints if provided Returns: List of OAuth routes @@ -366,7 +342,7 @@ class OAuthProvider( ) oauth_routes.extend(protected_routes) - # Add protected MCP endpoint from base class - oauth_routes.extend(super().get_routes(mcp_path, mcp_endpoint)) + # Add base routes + oauth_routes.extend(super().get_routes(mcp_path)) return oauth_routes diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py index 6b64bda49..998aa05a6 100644 --- a/src/fastmcp/server/auth/oauth_proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy.py @@ -28,6 +28,13 @@ from urllib.parse import urlencode import httpx from authlib.common.security import generate_token from authlib.integrations.httpx_client import AsyncOAuth2Client +from key_value.aio.adapters.pydantic import PydanticAdapter +from key_value.aio.protocols import AsyncKeyValue +from key_value.aio.stores.memory import MemoryStore +from mcp.server.auth.handlers.token import TokenErrorResponse, TokenSuccessResponse +from mcp.server.auth.handlers.token import TokenHandler as _SDKTokenHandler +from mcp.server.auth.json_response import PydanticJSONResponse +from mcp.server.auth.middleware.client_auth import ClientAuthenticator from mcp.server.auth.provider import ( AccessToken, AuthorizationCode, @@ -35,12 +42,13 @@ from mcp.server.auth.provider import ( RefreshToken, TokenError, ) +from mcp.server.auth.routes import cors_middleware from mcp.server.auth.settings import ( ClientRegistrationOptions, RevocationOptions, ) from mcp.shared.auth import OAuthClientInformationFull, OAuthToken -from pydantic import AnyHttpUrl, AnyUrl, SecretStr +from pydantic import AnyHttpUrl, AnyUrl, Field, SecretStr from starlette.requests import Request from starlette.responses import RedirectResponse from starlette.routing import Route @@ -81,18 +89,7 @@ class ProxyDCRClient(OAuthClientInformationFull): arise from accepting arbitrary redirect URIs. """ - def __init__( - self, *args, allowed_redirect_uri_patterns: list[str] | None = None, **kwargs - ): - """Initialize with allowed redirect URI patterns. - - Args: - allowed_redirect_uri_patterns: List of allowed redirect URI patterns with wildcard support. - If None, defaults to localhost-only patterns. - If empty list, allows all redirect URIs. - """ - super().__init__(*args, **kwargs) - self._allowed_redirect_uri_patterns = allowed_redirect_uri_patterns + allowed_redirect_uri_patterns: list[str] | None = Field(default=None) def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl: """Validate redirect URI against allowed patterns. @@ -104,7 +101,10 @@ class ProxyDCRClient(OAuthClientInformationFull): """ if redirect_uri is not None: # Validate against allowed patterns - if validate_redirect_uri(redirect_uri, self._allowed_redirect_uri_patterns): + if validate_redirect_uri( + redirect_uri=redirect_uri, + allowed_patterns=self.allowed_redirect_uri_patterns, + ): return redirect_uri # Fall back to normal validation if not in allowed patterns return super().validate_redirect_uri(redirect_uri) @@ -120,6 +120,55 @@ DEFAULT_AUTH_CODE_EXPIRY_SECONDS: Final[int] = 5 * 60 # 5 minutes HTTP_TIMEOUT_SECONDS: Final[int] = 30 +class TokenHandler(_SDKTokenHandler): + """TokenHandler that returns OAuth 2.1 compliant error responses. + + The MCP SDK always returns HTTP 400 for all client authentication issues. + However, OAuth 2.1 Section 5.3 and the MCP specification require that + invalid or expired tokens MUST receive a HTTP 401 response. + + This handler extends the base MCP SDK TokenHandler to transform client + authentication failures into OAuth 2.1 compliant responses: + - Changes 'unauthorized_client' to 'invalid_client' error code + - Returns HTTP 401 status code instead of 400 for client auth failures + + Per OAuth 2.1 Section 5.3: "The authorization server MAY return an HTTP 401 + (Unauthorized) status code to indicate which HTTP authentication schemes + are supported." + + Per MCP spec: "Invalid or expired tokens MUST receive a HTTP 401 response." + """ + + def response(self, obj: TokenSuccessResponse | TokenErrorResponse): + """Override response method to provide OAuth 2.1 compliant error handling.""" + # Check if this is a client authentication failure (not just unauthorized for grant type) + # unauthorized_client can mean two things: + # 1. Client authentication failed (client_id not found or wrong credentials) -> invalid_client 401 + # 2. Client not authorized for this grant type -> unauthorized_client 400 (correct per spec) + if ( + isinstance(obj, TokenErrorResponse) + and obj.error == "unauthorized_client" + and obj.error_description + and "Invalid client_id" in obj.error_description + ): + # Transform client auth failure to OAuth 2.1 compliant response + return PydanticJSONResponse( + content=TokenErrorResponse( + error="invalid_client", + error_description=obj.error_description, + error_uri=obj.error_uri, + ), + status_code=401, + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + }, + ) + + # Otherwise use default behavior from parent class + return super().response(obj) + + class OAuthProxy(OAuthProvider): """OAuth provider that presents a DCR-compliant interface while proxying to non-DCR IDPs. @@ -199,7 +248,6 @@ class OAuthProxy(OAuthProvider): State Management --------------- The proxy maintains minimal but crucial state: - - _clients: DCR registrations (all use ProxyDCRClient for flexibility) - _oauth_transactions: Active authorization flows with client context - _client_codes: Authorization codes with PKCE challenges and upstream tokens - _access_tokens, _refresh_tokens: Token storage for revocation @@ -254,6 +302,8 @@ class OAuthProxy(OAuthProvider): extra_authorize_params: dict[str, str] | None = None, # Extra parameters to forward to token endpoint extra_token_params: dict[str, str] | None = None, + # Client storage + client_storage: AsyncKeyValue | None = None, ): """Initialize the OAuth proxy provider. @@ -277,7 +327,7 @@ class OAuthProxy(OAuthProvider): valid_scopes: List of all the possible valid scopes for a client. These are advertised to clients through the `/.well-known` endpoints. Defaults to `required_scopes` if not provided. forward_pkce: Whether to forward PKCE to upstream server (default True). - Enable for providers that support/require PKCE (Google, Azure, etc.). + Enable for providers that support/require PKCE (Google, Azure, AWS, etc.). Disable only if upstream provider doesn't support PKCE. token_endpoint_auth_method: Token endpoint authentication method for upstream server. Common values: "client_secret_basic", "client_secret_post", "none". @@ -287,6 +337,7 @@ class OAuthProxy(OAuthProvider): Example: {"audience": "https://api.example.com"} extra_token_params: Additional parameters to forward to the upstream token endpoint. Useful for provider-specific parameters during token exchange. + client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided """ # Always enable DCR since we implement it locally for MCP clients client_registration_options = ClientRegistrationOptions( @@ -335,8 +386,16 @@ class OAuthProxy(OAuthProvider): self._extra_authorize_params = extra_authorize_params or {} self._extra_token_params = extra_token_params or {} - # Local state for DCR and token bookkeeping - self._clients: dict[str, OAuthClientInformationFull] = {} + self._client_storage: AsyncKeyValue = client_storage or MemoryStore() + + self._client_store = PydanticAdapter[ProxyDCRClient]( + key_value=self._client_storage, + pydantic_model=ProxyDCRClient, + default_collection="mcp-oauth-proxy-clients", + raise_on_validation_error=True, + ) + + # Local state for token bookkeeping only (no client caching) self._access_tokens: dict[str, AccessToken] = {} self._refresh_tokens: dict[str, RefreshToken] = {} @@ -387,7 +446,12 @@ class OAuthProxy(OAuthProvider): For unregistered clients, returns None (which will raise an error in the SDK). """ - client = self._clients.get(client_id) + # Load from storage + if not (client := await self._client_store.get(key=client_id)): + return None + + if client.allowed_redirect_uri_patterns is None: + client.allowed_redirect_uri_patterns = self._allowed_client_redirect_uris return client @@ -401,19 +465,21 @@ class OAuthProxy(OAuthProvider): """ # Create a ProxyDCRClient with configured redirect URI validation - proxy_client = ProxyDCRClient( + proxy_client: ProxyDCRClient = ProxyDCRClient( client_id=client_info.client_id, client_secret=client_info.client_secret, redirect_uris=client_info.redirect_uris or [AnyUrl("http://localhost")], grant_types=client_info.grant_types or ["authorization_code", "refresh_token"], - scope=self._default_scope_str, + scope=client_info.scope or self._default_scope_str, token_endpoint_auth_method="none", allowed_redirect_uri_patterns=self._allowed_client_redirect_uris, ) - # Store the ProxyDCRClient - self._clients[client_info.client_id] = proxy_client + await self._client_store.put( + key=client_info.client_id, + value=proxy_client, + ) # Log redirect URIs to help users discover what patterns they might need if client_info.redirect_uris: @@ -703,8 +769,7 @@ class OAuthProxy(OAuthProvider): ) # Handle refresh token rotation if new one provided - if "refresh_token" in token_response: - new_refresh_token = token_response["refresh_token"] + if new_refresh_token := token_response.get("refresh_token"): if new_refresh_token != refresh_token.token: # Remove old refresh token self._refresh_tokens.pop(refresh_token.token, None) @@ -792,7 +857,6 @@ class OAuthProxy(OAuthProvider): def get_routes( self, mcp_path: str | None = None, - mcp_endpoint: Any | None = None, ) -> list[Route]: """Get OAuth routes with custom proxy token handler. @@ -801,10 +865,10 @@ class OAuthProxy(OAuthProvider): Args: mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - mcp_endpoint: The MCP endpoint handler to protect with auth + This is used to advertise the resource URL in metadata. """ # Get standard OAuth routes from parent class - routes = super().get_routes(mcp_path, mcp_endpoint) + routes = super().get_routes(mcp_path) custom_routes = [] token_route_found = False @@ -817,9 +881,7 @@ class OAuthProxy(OAuthProvider): f"Route {i}: {route} - path: {getattr(route, 'path', 'N/A')}, methods: {getattr(route, 'methods', 'N/A')}" ) - # Keep all standard OAuth routes unchanged - our DCR-compliant flow handles everything - custom_routes.append(route) - + # Replace the token endpoint with our custom handler that returns proper OAuth 2.1 error codes if ( isinstance(route, Route) and route.path == "/token" @@ -827,6 +889,22 @@ class OAuthProxy(OAuthProvider): and "POST" in route.methods ): token_route_found = True + # Replace with our OAuth 2.1 compliant token handler + token_handler = TokenHandler( + provider=self, client_authenticator=ClientAuthenticator(self) + ) + custom_routes.append( + Route( + path="/token", + endpoint=cors_middleware( + token_handler.handle, ["POST", "OPTIONS"] + ), + methods=["POST", "OPTIONS"], + ) + ) + else: + # Keep all other standard OAuth routes unchanged + custom_routes.append(route) # Add OAuth callback endpoint for forwarding to client callbacks custom_routes.append( diff --git a/src/fastmcp/server/auth/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py index 268b849b2..589e0e2d3 100644 --- a/src/fastmcp/server/auth/oidc_proxy.py +++ b/src/fastmcp/server/auth/oidc_proxy.py @@ -12,6 +12,7 @@ This implementation is based on: from collections.abc import Sequence import httpx +from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, BaseModel, model_validator from typing_extensions import Self @@ -34,15 +35,15 @@ class OIDCConfiguration(BaseModel): strict: bool = True # OpenID Connect Discovery 1.0 - issuer: AnyHttpUrl | None = None # Strict + issuer: AnyHttpUrl | str | None = None # Strict - authorization_endpoint: AnyHttpUrl | None = None # Strict - token_endpoint: AnyHttpUrl | None = None # Strict - userinfo_endpoint: AnyHttpUrl | None = None + authorization_endpoint: AnyHttpUrl | str | None = None # Strict + token_endpoint: AnyHttpUrl | str | None = None # Strict + userinfo_endpoint: AnyHttpUrl | str | None = None - jwks_uri: AnyHttpUrl | None = None # Strict + jwks_uri: AnyHttpUrl | str | None = None # Strict - registration_endpoint: AnyHttpUrl | None = None + registration_endpoint: AnyHttpUrl | str | None = None scopes_supported: Sequence[str] | None = None @@ -75,7 +76,7 @@ class OIDCConfiguration(BaseModel): claim_types_supported: Sequence[str] | None = None claims_supported: Sequence[str] | None = None - service_documentation: AnyHttpUrl | None = None + service_documentation: AnyHttpUrl | str | None = None claims_locales_supported: Sequence[str] | None = None ui_locales_supported: Sequence[str] | None = None @@ -86,15 +87,15 @@ class OIDCConfiguration(BaseModel): require_request_uri_registration: bool | None = None - op_policy_uri: AnyHttpUrl | None = None - op_tos_uri: AnyHttpUrl | None = None + op_policy_uri: AnyHttpUrl | str | None = None + op_tos_uri: AnyHttpUrl | str | None = None # OAuth 2.0 Authorization Server Metadata - revocation_endpoint: AnyHttpUrl | None = None + revocation_endpoint: AnyHttpUrl | str | None = None revocation_endpoint_auth_methods_supported: Sequence[str] | None = None revocation_endpoint_auth_signing_alg_values_supported: Sequence[str] | None = None - introspection_endpoint: AnyHttpUrl | None = None + introspection_endpoint: AnyHttpUrl | str | None = None introspection_endpoint_auth_methods_supported: Sequence[str] | None = None introspection_endpoint_auth_signing_alg_values_supported: Sequence[str] | None = ( None @@ -110,16 +111,27 @@ class OIDCConfiguration(BaseModel): if not self.strict: return self - def enforce(attr: str) -> None: - if not getattr(self, attr, None): + def enforce(attr: str, is_url: bool = False) -> None: + value = getattr(self, attr, None) + if not value: message = f"Missing required configuration metadata: {attr}" logger.error(message) raise ValueError(message) - enforce("issuer") - enforce("authorization_endpoint") - enforce("token_endpoint") - enforce("jwks_uri") + if not is_url or isinstance(value, AnyHttpUrl): + return + + try: + AnyHttpUrl(value) + except Exception: + message = f"Invalid URL for configuration metadata: {attr}" + logger.error(message) + raise ValueError(message) + + enforce("issuer", True) + enforce("authorization_endpoint", True) + enforce("token_endpoint", True) + enforce("jwks_uri", True) enforce("response_types_supported") enforce("subject_types_supported") enforce("id_token_signing_alg_values_supported") @@ -201,6 +213,7 @@ class OIDCProxy(OAuthProxy): redirect_path: str | None = None, # Client configuration allowed_client_redirect_uris: list[str] | None = None, + client_storage: AsyncKeyValue | None = None, # Token validation configuration token_endpoint_auth_method: str | None = None, ) -> None: @@ -223,6 +236,7 @@ class OIDCProxy(OAuthProxy): If None (default), only localhost redirect URIs are allowed. If empty list, all redirect URIs are allowed (not recommended for production). These are for MCP clients performing loopback redirects, NOT for the upstream OAuth app. + client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided token_endpoint_auth_method: Token endpoint authentication method for upstream server. Common values: "client_secret_basic", "client_secret_post", "none". If None, authlib will use its default (typically "client_secret_basic"). @@ -277,6 +291,7 @@ class OIDCProxy(OAuthProxy): "base_url": base_url, "service_documentation_url": self.oidc_config.service_documentation, "allowed_client_redirect_uris": allowed_client_redirect_uris, + "client_storage": client_storage, "token_endpoint_auth_method": token_endpoint_auth_method, } diff --git a/src/fastmcp/server/auth/providers/auth0.py b/src/fastmcp/server/auth/providers/auth0.py index 8fa6a70af..24d3020b6 100644 --- a/src/fastmcp/server/auth/providers/auth0.py +++ b/src/fastmcp/server/auth/providers/auth0.py @@ -21,10 +21,12 @@ Example: ``` """ +from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict from fastmcp.server.auth.oidc_proxy import OIDCProxy +from fastmcp.settings import ENV_FILE from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -37,7 +39,7 @@ class Auth0ProviderSettings(BaseSettings): model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_AUTH0_", - env_file=".env", + env_file=ENV_FILE, extra="ignore", ) @@ -91,6 +93,7 @@ class Auth0Provider(OIDCProxy): required_scopes: list[str] | NotSetT = NotSet, redirect_path: str | NotSetT = NotSet, allowed_client_redirect_uris: list[str] | NotSetT = NotSet, + client_storage: AsyncKeyValue | None = None, ) -> None: """Initialize Auth0 OAuth provider. @@ -104,6 +107,7 @@ class Auth0Provider(OIDCProxy): redirect_path: Redirect path configured in Auth0 application allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. + client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided """ settings = Auth0ProviderSettings.model_validate( { @@ -155,13 +159,12 @@ class Auth0Provider(OIDCProxy): "client_secret": settings.client_secret.get_secret_value(), "audience": settings.audience, "base_url": settings.base_url, + "redirect_path": settings.redirect_path, "required_scopes": auth0_required_scopes, "allowed_client_redirect_uris": settings.allowed_client_redirect_uris, + "client_storage": client_storage, } - if settings.redirect_path: - init_kwargs["redirect_path"] = settings.redirect_path - super().__init__(**init_kwargs) logger.info( diff --git a/src/fastmcp/server/auth/providers/aws.py b/src/fastmcp/server/auth/providers/aws.py new file mode 100644 index 000000000..dccdefb57 --- /dev/null +++ b/src/fastmcp/server/auth/providers/aws.py @@ -0,0 +1,238 @@ +"""AWS Cognito OAuth provider for FastMCP. + +This module provides a complete AWS Cognito OAuth integration that's ready to use +with a user pool ID, domain prefix, client ID and client secret. It handles all +the complexity of AWS Cognito's OAuth flow, token validation, and user management. + +Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.auth.providers.aws_cognito import AWSCognitoProvider + + # Simple AWS Cognito OAuth protection + auth = AWSCognitoProvider( + user_pool_id="your-user-pool-id", + aws_region="eu-central-1", + client_id="your-cognito-client-id", + client_secret="your-cognito-client-secret" + ) + + mcp = FastMCP("My Protected Server", auth=auth) + ``` +""" + +from __future__ import annotations + +from pydantic import AnyHttpUrl, SecretStr, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from fastmcp.server.auth import TokenVerifier +from fastmcp.server.auth.auth import AccessToken +from fastmcp.server.auth.oidc_proxy import OIDCProxy +from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.settings import ENV_FILE +from fastmcp.utilities.auth import parse_scopes +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.types import NotSet, NotSetT + +logger = get_logger(__name__) + + +class AWSCognitoProviderSettings(BaseSettings): + """Settings for AWS Cognito OAuth provider.""" + + model_config = SettingsConfigDict( + env_prefix="FASTMCP_SERVER_AUTH_AWS_COGNITO_", + env_file=ENV_FILE, + extra="ignore", + ) + + user_pool_id: str | None = None + aws_region: str | None = None + client_id: str | None = None + client_secret: SecretStr | None = None + base_url: AnyHttpUrl | str | None = None + redirect_path: str | None = None + required_scopes: list[str] | None = None + allowed_client_redirect_uris: list[str] | None = None + + @field_validator("required_scopes", mode="before") + @classmethod + def _parse_scopes(cls, v): + return parse_scopes(v) + + +class AWSCognitoTokenVerifier(JWTVerifier): + """Token verifier that filters claims to Cognito-specific subset.""" + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify token and filter claims to Cognito-specific subset.""" + # Use base JWT verification + access_token = await super().verify_token(token) + if not access_token: + return None + + # Filter claims to Cognito-specific subset + cognito_claims = { + "sub": access_token.claims.get("sub"), + "username": access_token.claims.get("username"), + "cognito:groups": access_token.claims.get("cognito:groups", []), + } + + # Return new AccessToken with filtered claims + return AccessToken( + token=access_token.token, + client_id=access_token.client_id, + scopes=access_token.scopes, + expires_at=access_token.expires_at, + claims=cognito_claims, + ) + + +class AWSCognitoProvider(OIDCProxy): + """Complete AWS Cognito OAuth provider for FastMCP. + + This provider makes it trivial to add AWS Cognito OAuth protection to any + FastMCP server using OIDC Discovery. Just provide your Cognito User Pool details, + client credentials, and a base URL, and you're ready to go. + + Features: + - Automatic OIDC Discovery from AWS Cognito User Pool + - Automatic JWT token validation via Cognito's public keys + - Cognito-specific claim filtering (sub, username, cognito:groups) + - Support for Cognito User Pools + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.auth.providers.aws_cognito import AWSCognitoProvider + + auth = AWSCognitoProvider( + user_pool_id="eu-central-1_XXXXXXXXX", + aws_region="eu-central-1", + client_id="your-cognito-client-id", + client_secret="your-cognito-client-secret", + base_url="https://my-server.com", + redirect_path="/custom/callback", + ) + + mcp = FastMCP("My App", auth=auth) + ``` + """ + + def __init__( + self, + *, + user_pool_id: str | NotSetT = NotSet, + aws_region: str | NotSetT = NotSet, + client_id: str | NotSetT = NotSet, + client_secret: str | NotSetT = NotSet, + base_url: AnyHttpUrl | str | NotSetT = NotSet, + redirect_path: str | NotSetT = NotSet, + required_scopes: list[str] | NotSetT = NotSet, + allowed_client_redirect_uris: list[str] | NotSetT = NotSet, + ): + """Initialize AWS Cognito OAuth provider. + + Args: + user_pool_id: Your Cognito User Pool ID (e.g., "eu-central-1_XXXXXXXXX") + aws_region: AWS region where your User Pool is located (defaults to "eu-central-1") + client_id: Cognito app client ID + client_secret: Cognito app client secret + base_url: Public URL of your FastMCP server (for OAuth callbacks) + redirect_path: Redirect path configured in Cognito app (defaults to "/auth/callback") + required_scopes: Required Cognito scopes (defaults to ["openid"]) + allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. + If None (default), all URIs are allowed. If empty list, no URIs are allowed. + """ + + settings = AWSCognitoProviderSettings.model_validate( + { + k: v + for k, v in { + "user_pool_id": user_pool_id, + "aws_region": aws_region, + "client_id": client_id, + "client_secret": client_secret, + "base_url": base_url, + "redirect_path": redirect_path, + "required_scopes": required_scopes, + "allowed_client_redirect_uris": allowed_client_redirect_uris, + }.items() + if v is not NotSet + } + ) + + # Validate required settings + if not settings.user_pool_id: + raise ValueError( + "user_pool_id is required - set via parameter or FASTMCP_SERVER_AUTH_AWS_COGNITO_USER_POOL_ID" + ) + if not settings.client_id: + raise ValueError( + "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_ID" + ) + if not settings.client_secret: + raise ValueError( + "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_SECRET" + ) + + # Apply defaults + required_scopes_final = settings.required_scopes or ["openid"] + allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris + aws_region_final = settings.aws_region or "eu-central-1" + redirect_path_final = settings.redirect_path or "/auth/callback" + + # Construct OIDC discovery URL + config_url = f"https://cognito-idp.{aws_region_final}.amazonaws.com/{settings.user_pool_id}/.well-known/openid-configuration" + + # Extract secret string from SecretStr + client_secret_str = ( + settings.client_secret.get_secret_value() if settings.client_secret else "" + ) + + # Store Cognito-specific info for claim filtering + self.user_pool_id = settings.user_pool_id + self.aws_region = aws_region_final + + # Initialize OIDC proxy with Cognito discovery + super().__init__( + config_url=config_url, + client_id=settings.client_id, + client_secret=client_secret_str, + algorithm="RS256", + required_scopes=required_scopes_final, + base_url=settings.base_url, + redirect_path=redirect_path_final, + allowed_client_redirect_uris=allowed_client_redirect_uris_final, + ) + + logger.info( + "Initialized AWS Cognito OAuth provider for client %s with scopes: %s", + settings.client_id, + required_scopes_final, + ) + + def get_token_verifier( + self, + *, + algorithm: str | None = None, + audience: str | None = None, + required_scopes: list[str] | None = None, + timeout_seconds: int | None = None, + ) -> TokenVerifier: + """Creates a Cognito-specific token verifier with claim filtering. + + Args: + algorithm: Optional token verifier algorithm + audience: Optional token verifier audience + required_scopes: Optional token verifier required_scopes + timeout_seconds: HTTP request timeout in seconds + """ + return AWSCognitoTokenVerifier( + issuer=str(self.oidc_config.issuer), + audience=audience, + algorithm=algorithm, + jwks_uri=str(self.oidc_config.jwks_uri), + required_scopes=required_scopes, + ) diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 84a3d4791..723d79152 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -6,16 +6,23 @@ using the OAuth Proxy pattern for non-DCR OAuth flows. from __future__ import annotations -import httpx +from typing import TYPE_CHECKING + +from key_value.aio.protocols import AsyncKeyValue from pydantic import SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict -from fastmcp.server.auth import AccessToken, TokenVerifier from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.settings import ENV_FILE from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT +if TYPE_CHECKING: + from mcp.server.auth.provider import AuthorizationParams + from mcp.shared.auth import OAuthClientInformationFull + logger = get_logger(__name__) @@ -24,94 +31,29 @@ class AzureProviderSettings(BaseSettings): model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_AZURE_", - env_file=".env", + env_file=ENV_FILE, extra="ignore", ) client_id: str | None = None client_secret: SecretStr | None = None tenant_id: str | None = None + identifier_uri: str | None = None base_url: str | None = None redirect_path: str | None = None required_scopes: list[str] | None = None - timeout_seconds: int | None = None + additional_authorize_scopes: list[str] | None = None allowed_client_redirect_uris: list[str] | None = None @field_validator("required_scopes", mode="before") @classmethod - def _parse_scopes(cls, v): + def _parse_scopes(cls, v: object) -> list[str] | None: return parse_scopes(v) - -class AzureTokenVerifier(TokenVerifier): - """Token verifier for Azure OAuth tokens. - - Azure tokens are JWTs, but we verify them by calling the Microsoft Graph API - to get user information and validate the token. - """ - - def __init__( - self, - *, - required_scopes: list[str] | None = None, - timeout_seconds: int = 10, - ): - """Initialize the Azure token verifier. - - Args: - required_scopes: Required OAuth scopes - timeout_seconds: HTTP request timeout - """ - super().__init__(required_scopes=required_scopes) - self.timeout_seconds = timeout_seconds - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify Azure OAuth token by calling Microsoft Graph API.""" - try: - async with httpx.AsyncClient(timeout=self.timeout_seconds) as client: - # Use Microsoft Graph API to validate token and get user info - response = await client.get( - "https://graph.microsoft.com/v1.0/me", - headers={ - "Authorization": f"Bearer {token}", - "User-Agent": "FastMCP-Azure-OAuth", - }, - ) - - if response.status_code != 200: - logger.debug( - "Azure token verification failed: %d - %s", - response.status_code, - response.text[:200], - ) - return None - - user_data = response.json() - - # Create AccessToken with Azure user info - return AccessToken( - token=token, - client_id=str(user_data.get("id", "unknown")), - scopes=self.required_scopes or [], - expires_at=None, - claims={ - "sub": user_data.get("id"), - "email": user_data.get("mail") - or user_data.get("userPrincipalName"), - "name": user_data.get("displayName"), - "given_name": user_data.get("givenName"), - "family_name": user_data.get("surname"), - "job_title": user_data.get("jobTitle"), - "office_location": user_data.get("officeLocation"), - }, - ) - - except httpx.RequestError as e: - logger.debug("Failed to verify Azure token: %s", e) - return None - except Exception as e: - logger.debug("Azure token verification error: %s", e) - return None + @field_validator("additional_authorize_scopes", mode="before") + @classmethod + def _parse_additional_authorize_scopes(cls, v: object) -> list[str] | None: + return parse_scopes(v) class AzureProvider(OAuthProxy): @@ -122,16 +64,17 @@ class AzureProvider(OAuthProxy): Microsoft accounts depending on the tenant configuration. Features: - - Transparent OAuth proxy to Azure/Microsoft identity platform - - Automatic token validation via Microsoft Graph API - - User information extraction - - Support for different tenant configurations (common, organizations, consumers) + - OAuth proxy to Azure/Microsoft identity platform + - JWT validation using tenant issuer and JWKS + - Supports tenant configurations: specific tenant ID, "organizations", or "consumers" - Setup Requirements: - 1. Register an application in Azure Portal (portal.azure.com) - 2. Configure redirect URI as: http://localhost:8000/auth/callback - 3. Note your Application (client) ID and create a client secret - 4. Optionally note your Directory (tenant) ID for single-tenant apps + Setup: + 1. Create an App registration in Azure Portal + 2. Configure Web platform redirect URI: http://localhost:8000/auth/callback (or your custom path) + 3. Add an Application ID URI. Either use the default (api://{client_id}) or set a custom one. + 4. Add a custom scope. + 5. Create a client secret. + 6. Get Application (client) ID, Directory (tenant) ID, and client secret Example: ```python @@ -141,8 +84,10 @@ class AzureProvider(OAuthProxy): auth = AzureProvider( client_id="your-client-id", client_secret="your-client-secret", - tenant_id="your-tenant-id", # Required: your Azure tenant ID from Azure Portal - base_url="http://localhost:8000" + tenant_id="your-tenant-id", + required_scopes=["your-scope"], + base_url="http://localhost:8000", + # identifier_uri defaults to api://{client_id} ) mcp = FastMCP("My App", auth=auth) @@ -155,24 +100,33 @@ class AzureProvider(OAuthProxy): client_id: str | NotSetT = NotSet, client_secret: str | NotSetT = NotSet, tenant_id: str | NotSetT = NotSet, + identifier_uri: str | None | NotSetT = NotSet, base_url: str | NotSetT = NotSet, redirect_path: str | NotSetT = NotSet, required_scopes: list[str] | None | NotSetT = NotSet, - timeout_seconds: int | NotSetT = NotSet, + additional_authorize_scopes: list[str] | None | NotSetT = NotSet, allowed_client_redirect_uris: list[str] | NotSetT = NotSet, - ): + client_storage: AsyncKeyValue | None = None, + ) -> None: """Initialize Azure OAuth provider. Args: client_id: Azure application (client) ID client_secret: Azure client secret tenant_id: Azure tenant ID (your specific tenant ID, "organizations", or "consumers") + identifier_uri: Optional Application ID URI for your API. (defaults to api://{client_id}) + Used only to prefix scopes in authorization requests. Tokens are always validated + against your app's client ID. base_url: Public URL of your FastMCP server (for OAuth callbacks) redirect_path: Redirect path configured in Azure (defaults to "/auth/callback") - required_scopes: Required scopes (defaults to ["User.Read", "email", "openid", "profile"]) - timeout_seconds: HTTP request timeout for Azure API calls + required_scopes: Required scopes. These are validated on tokens and used as defaults + when the client does not request specific scopes. + additional_authorize_scopes: Additional scopes to include in the authorization request + without prefixing. Use this to request upstream scopes such as Microsoft Graph + permissions. These are not used for token validation. allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. + client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided """ settings = AzureProviderSettings.model_validate( { @@ -181,10 +135,11 @@ class AzureProvider(OAuthProxy): "client_id": client_id, "client_secret": client_secret, "tenant_id": tenant_id, + "identifier_uri": identifier_uri, "base_url": base_url, "redirect_path": redirect_path, "required_scopes": required_scopes, - "timeout_seconds": timeout_seconds, + "additional_authorize_scopes": additional_authorize_scopes, "allowed_client_redirect_uris": allowed_client_redirect_uris, }.items() if v is not NotSet @@ -193,45 +148,48 @@ class AzureProvider(OAuthProxy): # Validate required settings if not settings.client_id: - raise ValueError( - "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID" - ) + msg = "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID" + raise ValueError(msg) if not settings.client_secret: - raise ValueError( - "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET" - ) + msg = "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET" + raise ValueError(msg) # Validate tenant_id is provided if not settings.tenant_id: - raise ValueError( - "tenant_id is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_TENANT_ID. " - "Use your Azure tenant ID (found in Azure Portal), 'organizations', or 'consumers'" + msg = ( + "tenant_id is required - set via parameter or " + "FASTMCP_SERVER_AUTH_AZURE_TENANT_ID. Use your Azure tenant ID " + "(found in Azure Portal), 'organizations', or 'consumers'" ) + raise ValueError(msg) + + if not settings.required_scopes: + raise ValueError("required_scopes is required") # Apply defaults + self.identifier_uri = settings.identifier_uri or f"api://{settings.client_id}" + self.additional_authorize_scopes = settings.additional_authorize_scopes or [] tenant_id_final = settings.tenant_id - timeout_seconds_final = settings.timeout_seconds or 10 - # Default scopes for Azure - User.Read gives us access to user info via Graph API - scopes_final = settings.required_scopes or [ - "User.Read", - "email", - "openid", - "profile", - ] - allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris + # Always validate tokens against the app's API client ID using JWT + issuer = f"https://login.microsoftonline.com/{tenant_id_final}/v2.0" + jwks_uri = ( + f"https://login.microsoftonline.com/{tenant_id_final}/discovery/v2.0/keys" + ) + + token_verifier = JWTVerifier( + jwks_uri=jwks_uri, + issuer=issuer, + audience=settings.client_id, + algorithm="RS256", + required_scopes=settings.required_scopes, + ) # Extract secret string from SecretStr client_secret_str = ( settings.client_secret.get_secret_value() if settings.client_secret else "" ) - # Create Azure token verifier - token_verifier = AzureTokenVerifier( - required_scopes=scopes_final, - timeout_seconds=timeout_seconds_final, - ) - # Build Azure OAuth endpoints with tenant authorization_endpoint = ( f"https://login.microsoftonline.com/{tenant_id_final}/oauth2/v2.0/authorize" @@ -250,11 +208,65 @@ class AzureProvider(OAuthProxy): base_url=settings.base_url, redirect_path=settings.redirect_path, issuer_url=settings.base_url, - allowed_client_redirect_uris=allowed_client_redirect_uris_final, + allowed_client_redirect_uris=settings.allowed_client_redirect_uris, + client_storage=client_storage, ) logger.info( - "Initialized Azure OAuth provider for client %s with tenant %s", + "Initialized Azure OAuth provider for client %s with tenant %s%s", settings.client_id, tenant_id_final, + f" and identifier_uri {self.identifier_uri}" if self.identifier_uri else "", ) + + async def authorize( + self, + client: OAuthClientInformationFull, + params: AuthorizationParams, + ) -> str: + """Start OAuth transaction and redirect to Azure AD. + + Override parent's authorize method to filter out the 'resource' parameter + which is not supported by Azure AD v2.0 endpoints. The v2.0 endpoints use + scopes to determine the resource/audience instead of a separate parameter. + + Args: + client: OAuth client information + params: Authorization parameters from the client + + Returns: + Authorization URL to redirect the user to Azure AD + """ + # Clear the resource parameter that Azure AD v2.0 doesn't support + # This parameter comes from RFC 8707 (OAuth 2.0 Resource Indicators) + # but Azure AD v2.0 uses scopes instead to determine the audience + params_to_use = params + if hasattr(params, "resource"): + original_resource = getattr(params, "resource", None) + if original_resource is not None: + params_to_use = params.model_copy(update={"resource": None}) + if original_resource: + logger.debug( + "Filtering out 'resource' parameter '%s' for Azure AD v2.0 (use scopes instead)", + original_resource, + ) + original_scopes = params_to_use.scopes or self.required_scopes + prefixed_scopes = ( + self._add_prefix_to_scopes(original_scopes) + if self.identifier_uri + else original_scopes + ) + + final_scopes = list(prefixed_scopes) + if self.additional_authorize_scopes: + final_scopes.extend(self.additional_authorize_scopes) + + modified_params = params_to_use.model_copy(update={"scopes": final_scopes}) + + auth_url = await super().authorize(client, modified_params) + separator = "&" if "?" in auth_url else "?" + return f"{auth_url}{separator}prompt=select_account" + + def _add_prefix_to_scopes(self, scopes: list[str]) -> list[str]: + """Add Application ID URI prefix for authorization request.""" + return [f"{self.identifier_uri}/{scope}" for scope in scopes] diff --git a/src/fastmcp/server/auth/providers/descope.py b/src/fastmcp/server/auth/providers/descope.py new file mode 100644 index 000000000..43163c6f8 --- /dev/null +++ b/src/fastmcp/server/auth/providers/descope.py @@ -0,0 +1,170 @@ +"""Descope authentication provider for FastMCP. + +This module provides DescopeProvider - a complete authentication solution that integrates +with Descope's OAuth 2.1 and OpenID Connect services, supporting Dynamic Client Registration (DCR) +for seamless MCP client authentication. +""" + +from __future__ import annotations + +import httpx +from pydantic import AnyHttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict +from starlette.responses import JSONResponse +from starlette.routing import Route + +from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier +from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.settings import ENV_FILE +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.types import NotSet, NotSetT + +logger = get_logger(__name__) + + +class DescopeProviderSettings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_", + env_file=ENV_FILE, + extra="ignore", + ) + + project_id: str + base_url: AnyHttpUrl + descope_base_url: AnyHttpUrl = AnyHttpUrl("https://api.descope.com") + + +class DescopeProvider(RemoteAuthProvider): + """Descope metadata provider for DCR (Dynamic Client Registration). + + This provider implements Descope integration using metadata forwarding. + This is the recommended approach for Descope DCR + as it allows Descope to handle the OAuth flow directly while FastMCP acts + as a resource server. + + IMPORTANT SETUP REQUIREMENTS: + + 1. Enable Dynamic Client Registration in Descope Console: + - Go to the [Inbound Apps page](https://app.descope.com/apps/inbound) of the Descope Console + - Click **DCR Settings** + - Enable **Dynamic Client Registration (DCR)** + - Define allowed scopes + + 2. Note your Project ID: + - Save your Project ID from [Project Settings](https://app.descope.com/settings/project) + - Example: P2abc...123 + + For detailed setup instructions, see: + https://docs.descope.com/identity-federation/inbound-apps/creating-inbound-apps#method-2-dynamic-client-registration-dcr + + Example: + ```python + from fastmcp.server.auth.providers.descope import DescopeProvider + + # Create Descope metadata provider (JWT verifier created automatically) + descope_auth = DescopeProvider( + project_id="P2abc...123", + base_url="https://your-fastmcp-server.com", + descope_base_url="https://api.descope.com", + ) + + # Use with FastMCP + mcp = FastMCP("My App", auth=descope_auth) + ``` + """ + + def __init__( + self, + *, + project_id: str | NotSetT = NotSet, + base_url: AnyHttpUrl | str | NotSetT = NotSet, + descope_base_url: AnyHttpUrl | str | NotSetT = NotSet, + token_verifier: TokenVerifier | None = None, + ): + """Initialize Descope metadata provider. + + Args: + project_id: Your Descope Project ID (e.g., "P2abc...123") + base_url: Public URL of this FastMCP server + descope_base_url: Descope API base URL (defaults to https://api.descope.com) + token_verifier: Optional token verifier. If None, creates JWT verifier for Descope + """ + settings = DescopeProviderSettings.model_validate( + { + k: v + for k, v in { + "project_id": project_id, + "base_url": base_url, + "descope_base_url": descope_base_url, + }.items() + if v is not NotSet + } + ) + + self.project_id = settings.project_id + self.base_url = str(settings.base_url).rstrip("/") + self.descope_base_url = str(settings.descope_base_url).rstrip("/") + + # Create default JWT verifier if none provided + if token_verifier is None: + token_verifier = JWTVerifier( + jwks_uri=f"{self.descope_base_url}/{self.project_id}/.well-known/jwks.json", + issuer=f"{self.descope_base_url}/v1/apps/{self.project_id}", + algorithm="RS256", + audience=self.project_id, + ) + + # Initialize RemoteAuthProvider with Descope as the authorization server + super().__init__( + token_verifier=token_verifier, + authorization_servers=[ + AnyHttpUrl(f"{self.descope_base_url}/v1/apps/{self.project_id}") + ], + base_url=self.base_url, + ) + + def get_routes( + self, + mcp_path: str | None = None, + ) -> list[Route]: + """Get OAuth routes including Descope authorization server metadata forwarding. + + This returns the standard protected resource routes plus an authorization server + metadata endpoint that forwards Descope's OAuth metadata to clients. + + Args: + mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") + This is used to advertise the resource URL in metadata. + """ + # Get the standard protected resource routes from RemoteAuthProvider + routes = super().get_routes(mcp_path) + + async def oauth_authorization_server_metadata(request): + """Forward Descope OAuth authorization server metadata with FastMCP customizations.""" + try: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.descope_base_url}/v1/apps/{self.project_id}/.well-known/oauth-authorization-server" + ) + response.raise_for_status() + metadata = response.json() + return JSONResponse(metadata) + except Exception as e: + return JSONResponse( + { + "error": "server_error", + "error_description": f"Failed to fetch Descope metadata: {e}", + }, + status_code=500, + ) + + # Add Descope authorization server metadata forwarding + routes.append( + Route( + "/.well-known/oauth-authorization-server", + endpoint=oauth_authorization_server_metadata, + methods=["GET"], + ) + ) + + return routes diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py index 2a9490ff9..0846bd03f 100644 --- a/src/fastmcp/server/auth/providers/github.py +++ b/src/fastmcp/server/auth/providers/github.py @@ -22,12 +22,14 @@ Example: from __future__ import annotations import httpx +from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.settings import ENV_FILE from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -40,7 +42,7 @@ class GitHubProviderSettings(BaseSettings): model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_GITHUB_", - env_file=".env", + env_file=ENV_FILE, extra="ignore", ) @@ -201,6 +203,7 @@ class GitHubProvider(OAuthProxy): required_scopes: list[str] | NotSetT = NotSet, timeout_seconds: int | NotSetT = NotSet, allowed_client_redirect_uris: list[str] | NotSetT = NotSet, + client_storage: AsyncKeyValue | None = None, ): """Initialize GitHub OAuth provider. @@ -213,6 +216,7 @@ class GitHubProvider(OAuthProxy): timeout_seconds: HTTP request timeout for GitHub API calls allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. + client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided """ settings = GitHubProviderSettings.model_validate( @@ -269,6 +273,7 @@ class GitHubProvider(OAuthProxy): redirect_path=settings.redirect_path, issuer_url=settings.base_url, # We act as the issuer for client registration allowed_client_redirect_uris=allowed_client_redirect_uris_final, + client_storage=client_storage, ) logger.info( diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py index a8b0312c3..71cb29472 100644 --- a/src/fastmcp/server/auth/providers/google.py +++ b/src/fastmcp/server/auth/providers/google.py @@ -24,12 +24,14 @@ from __future__ import annotations import time import httpx +from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.settings import ENV_FILE from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -42,7 +44,7 @@ class GoogleProviderSettings(BaseSettings): model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_GOOGLE_", - env_file=".env", + env_file=ENV_FILE, extra="ignore", ) @@ -217,6 +219,7 @@ class GoogleProvider(OAuthProxy): required_scopes: list[str] | NotSetT = NotSet, timeout_seconds: int | NotSetT = NotSet, allowed_client_redirect_uris: list[str] | NotSetT = NotSet, + client_storage: AsyncKeyValue | None = None, ): """Initialize Google OAuth provider. @@ -232,6 +235,7 @@ class GoogleProvider(OAuthProxy): timeout_seconds: HTTP request timeout for Google API calls allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. + client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided """ settings = GoogleProviderSettings.model_validate( @@ -288,6 +292,7 @@ class GoogleProvider(OAuthProxy): redirect_path=settings.redirect_path, issuer_url=settings.base_url, # We act as the issuer for client registration allowed_client_redirect_uris=allowed_client_redirect_uris_final, + client_storage=client_storage, ) logger.info( diff --git a/src/fastmcp/server/auth/providers/jwt.py b/src/fastmcp/server/auth/providers/jwt.py index 5eccb82b3..c33d122ef 100644 --- a/src/fastmcp/server/auth/providers/jwt.py +++ b/src/fastmcp/server/auth/providers/jwt.py @@ -16,6 +16,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from typing_extensions import TypedDict from fastmcp.server.auth import AccessToken, TokenVerifier +from fastmcp.settings import ENV_FILE from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -143,7 +144,7 @@ class JWTVerifierSettings(BaseSettings): model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_JWT_", - env_file=".env", + env_file=ENV_FILE, extra="ignore", ) diff --git a/src/fastmcp/server/auth/providers/scalekit.py b/src/fastmcp/server/auth/providers/scalekit.py new file mode 100644 index 000000000..1aefaf052 --- /dev/null +++ b/src/fastmcp/server/auth/providers/scalekit.py @@ -0,0 +1,179 @@ +"""Scalekit authentication provider for FastMCP. + +This module provides ScalekitProvider - a complete authentication solution that integrates +with Scalekit's OAuth 2.1 and OpenID Connect services, supporting Resource Server +authentication for seamless MCP client authentication. +""" + +from __future__ import annotations + +import httpx +from pydantic import AnyHttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict +from starlette.responses import JSONResponse +from starlette.routing import Route + +from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier +from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.settings import ENV_FILE +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.types import NotSet, NotSetT + +logger = get_logger(__name__) + + +class ScalekitProviderSettings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_", + env_file=ENV_FILE, + extra="ignore", + ) + + environment_url: AnyHttpUrl + client_id: str + resource_id: str + mcp_url: AnyHttpUrl + + +class ScalekitProvider(RemoteAuthProvider): + """Scalekit resource server provider for OAuth 2.1 authentication. + + This provider implements Scalekit integration using resource server pattern. + FastMCP acts as a protected resource server that validates access tokens issued + by Scalekit's authorization server. + + IMPORTANT SETUP REQUIREMENTS: + + 1. Create an MCP Server in Scalekit Dashboard: + - Go to your [Scalekit Dashboard](https://app.scalekit.com/) + - Navigate to MCP Servers section + - Register a new MCP Server with appropriate scopes + - Ensure the Resource Identifier matches exactly what you configure as MCP URL + - Note the Resource ID + + 2. Environment Configuration: + - Set SCALEKIT_ENVIRONMENT_URL (e.g., https://your-env.scalekit.com) + - Set SCALEKIT_CLIENT_ID from your OAuth application + - Set SCALEKIT_RESOURCE_ID from your created resource + - Set MCP_URL to your FastMCP server's public URL + + For detailed setup instructions, see: + https://docs.scalekit.com/mcp/overview/ + + Example: + ```python + from fastmcp.server.auth.providers.scalekit import ScalekitProvider + + # Create Scalekit resource server provider + scalekit_auth = ScalekitProvider( + environment_url="https://your-env.scalekit.com", + client_id="sk_client_...", + resource_id="sk_resource_...", + mcp_url="https://your-fastmcp-server.com", + ) + + # Use with FastMCP + mcp = FastMCP("My App", auth=scalekit_auth) + ``` + """ + + def __init__( + self, + *, + environment_url: AnyHttpUrl | str | NotSetT = NotSet, + client_id: str | NotSetT = NotSet, + resource_id: str | NotSetT = NotSet, + mcp_url: AnyHttpUrl | str | NotSetT = NotSet, + token_verifier: TokenVerifier | None = None, + ): + """Initialize Scalekit resource server provider. + + Args: + environment_url: Your Scalekit environment URL (e.g., "https://your-env.scalekit.com") + client_id: Your Scalekit OAuth client ID + resource_id: Your Scalekit resource ID + mcp_url: Public URL of this FastMCP server (used as audience) + token_verifier: Optional token verifier. If None, creates JWT verifier for Scalekit + """ + settings = ScalekitProviderSettings.model_validate( + { + k: v + for k, v in { + "environment_url": environment_url, + "client_id": client_id, + "resource_id": resource_id, + "mcp_url": mcp_url, + }.items() + if v is not NotSet + } + ) + + self.environment_url = str(settings.environment_url).rstrip("/") + self.client_id = settings.client_id + self.resource_id = settings.resource_id + self.mcp_url = str(settings.mcp_url) + + # Create default JWT verifier if none provided + if token_verifier is None: + token_verifier = JWTVerifier( + jwks_uri=f"{self.environment_url}/keys", + issuer=self.environment_url, + algorithm="RS256", + audience=self.mcp_url, + ) + + # Initialize RemoteAuthProvider with Scalekit as the authorization server + super().__init__( + token_verifier=token_verifier, + authorization_servers=[ + AnyHttpUrl(f"{self.environment_url}/resources/{self.resource_id}") + ], + base_url=self.mcp_url, + ) + + def get_routes( + self, + mcp_path: str | None = None, + ) -> list[Route]: + """Get OAuth routes including Scalekit authorization server metadata forwarding. + + This returns the standard protected resource routes plus an authorization server + metadata endpoint that forwards Scalekit's OAuth metadata to clients. + + Args: + mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") + This is used to advertise the resource URL in metadata. + """ + # Get the standard protected resource routes from RemoteAuthProvider + routes = super().get_routes(mcp_path) + + async def oauth_authorization_server_metadata(request): + """Forward Scalekit OAuth authorization server metadata with FastMCP customizations.""" + try: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.environment_url}/.well-known/oauth-authorization-server/resources/{self.resource_id}" + ) + response.raise_for_status() + metadata = response.json() + return JSONResponse(metadata) + except Exception as e: + logger.error(f"Failed to fetch Scalekit metadata: {e}") + return JSONResponse( + { + "error": "server_error", + "error_description": f"Failed to fetch Scalekit metadata: {e}", + }, + status_code=500, + ) + + # Add Scalekit authorization server metadata forwarding + routes.append( + Route( + "/.well-known/oauth-authorization-server", + endpoint=oauth_authorization_server_metadata, + methods=["GET"], + ) + ) + + return routes diff --git a/src/fastmcp/server/auth/providers/supabase.py b/src/fastmcp/server/auth/providers/supabase.py new file mode 100644 index 000000000..40019d688 --- /dev/null +++ b/src/fastmcp/server/auth/providers/supabase.py @@ -0,0 +1,172 @@ +"""Supabase authentication provider for FastMCP. + +This module provides SupabaseProvider - a complete authentication solution that integrates +with Supabase Auth's JWT verification, supporting Dynamic Client Registration (DCR) +for seamless MCP client authentication. +""" + +from __future__ import annotations + +import httpx +from pydantic import AnyHttpUrl, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict +from starlette.responses import JSONResponse +from starlette.routing import Route + +from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier +from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.settings import ENV_FILE +from fastmcp.utilities.auth import parse_scopes +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.types import NotSet, NotSetT + +logger = get_logger(__name__) + + +class SupabaseProviderSettings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="FASTMCP_SERVER_AUTH_SUPABASE_", + env_file=ENV_FILE, + extra="ignore", + ) + + project_url: AnyHttpUrl + base_url: AnyHttpUrl + required_scopes: list[str] | None = None + + @field_validator("required_scopes", mode="before") + @classmethod + def _parse_scopes(cls, v): + return parse_scopes(v) + + +class SupabaseProvider(RemoteAuthProvider): + """Supabase metadata provider for DCR (Dynamic Client Registration). + + This provider implements Supabase Auth integration using metadata forwarding. + This approach allows Supabase to handle the OAuth flow directly while FastMCP acts + as a resource server, verifying JWTs issued by Supabase Auth. + + IMPORTANT SETUP REQUIREMENTS: + + 1. Supabase Project Setup: + - Create a Supabase project at https://supabase.com + - Note your project URL (e.g., "https://abc123.supabase.co") + - For projects created after May 1st, 2025, asymmetric RS256 keys are used by default + - For older projects, consider migrating to asymmetric keys for better security + + 2. JWT Verification: + - FastMCP verifies JWTs using the JWKS endpoint at {project_url}/auth/v1/.well-known/jwks.json + - JWTs are issued by {project_url}/auth/v1 + - Tokens are cached for up to 10 minutes by Supabase's edge servers + + For detailed setup instructions, see: + https://supabase.com/docs/guides/auth/jwts + + Example: + ```python + from fastmcp.server.auth.providers.supabase import SupabaseProvider + + # Create Supabase metadata provider (JWT verifier created automatically) + supabase_auth = SupabaseProvider( + project_url="https://abc123.supabase.co", + base_url="https://your-fastmcp-server.com", + ) + + # Use with FastMCP + mcp = FastMCP("My App", auth=supabase_auth) + ``` + """ + + def __init__( + self, + *, + project_url: AnyHttpUrl | str | NotSetT = NotSet, + base_url: AnyHttpUrl | str | NotSetT = NotSet, + required_scopes: list[str] | None | NotSetT = NotSet, + token_verifier: TokenVerifier | None = None, + ): + """Initialize Supabase metadata provider. + + Args: + project_url: Your Supabase project URL (e.g., "https://abc123.supabase.co") + base_url: Public URL of this FastMCP server + required_scopes: Optional list of scopes to require for all requests + token_verifier: Optional token verifier. If None, creates JWT verifier for Supabase + """ + settings = SupabaseProviderSettings.model_validate( + { + k: v + for k, v in { + "project_url": project_url, + "base_url": base_url, + "required_scopes": required_scopes, + }.items() + if v is not NotSet + } + ) + + self.project_url = str(settings.project_url).rstrip("/") + self.base_url = str(settings.base_url).rstrip("/") + + # Create default JWT verifier if none provided + if token_verifier is None: + token_verifier = JWTVerifier( + jwks_uri=f"{self.project_url}/auth/v1/.well-known/jwks.json", + issuer=f"{self.project_url}/auth/v1", + algorithm="ES256", # Supabase uses ES256 for asymmetric keys + required_scopes=settings.required_scopes, + ) + + # Initialize RemoteAuthProvider with Supabase as the authorization server + super().__init__( + token_verifier=token_verifier, + authorization_servers=[AnyHttpUrl(f"{self.project_url}/auth/v1")], + base_url=self.base_url, + ) + + def get_routes( + self, + mcp_path: str | None = None, + ) -> list[Route]: + """Get OAuth routes including Supabase authorization server metadata forwarding. + + This returns the standard protected resource routes plus an authorization server + metadata endpoint that forwards Supabase's OAuth metadata to clients. + + Args: + mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") + This is used to advertise the resource URL in metadata. + """ + # Get the standard protected resource routes from RemoteAuthProvider + routes = super().get_routes(mcp_path) + + async def oauth_authorization_server_metadata(request): + """Forward Supabase OAuth authorization server metadata with FastMCP customizations.""" + try: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.project_url}/auth/v1/.well-known/oauth-authorization-server" + ) + response.raise_for_status() + metadata = response.json() + return JSONResponse(metadata) + except Exception as e: + return JSONResponse( + { + "error": "server_error", + "error_description": f"Failed to fetch Supabase metadata: {e}", + }, + status_code=500, + ) + + # Add Supabase authorization server metadata forwarding + routes.append( + Route( + "/.well-known/oauth-authorization-server", + endpoint=oauth_authorization_server_metadata, + methods=["GET"], + ) + ) + + return routes diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index 363f96f7d..ae8814a92 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -10,9 +10,8 @@ Choose based on your WorkOS setup and authentication requirements. from __future__ import annotations -from typing import Any - import httpx +from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict from starlette.responses import JSONResponse @@ -21,6 +20,7 @@ from starlette.routing import Route from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.settings import ENV_FILE from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -33,7 +33,7 @@ class WorkOSProviderSettings(BaseSettings): model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_WORKOS_", - env_file=".env", + env_file=ENV_FILE, extra="ignore", ) @@ -169,6 +169,7 @@ class WorkOSProvider(OAuthProxy): required_scopes: list[str] | None | NotSetT = NotSet, timeout_seconds: int | NotSetT = NotSet, allowed_client_redirect_uris: list[str] | NotSetT = NotSet, + client_storage: AsyncKeyValue | None = None, ): """Initialize WorkOS OAuth provider. @@ -182,6 +183,7 @@ class WorkOSProvider(OAuthProxy): timeout_seconds: HTTP request timeout for WorkOS API calls allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. + client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided """ settings = WorkOSProviderSettings.model_validate( @@ -247,6 +249,7 @@ class WorkOSProvider(OAuthProxy): redirect_path=settings.redirect_path, issuer_url=settings.base_url, allowed_client_redirect_uris=allowed_client_redirect_uris_final, + client_storage=client_storage, ) logger.info( @@ -259,7 +262,7 @@ class WorkOSProvider(OAuthProxy): class AuthKitProviderSettings(BaseSettings): model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_AUTHKITPROVIDER_", - env_file=".env", + env_file=ENV_FILE, extra="ignore", ) @@ -359,7 +362,6 @@ class AuthKitProvider(RemoteAuthProvider): def get_routes( self, mcp_path: str | None = None, - mcp_endpoint: Any | None = None, ) -> list[Route]: """Get OAuth routes including AuthKit authorization server metadata forwarding. @@ -368,10 +370,10 @@ class AuthKitProvider(RemoteAuthProvider): Args: mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - mcp_endpoint: The MCP endpoint handler to protect with auth + This is used to advertise the resource URL in metadata. """ # Get the standard protected resource routes from RemoteAuthProvider - routes = super().get_routes(mcp_path, mcp_endpoint) + routes = super().get_routes(mcp_path) async def oauth_authorization_server_metadata(request): """Forward AuthKit OAuth authorization server metadata with FastMCP customizations.""" diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index e5e98cce3..c1b39b805 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -5,7 +5,7 @@ import copy import inspect import warnings import weakref -from collections.abc import Generator, Mapping +from collections.abc import Generator, Mapping, Sequence from contextlib import contextmanager from contextvars import ContextVar, Token from dataclasses import dataclass @@ -17,9 +17,10 @@ from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.lowlevel.server import request_ctx from mcp.shared.context import RequestContext from mcp.types import ( + AudioContent, ClientCapabilities, - ContentBlock, CreateMessageResult, + ImageContent, IncludeContext, ModelHint, ModelPreferences, @@ -204,7 +205,7 @@ class Context: """ if self.fastmcp is None: raise ValueError("Context is not available outside of a request") - return await self.fastmcp._mcp_read_resource(uri) + return await self.fastmcp._read_resource_mcp(uri) async def log( self, @@ -359,13 +360,13 @@ class Context: async def sample( self, - messages: str | list[str | SamplingMessage], + messages: str | Sequence[str | SamplingMessage], system_prompt: str | None = None, include_context: IncludeContext | None = None, temperature: float | None = None, max_tokens: int | None = None, model_preferences: ModelPreferences | str | list[str] | None = None, - ) -> ContentBlock: + ) -> TextContent | ImageContent | AudioContent: """ Send a sampling request to the client and await the response. @@ -383,7 +384,7 @@ class Context: content=TextContent(text=messages, type="text"), role="user" ) ] - elif isinstance(messages, list): + elif isinstance(messages, Sequence): sampling_messages = [ SamplingMessage(content=TextContent(text=m, type="text"), role="user") if isinstance(m, str) diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index 21271129a..a5e41daf6 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -167,16 +167,25 @@ def create_sse_app( # Get auth middleware from the provider auth_middleware = auth.get_middleware() - # Get auth routes including protected MCP endpoint - auth_routes = auth.get_routes( - mcp_path=sse_path, - mcp_endpoint=handle_sse, - ) - + # Get auth provider's own routes (OAuth endpoints, metadata, etc) + auth_routes = auth.get_routes(mcp_path=sse_path) server_routes.extend(auth_routes) server_middleware.extend(auth_middleware) - # Manually wrap the SSE message endpoint with RequireAuthMiddleware + # Create protected SSE endpoint route with GET method only + server_routes.append( + Route( + sse_path, + endpoint=RequireAuthMiddleware( + handle_sse, + auth.required_scopes, + auth._get_resource_url("/.well-known/oauth-protected-resource"), + ), + methods=["GET"], + ) + ) + + # Wrap the SSE message endpoint with RequireAuthMiddleware server_routes.append( Mount( message_path, @@ -274,14 +283,22 @@ def create_streamable_http_app( # Get auth middleware from the provider auth_middleware = auth.get_middleware() - # Get auth routes including protected MCP endpoint - auth_routes = auth.get_routes( - mcp_path=streamable_http_path, - mcp_endpoint=streamable_http_app, - ) - + # Get auth provider's own routes (OAuth endpoints, metadata, etc) + auth_routes = auth.get_routes(mcp_path=streamable_http_path) server_routes.extend(auth_routes) server_middleware.extend(auth_middleware) + + # Create protected HTTP endpoint route + server_routes.append( + Route( + streamable_http_path, + endpoint=RequireAuthMiddleware( + streamable_http_app, + auth.required_scopes, + auth._get_resource_url("/.well-known/oauth-protected-resource"), + ), + ) + ) else: # No auth required server_routes.append( diff --git a/src/fastmcp/server/low_level.py b/src/fastmcp/server/low_level.py index 7dd3e9d4b..4251b3b80 100644 --- a/src/fastmcp/server/low_level.py +++ b/src/fastmcp/server/low_level.py @@ -1,5 +1,12 @@ -from typing import Any +from __future__ import annotations +import weakref +from contextlib import AsyncExitStack +from typing import TYPE_CHECKING, Any + +import anyio +import mcp.types +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from mcp.server.lowlevel.server import ( LifespanResultT, NotificationOptions, @@ -9,11 +16,82 @@ from mcp.server.lowlevel.server import ( Server as _Server, ) from mcp.server.models import InitializationOptions +from mcp.server.session import ServerSession +from mcp.server.stdio import stdio_server as stdio_server +from mcp.shared.message import SessionMessage +from mcp.shared.session import RequestResponder + +from fastmcp.utilities.logging import get_logger + +if TYPE_CHECKING: + from fastmcp.server.server import FastMCP + +logger = get_logger(__name__) + + +class MiddlewareServerSession(ServerSession): + """ServerSession that routes initialization requests through FastMCP middleware.""" + + def __init__(self, fastmcp: FastMCP, *args, **kwargs): + super().__init__(*args, **kwargs) + self._fastmcp_ref: weakref.ref[FastMCP] = weakref.ref(fastmcp) + + @property + def fastmcp(self) -> FastMCP: + """Get the FastMCP instance.""" + fastmcp = self._fastmcp_ref() + if fastmcp is None: + raise RuntimeError("FastMCP instance is no longer available") + return fastmcp + + async def _received_request( + self, + responder: RequestResponder[mcp.types.ClientRequest, mcp.types.ServerResult], + ): + """ + Override the _received_request method to route initialization requests + through FastMCP middleware. + + These are not handled by routes that FastMCP typically overrides and + require special handling. + """ + import fastmcp.server.context + from fastmcp.server.middleware.middleware import MiddlewareContext + + if isinstance(responder.request.root, mcp.types.InitializeRequest): + + async def call_original_handler( + ctx: MiddlewareContext, + ) -> None: + return await super(MiddlewareServerSession, self)._received_request( + responder + ) + + async with fastmcp.server.context.Context( + fastmcp=self.fastmcp + ) as fastmcp_ctx: + # Create the middleware context. + mw_context = MiddlewareContext( + message=responder.request.root, + source="client", + type="request", + method="initialize", + fastmcp_context=fastmcp_ctx, + ) + + return await self.fastmcp._apply_middleware( + mw_context, call_original_handler + ) + else: + return await super()._received_request(responder) class LowLevelServer(_Server[LifespanResultT, RequestT]): - def __init__(self, *args, **kwargs): + def __init__(self, fastmcp: FastMCP, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) + # Store a weak reference to FastMCP to avoid circular references + self._fastmcp_ref: weakref.ref[FastMCP] = weakref.ref(fastmcp) + # FastMCP servers support notifications for all components self.notification_options = NotificationOptions( prompts_changed=True, @@ -21,6 +99,14 @@ class LowLevelServer(_Server[LifespanResultT, RequestT]): tools_changed=True, ) + @property + def fastmcp(self) -> FastMCP: + """Get the FastMCP instance.""" + fastmcp = self._fastmcp_ref() + if fastmcp is None: + raise RuntimeError("FastMCP instance is no longer available") + return fastmcp + def create_initialization_options( self, notification_options: NotificationOptions | None = None, @@ -35,3 +121,36 @@ class LowLevelServer(_Server[LifespanResultT, RequestT]): experimental_capabilities=experimental_capabilities, **kwargs, ) + + async def run( + self, + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], + write_stream: MemoryObjectSendStream[SessionMessage], + initialization_options: InitializationOptions, + raise_exceptions: bool = False, + stateless: bool = False, + ): + """ + Overrides the run method to use the MiddlewareServerSession. + """ + async with AsyncExitStack() as stack: + lifespan_context = await stack.enter_async_context(self.lifespan(self)) + session = await stack.enter_async_context( + MiddlewareServerSession( + self.fastmcp, + read_stream, + write_stream, + initialization_options, + stateless=stateless, + ) + ) + + async with anyio.create_task_group() as tg: + async for message in session.incoming_messages: + tg.start_soon( + self._handle_message, + message, + session, + lifespan_context, + raise_exceptions, + ) diff --git a/src/fastmcp/server/middleware/logging.py b/src/fastmcp/server/middleware/logging.py index fe2a46cfc..593ce3bbf 100644 --- a/src/fastmcp/server/middleware/logging.py +++ b/src/fastmcp/server/middleware/logging.py @@ -2,6 +2,7 @@ import json import logging +import time from collections.abc import Callable from logging import Logger from typing import Any @@ -52,14 +53,14 @@ class BaseLoggingMiddleware(Middleware): else: return " ".join([f"{k}={v}" for k, v in message.items()]) - def _get_timestamp_from_context(self, context: MiddlewareContext[Any]) -> str: - """Get a timestamp from the context.""" - return context.timestamp.isoformat() - def _create_before_message( - self, context: MiddlewareContext[Any], event: str + self, context: MiddlewareContext[Any] ) -> dict[str, str | int]: - message = self._create_base_message(context, event) + message = { + "event": context.type + "_start", + "method": context.method or "unknown", + "source": context.source, + } if ( self.include_payloads @@ -85,57 +86,61 @@ class BaseLoggingMiddleware(Middleware): return message - def _create_after_message( - self, context: MiddlewareContext[Any], event: str - ) -> dict[str, str | int]: - return self._create_base_message(context, event) - - def _create_base_message( + def _create_error_message( self, context: MiddlewareContext[Any], - event: str, - ) -> dict[str, str | int]: - """Format a message for logging.""" - - parts: dict[str, str | int] = { - "event": event, - "timestamp": self._get_timestamp_from_context(context), + start_time: float, + error: Exception, + ) -> dict[str, str | int | float]: + duration_ms: float = _get_duration_ms(start_time) + message = { + "event": context.type + "_error", "method": context.method or "unknown", - "type": context.type, "source": context.source, + "duration_ms": duration_ms, + "error": str(object=error), } + return message - return parts + def _create_after_message( + self, + context: MiddlewareContext[Any], + start_time: float, + ) -> dict[str, str | int | float]: + duration_ms: float = _get_duration_ms(start_time) + message = { + "event": context.type + "_success", + "method": context.method or "unknown", + "source": context.source, + "duration_ms": duration_ms, + } + return message + + def _log_message( + self, message: dict[str, str | int | float], log_level: int | None = None + ): + self.logger.log(log_level or self.log_level, self._format_message(message)) async def on_message( self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any] ) -> Any: - """Log all messages.""" + """Log messages for configured methods.""" if self.methods and context.method not in self.methods: return await call_next(context) - request_start_log_message = self._create_before_message( - context, "request_start" - ) - - formatted_message = self._format_message(request_start_log_message) - self.logger.log(self.log_level, f"Processing message: {formatted_message}") + self._log_message(self._create_before_message(context)) + start_time = time.perf_counter() try: result = await call_next(context) - request_success_log_message = self._create_after_message( - context, "request_success" - ) - - formatted_message = self._format_message(request_success_log_message) - self.logger.log(self.log_level, f"Completed message: {formatted_message}") + self._log_message(self._create_after_message(context, start_time)) return result except Exception as e: - self.logger.log( - logging.ERROR, f"Failed message: {context.method or 'unknown'} - {e}" + self._log_message( + self._create_error_message(context, start_time, e), logging.ERROR ) raise @@ -184,7 +189,7 @@ class LoggingMiddleware(BaseLoggingMiddleware): payload_serializer: Callable that converts objects to a JSON string for the payload. If not provided, uses FastMCP's default tool serializer. """ - self.logger: Logger = logger or logging.getLogger("fastmcp.requests") + self.logger: Logger = logger or logging.getLogger("fastmcp.middleware.logging") self.log_level = log_level self.include_payloads: bool = include_payloads self.include_payload_length: bool = include_payload_length @@ -234,7 +239,9 @@ class StructuredLoggingMiddleware(BaseLoggingMiddleware): payload_serializer: Callable that converts objects to a JSON string for the payload. If not provided, uses FastMCP's default tool serializer. """ - self.logger: Logger = logger or logging.getLogger("fastmcp.structured") + self.logger: Logger = logger or logging.getLogger( + "fastmcp.middleware.structured_logging" + ) self.log_level: int = log_level self.include_payloads: bool = include_payloads self.include_payload_length: bool = include_payload_length @@ -243,3 +250,7 @@ class StructuredLoggingMiddleware(BaseLoggingMiddleware): self.payload_serializer: Callable[[Any], str] | None = payload_serializer self.max_payload_length: int | None = None self.structured_logging: bool = True + + +def _get_duration_ms(start_time: float, /) -> float: + return round(number=(time.perf_counter() - start_time) * 1000, ndigits=2) diff --git a/src/fastmcp/server/middleware/middleware.py b/src/fastmcp/server/middleware/middleware.py index 8b262d4f5..0b78e4866 100644 --- a/src/fastmcp/server/middleware/middleware.py +++ b/src/fastmcp/server/middleware/middleware.py @@ -99,6 +99,8 @@ class Middleware: handler = call_next match context.method: + case "initialize": + handler = partial(self.on_initialize, call_next=handler) case "tools/call": handler = partial(self.on_call_tool, call_next=handler) case "resources/read": @@ -145,6 +147,13 @@ class Middleware: ) -> Any: return await call_next(context) + async def on_initialize( + self, + context: MiddlewareContext[mt.InitializeRequestParams], + call_next: CallNext[mt.InitializeRequestParams, None], + ) -> None: + return await call_next(context) + async def on_call_tool( self, context: MiddlewareContext[mt.CallToolRequestParams], diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index e090957fd..3aa752abc 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -785,6 +785,7 @@ class FastMCPOpenAPI(FastMCP): http_routes = openapi.parse_openapi_to_http_routes(openapi_spec) # Process routes + num_excluded = 0 route_maps = (route_maps or []) + DEFAULT_ROUTE_MAPPINGS for route in http_routes: # Determine route type based on mappings or default rules @@ -823,8 +824,11 @@ class FastMCPOpenAPI(FastMCP): self._create_openapi_template(route, component_name, tags=route_tags) elif route_type == MCPType.EXCLUDE: logger.info(f"Excluding route: {route.method} {route.path}") + num_excluded += 1 - logger.info(f"Created FastMCP OpenAPI server with {len(http_routes)} routes") + logger.info( + f"Created FastMCP OpenAPI server with {len(http_routes) - num_excluded} routes" + ) def _generate_default_name( self, route: openapi.HTTPRoute, mcp_names_map: dict[str, str] | None = None diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py index 57ec5facf..6847befcd 100644 --- a/src/fastmcp/server/proxy.py +++ b/src/fastmcp/server/proxy.py @@ -69,7 +69,7 @@ class ProxyManagerMixin: class ProxyToolManager(ToolManager, ProxyManagerMixin): """A ToolManager that sources its tools from a remote client in addition to local and mounted tools.""" - def __init__(self, client_factory: ClientFactoryT, **kwargs): + def __init__(self, client_factory: ClientFactoryT, **kwargs: Any): super().__init__(**kwargs) self.client_factory = client_factory @@ -123,7 +123,7 @@ class ProxyToolManager(ToolManager, ProxyManagerMixin): class ProxyResourceManager(ResourceManager, ProxyManagerMixin): """A ResourceManager that sources its resources from a remote client in addition to local and mounted resources.""" - def __init__(self, client_factory: ClientFactoryT, **kwargs): + def __init__(self, client_factory: ClientFactoryT, **kwargs: Any): super().__init__(**kwargs) self.client_factory = client_factory @@ -204,7 +204,7 @@ class ProxyResourceManager(ResourceManager, ProxyManagerMixin): class ProxyPromptManager(PromptManager, ProxyManagerMixin): """A PromptManager that sources its prompts from a remote client in addition to local and mounted prompts.""" - def __init__(self, client_factory: ClientFactoryT, **kwargs): + def __init__(self, client_factory: ClientFactoryT, **kwargs: Any): super().__init__(**kwargs) self.client_factory = client_factory @@ -258,7 +258,7 @@ class ProxyTool(Tool, MirroredComponent): A Tool that represents and executes a tool on a remote server. """ - def __init__(self, client: Client, **kwargs): + def __init__(self, client: Client, **kwargs: Any): super().__init__(**kwargs) self._client = client @@ -354,7 +354,7 @@ class ProxyTemplate(ResourceTemplate, MirroredComponent): A ResourceTemplate that represents and creates resources from a remote server template. """ - def __init__(self, client: Client, **kwargs): + def __init__(self, client: Client, **kwargs: Any): super().__init__(**kwargs) self._client = client @@ -640,7 +640,7 @@ class StatefulProxyClient(ProxyClient[ClientTransportT]): Note that it is essential to ensure that the proxy server itself is also stateful. """ - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) self._caches: dict[ServerSession, Client[ClientTransportT]] = {} diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 132589080..91367360c 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -8,11 +8,7 @@ import re import secrets import warnings from collections.abc import AsyncIterator, Awaitable, Callable -from contextlib import ( - AbstractAsyncContextManager, - AsyncExitStack, - asynccontextmanager, -) +from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager from dataclasses import dataclass from functools import partial from pathlib import Path @@ -65,7 +61,7 @@ from fastmcp.tools.tool import FunctionTool, Tool, ToolResult from fastmcp.tools.tool_transform import ToolTransformConfig from fastmcp.utilities.cli import log_server_banner from fastmcp.utilities.components import FastMCPComponent -from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.logging import get_logger, temporary_log_level from fastmcp.utilities.types import NotSet, NotSetT if TYPE_CHECKING: @@ -199,8 +195,9 @@ class FastMCP(Generic[LifespanResultT]): self._has_lifespan = True # Generate random ID if no name provided self._mcp_server = LowLevelServer[LifespanResultT]( + fastmcp=self, name=name or self.generate_name(), - version=version, + version=version or fastmcp.__version__, instructions=instructions, lifespan=_lifespan_wrapper(self, lifespan), ) @@ -390,13 +387,13 @@ class FastMCP(Generic[LifespanResultT]): def _setup_handlers(self) -> None: """Set up core MCP protocol handlers.""" - self._mcp_server.list_tools()(self._mcp_list_tools) - self._mcp_server.list_resources()(self._mcp_list_resources) - self._mcp_server.list_resource_templates()(self._mcp_list_resource_templates) - self._mcp_server.list_prompts()(self._mcp_list_prompts) - self._mcp_server.call_tool()(self._mcp_call_tool) - self._mcp_server.read_resource()(self._mcp_read_resource) - self._mcp_server.get_prompt()(self._mcp_get_prompt) + self._mcp_server.list_tools()(self._list_tools_mcp) + self._mcp_server.list_resources()(self._list_resources_mcp) + self._mcp_server.list_resource_templates()(self._list_resource_templates_mcp) + self._mcp_server.list_prompts()(self._list_prompts_mcp) + self._mcp_server.call_tool()(self._call_tool_mcp) + self._mcp_server.read_resource()(self._read_resource_mcp) + self._mcp_server.get_prompt()(self._get_prompt_mcp) async def _apply_middleware( self, @@ -523,11 +520,15 @@ class FastMCP(Generic[LifespanResultT]): return routes - async def _mcp_list_tools(self) -> list[MCPTool]: + async def _list_tools_mcp(self) -> list[MCPTool]: + """ + List all available tools, in the format expected by the low-level MCP + server. + """ logger.debug(f"[{self.name}] Handler called: list_tools") async with fastmcp.server.context.Context(fastmcp=self): - tools = await self._list_tools() + tools = await self._list_tools_middleware() return [ tool.to_mcp_tool( name=tool.key, @@ -536,24 +537,11 @@ class FastMCP(Generic[LifespanResultT]): for tool in tools ] - async def _list_tools(self) -> list[Tool]: + async def _list_tools_middleware(self) -> list[Tool]: """ - List all available tools, in the format expected by the low-level MCP - server. + List all available tools, applying MCP middleware. """ - async def _handler( - context: MiddlewareContext[mcp.types.ListToolsRequest], - ) -> list[Tool]: - tools = await self._tool_manager.list_tools() # type: ignore[reportPrivateUsage] - - mcp_tools: list[Tool] = [] - for tool in tools: - if self._should_enable_component(tool): - mcp_tools.append(tool) - - return mcp_tools - async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx: # Create the middleware context. mw_context = MiddlewareContext( @@ -565,13 +553,33 @@ class FastMCP(Generic[LifespanResultT]): ) # Apply the middleware chain. - return await self._apply_middleware(mw_context, _handler) + return await self._apply_middleware(mw_context, self._list_tools) - async def _mcp_list_resources(self) -> list[MCPResource]: + async def _list_tools( + self, + context: MiddlewareContext[mcp.types.ListToolsRequest], + ) -> list[Tool]: + """ + List all available tools + """ + tools = await self._tool_manager.list_tools() # type: ignore[reportPrivateUsage] + + mcp_tools: list[Tool] = [] + for tool in tools: + if self._should_enable_component(tool): + mcp_tools.append(tool) + + return mcp_tools + + async def _list_resources_mcp(self) -> list[MCPResource]: + """ + List all available resources, in the format expected by the low-level MCP + server. + """ logger.debug(f"[{self.name}] Handler called: list_resources") async with fastmcp.server.context.Context(fastmcp=self): - resources = await self._list_resources() + resources = await self._list_resources_middleware() return [ resource.to_mcp_resource( uri=resource.key, @@ -580,25 +588,11 @@ class FastMCP(Generic[LifespanResultT]): for resource in resources ] - async def _list_resources(self) -> list[Resource]: + async def _list_resources_middleware(self) -> list[Resource]: """ - List all available resources, in the format expected by the low-level MCP - server. - + List all available resources, applying MCP middleware. """ - async def _handler( - context: MiddlewareContext[dict[str, Any]], - ) -> list[Resource]: - resources = await self._resource_manager.list_resources() # type: ignore[reportPrivateUsage] - - mcp_resources: list[Resource] = [] - for resource in resources: - if self._should_enable_component(resource): - mcp_resources.append(resource) - - return mcp_resources - async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx: # Create the middleware context. mw_context = MiddlewareContext( @@ -610,13 +604,33 @@ class FastMCP(Generic[LifespanResultT]): ) # Apply the middleware chain. - return await self._apply_middleware(mw_context, _handler) + return await self._apply_middleware(mw_context, self._list_resources) - async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]: + async def _list_resources( + self, + context: MiddlewareContext[dict[str, Any]], + ) -> list[Resource]: + """ + List all available resources + """ + resources = await self._resource_manager.list_resources() # type: ignore[reportPrivateUsage] + + mcp_resources: list[Resource] = [] + for resource in resources: + if self._should_enable_component(resource): + mcp_resources.append(resource) + + return mcp_resources + + async def _list_resource_templates_mcp(self) -> list[MCPResourceTemplate]: + """ + List all available resource templates, in the format expected by the low-level MCP + server. + """ logger.debug(f"[{self.name}] Handler called: list_resource_templates") async with fastmcp.server.context.Context(fastmcp=self): - templates = await self._list_resource_templates() + templates = await self._list_resource_templates_middleware() return [ template.to_mcp_template( uriTemplate=template.key, @@ -625,25 +639,12 @@ class FastMCP(Generic[LifespanResultT]): for template in templates ] - async def _list_resource_templates(self) -> list[ResourceTemplate]: + async def _list_resource_templates_middleware(self) -> list[ResourceTemplate]: """ - List all available resource templates, in the format expected by the low-level MCP - server. + List all available resource templates, applying MCP middleware. """ - async def _handler( - context: MiddlewareContext[dict[str, Any]], - ) -> list[ResourceTemplate]: - templates = await self._resource_manager.list_resource_templates() - - mcp_templates: list[ResourceTemplate] = [] - for template in templates: - if self._should_enable_component(template): - mcp_templates.append(template) - - return mcp_templates - async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx: # Create the middleware context. mw_context = MiddlewareContext( @@ -655,13 +656,35 @@ class FastMCP(Generic[LifespanResultT]): ) # Apply the middleware chain. - return await self._apply_middleware(mw_context, _handler) + return await self._apply_middleware( + mw_context, self._list_resource_templates + ) - async def _mcp_list_prompts(self) -> list[MCPPrompt]: + async def _list_resource_templates( + self, + context: MiddlewareContext[dict[str, Any]], + ) -> list[ResourceTemplate]: + """ + List all available resource templates + """ + templates = await self._resource_manager.list_resource_templates() # type: ignore[reportPrivateUsage] + + mcp_templates: list[ResourceTemplate] = [] + for template in templates: + if self._should_enable_component(template): + mcp_templates.append(template) + + return mcp_templates + + async def _list_prompts_mcp(self) -> list[MCPPrompt]: + """ + List all available prompts, in the format expected by the low-level MCP + server. + """ logger.debug(f"[{self.name}] Handler called: list_prompts") async with fastmcp.server.context.Context(fastmcp=self): - prompts = await self._list_prompts() + prompts = await self._list_prompts_middleware() return [ prompt.to_mcp_prompt( name=prompt.key, @@ -670,25 +693,12 @@ class FastMCP(Generic[LifespanResultT]): for prompt in prompts ] - async def _list_prompts(self) -> list[Prompt]: + async def _list_prompts_middleware(self) -> list[Prompt]: """ - List all available prompts, in the format expected by the low-level MCP - server. + List all available prompts, applying MCP middleware. """ - async def _handler( - context: MiddlewareContext[mcp.types.ListPromptsRequest], - ) -> list[Prompt]: - prompts = await self._prompt_manager.list_prompts() # type: ignore[reportPrivateUsage] - - mcp_prompts: list[Prompt] = [] - for prompt in prompts: - if self._should_enable_component(prompt): - mcp_prompts.append(prompt) - - return mcp_prompts - async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx: # Create the middleware context. mw_context = MiddlewareContext( @@ -700,9 +710,25 @@ class FastMCP(Generic[LifespanResultT]): ) # Apply the middleware chain. - return await self._apply_middleware(mw_context, _handler) + return await self._apply_middleware(mw_context, self._list_prompts) - async def _mcp_call_tool( + async def _list_prompts( + self, + context: MiddlewareContext[mcp.types.ListPromptsRequest], + ) -> list[Prompt]: + """ + List all available prompts + """ + prompts = await self._prompt_manager.list_prompts() # type: ignore[reportPrivateUsage] + + mcp_prompts: list[Prompt] = [] + for prompt in prompts: + if self._should_enable_component(prompt): + mcp_prompts.append(prompt) + + return mcp_prompts + + async def _call_tool_mcp( self, key: str, arguments: dict[str, Any] ) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]: """ @@ -723,29 +749,22 @@ class FastMCP(Generic[LifespanResultT]): async with fastmcp.server.context.Context(fastmcp=self): try: - result = await self._call_tool(key, arguments) + result = await self._call_tool_middleware(key, arguments) return result.to_mcp_result() except DisabledError: raise NotFoundError(f"Unknown tool: {key}") except NotFoundError: raise NotFoundError(f"Unknown tool: {key}") - async def _call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult: + async def _call_tool_middleware( + self, + key: str, + arguments: dict[str, Any], + ) -> ToolResult: """ Applies this server's middleware and delegates the filtered call to the manager. """ - async def _handler( - context: MiddlewareContext[mcp.types.CallToolRequestParams], - ) -> ToolResult: - tool = await self._tool_manager.get_tool(context.message.name) - if not self._should_enable_component(tool): - raise NotFoundError(f"Unknown tool: {context.message.name!r}") - - return await self._tool_manager.call_tool( - key=context.message.name, arguments=context.message.arguments or {} - ) - mw_context = MiddlewareContext[CallToolRequestParams]( message=mcp.types.CallToolRequestParams(name=key, arguments=arguments), source="client", @@ -753,9 +772,24 @@ class FastMCP(Generic[LifespanResultT]): method="tools/call", fastmcp_context=fastmcp.server.dependencies.get_context(), ) - return await self._apply_middleware(mw_context, _handler) + return await self._apply_middleware(mw_context, self._call_tool) - async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]: + async def _call_tool( + self, + context: MiddlewareContext[mcp.types.CallToolRequestParams], + ) -> ToolResult: + """ + Call a tool + """ + tool = await self._tool_manager.get_tool(context.message.name) + if not self._should_enable_component(tool): + raise NotFoundError(f"Unknown tool: {context.message.name!r}") + + return await self._tool_manager.call_tool( + key=context.message.name, arguments=context.message.arguments or {} + ) + + async def _read_resource_mcp(self, uri: AnyUrl | str) -> list[ReadResourceContents]: """ Handle MCP 'readResource' requests. @@ -765,7 +799,7 @@ class FastMCP(Generic[LifespanResultT]): async with fastmcp.server.context.Context(fastmcp=self): try: - return await self._read_resource(uri) + return await self._read_resource_middleware(uri) except DisabledError: # convert to NotFoundError to avoid leaking resource presence raise NotFoundError(f"Unknown resource: {str(uri)!r}") @@ -773,26 +807,14 @@ class FastMCP(Generic[LifespanResultT]): # standardize NotFound message raise NotFoundError(f"Unknown resource: {str(uri)!r}") - async def _read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]: + async def _read_resource_middleware( + self, + uri: AnyUrl | str, + ) -> list[ReadResourceContents]: """ Applies this server's middleware and delegates the filtered call to the manager. """ - async def _handler( - context: MiddlewareContext[mcp.types.ReadResourceRequestParams], - ) -> list[ReadResourceContents]: - resource = await self._resource_manager.get_resource(context.message.uri) - if not self._should_enable_component(resource): - raise NotFoundError(f"Unknown resource: {str(context.message.uri)!r}") - - content = await self._resource_manager.read_resource(context.message.uri) - return [ - ReadResourceContents( - content=content, - mime_type=resource.mime_type, - ) - ] - # Convert string URI to AnyUrl if needed if isinstance(uri, str): uri_param = AnyUrl(uri) @@ -806,9 +828,28 @@ class FastMCP(Generic[LifespanResultT]): method="resources/read", fastmcp_context=fastmcp.server.dependencies.get_context(), ) - return await self._apply_middleware(mw_context, _handler) + return await self._apply_middleware(mw_context, self._read_resource) - async def _mcp_get_prompt( + async def _read_resource( + self, + context: MiddlewareContext[mcp.types.ReadResourceRequestParams], + ) -> list[ReadResourceContents]: + """ + Read a resource + """ + resource = await self._resource_manager.get_resource(context.message.uri) + if not self._should_enable_component(resource): + raise NotFoundError(f"Unknown resource: {str(context.message.uri)!r}") + + content = await self._resource_manager.read_resource(context.message.uri) + return [ + ReadResourceContents( + content=content, + mime_type=resource.mime_type, + ) + ] + + async def _get_prompt_mcp( self, name: str, arguments: dict[str, Any] | None = None ) -> GetPromptResult: """ @@ -824,7 +865,7 @@ class FastMCP(Generic[LifespanResultT]): async with fastmcp.server.context.Context(fastmcp=self): try: - return await self._get_prompt(name, arguments) + return await self._get_prompt_middleware(name, arguments) except DisabledError: # convert to NotFoundError to avoid leaking prompt presence raise NotFoundError(f"Unknown prompt: {name}") @@ -832,24 +873,13 @@ class FastMCP(Generic[LifespanResultT]): # standardize NotFound message raise NotFoundError(f"Unknown prompt: {name}") - async def _get_prompt( + async def _get_prompt_middleware( self, name: str, arguments: dict[str, Any] | None = None ) -> GetPromptResult: """ Applies this server's middleware and delegates the filtered call to the manager. """ - async def _handler( - context: MiddlewareContext[mcp.types.GetPromptRequestParams], - ) -> GetPromptResult: - prompt = await self._prompt_manager.get_prompt(context.message.name) - if not self._should_enable_component(prompt): - raise NotFoundError(f"Unknown prompt: {context.message.name!r}") - - return await self._prompt_manager.render_prompt( - name=context.message.name, arguments=context.message.arguments - ) - mw_context = MiddlewareContext( message=mcp.types.GetPromptRequestParams(name=name, arguments=arguments), source="client", @@ -857,7 +887,19 @@ class FastMCP(Generic[LifespanResultT]): method="prompts/get", fastmcp_context=fastmcp.server.dependencies.get_context(), ) - return await self._apply_middleware(mw_context, _handler) + return await self._apply_middleware(mw_context, self._get_prompt) + + async def _get_prompt( + self, + context: MiddlewareContext[mcp.types.GetPromptRequestParams], + ) -> GetPromptResult: + prompt = await self._prompt_manager.get_prompt(context.message.name) + if not self._should_enable_component(prompt): + raise NotFoundError(f"Unknown prompt: {context.message.name!r}") + + return await self._prompt_manager.render_prompt( + name=context.message.name, arguments=context.message.arguments + ) def add_tool(self, tool: Tool) -> Tool: """Add a tool to the server. @@ -1485,9 +1527,15 @@ class FastMCP(Generic[LifespanResultT]): meta=meta, ) - async def run_stdio_async(self, show_banner: bool = True) -> None: - """Run the server using stdio transport.""" + async def run_stdio_async( + self, show_banner: bool = True, log_level: str | None = None + ) -> None: + """Run the server using stdio transport. + Args: + show_banner: Whether to display the server banner + log_level: Log level for the server + """ # Display server banner if show_banner: log_server_banner( @@ -1495,15 +1543,16 @@ class FastMCP(Generic[LifespanResultT]): transport="stdio", ) - async with stdio_server() as (read_stream, write_stream): - logger.info(f"Starting MCP server {self.name!r} with transport 'stdio'") - await self._mcp_server.run( - read_stream, - write_stream, - self._mcp_server.create_initialization_options( - NotificationOptions(tools_changed=True) - ), - ) + with temporary_log_level(log_level): + async with stdio_server() as (read_stream, write_stream): + logger.info(f"Starting MCP server {self.name!r} with transport 'stdio'") + await self._mcp_server.run( + read_stream, + write_stream, + self._mcp_server.create_initialization_options( + NotificationOptions(tools_changed=True) + ), + ) async def run_http_async( self, @@ -1529,7 +1578,6 @@ class FastMCP(Generic[LifespanResultT]): middleware: A list of middleware to apply to the app stateless_http: Whether to use stateless HTTP (defaults to settings.stateless_http) """ - host = host or self._deprecated_settings.host port = port or self._deprecated_settings.port default_log_level_to_use = ( @@ -1564,20 +1612,22 @@ class FastMCP(Generic[LifespanResultT]): config_kwargs: dict[str, Any] = { "timeout_graceful_shutdown": 0, "lifespan": "on", + "ws": "websockets-sansio", } config_kwargs.update(_uvicorn_config_from_user) if "log_config" not in config_kwargs and "log_level" not in config_kwargs: config_kwargs["log_level"] = default_log_level_to_use - config = uvicorn.Config(app, host=host, port=port, **config_kwargs) - server = uvicorn.Server(config) - path = app.state.path.lstrip("/") # type: ignore - logger.info( - f"Starting MCP server {self.name!r} with transport {transport!r} on http://{host}:{port}/{path}" - ) + with temporary_log_level(log_level): + config = uvicorn.Config(app, host=host, port=port, **config_kwargs) + server = uvicorn.Server(config) + path = app.state.path.lstrip("/") # type: ignore + logger.info( + f"Starting MCP server {self.name!r} with transport {transport!r} on http://{host}:{port}/{path}" + ) - await server.serve() + await server.serve() async def run_sse_async( self, @@ -2126,10 +2176,8 @@ class FastMCP(Generic[LifespanResultT]): # - Connected clients: reuse existing session for all requests # - Disconnected clients: create fresh sessions per request for isolation if client.is_connected(): - from fastmcp.utilities.logging import get_logger - - logger = get_logger(__name__) - logger.info( + _proxy_logger = get_logger(__name__) + _proxy_logger.info( "Proxy detected connected client - reusing existing session for all requests. " "This may cause context mixing in concurrent scenarios." ) diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 95e9f6290..ac8ce6df1 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -1,6 +1,7 @@ from __future__ import annotations as _annotations import inspect +import os import warnings from pathlib import Path from typing import TYPE_CHECKING, Annotated, Any, Literal @@ -19,10 +20,14 @@ from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) +ENV_FILE = os.getenv("FASTMCP_ENV_FILE", ".env") + LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] DuplicateBehavior = Literal["warn", "error", "replace", "ignore"] +TEN_MB_IN_BYTES = 1024 * 1024 * 10 + if TYPE_CHECKING: from fastmcp.server.auth.auth import AuthProvider @@ -82,7 +87,7 @@ class Settings(BaseSettings): model_config = ExtendedSettingsConfigDict( env_prefixes=["FASTMCP_", "FASTMCP_SERVER_"], - env_file=".env", + env_file=ENV_FILE, extra="ignore", env_nested_delimiter="__", nested_model_default_partial_update=True, diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index ec80dfde0..d1fd03f64 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -413,7 +413,9 @@ class ParsedFunction: input_type_adapter = get_cached_typeadapter(fn) input_schema = input_type_adapter.json_schema() - input_schema = compress_schema(input_schema, prune_params=prune_params) + input_schema = compress_schema( + input_schema, prune_params=prune_params, prune_titles=True + ) output_schema = None # Get the return annotation from the signature @@ -473,7 +475,7 @@ class ParsedFunction: else: output_schema = base_schema - output_schema = compress_schema(output_schema) + output_schema = compress_schema(output_schema, prune_titles=True) except PydanticSchemaGenerationError as e: if "_UnserializableType" not in str(e): @@ -544,13 +546,12 @@ def _convert_to_content( # If any item is a ContentBlock, convert non-ContentBlock items to TextContent # without aggregating them - if any(isinstance(item, ContentBlock) for item in result): + if any(isinstance(item, ContentBlock | Image | Audio | File) for item in result): return [ _convert_to_single_content_block(item, serializer) if not isinstance(item, ContentBlock) else item for item in result ] - # If none of the items are ContentBlocks, aggregate all items into a single TextContent return [TextContent(type="text", text=_serialize_with_fallback(result, serializer))] diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py index a40a9877c..02797f55f 100644 --- a/src/fastmcp/tools/tool_manager.py +++ b/src/fastmcp/tools/tool_manager.py @@ -52,21 +52,21 @@ class ToolManager: """Adds a mounted server as a source for tools.""" self._mounted_servers.append(server) - async def _load_tools(self, *, via_server: bool = False) -> dict[str, Tool]: + async def _load_tools(self, *, apply_filtering: bool = False) -> dict[str, Tool]: """ - The single, consolidated recursive method for fetching tools. The 'via_server' + The single, consolidated recursive method for fetching tools. The 'apply_filtering' parameter determines the communication path. - - via_server=False: Manager-to-manager path for complete, unfiltered inventory - - via_server=True: Server-to-server path for filtered MCP requests + - apply_filtering=False: Manager-to-manager path for complete, unfiltered inventory + - apply_filtering=True: Server-to-server path for filtered MCP requests """ all_tools: dict[str, Tool] = {} for mounted in self._mounted_servers: try: - if via_server: + if apply_filtering: # Use the server-to-server filtered path - child_results = await mounted.server._list_tools() + child_results = await mounted.server._list_tools_middleware() else: # Use the manager-to-manager unfiltered path child_results = await mounted.server._tool_manager.list_tools() @@ -116,13 +116,13 @@ class ToolManager: """ Gets the complete, unfiltered inventory of all tools. """ - return await self._load_tools(via_server=False) + return await self._load_tools(apply_filtering=False) async def list_tools(self) -> list[Tool]: """ Lists all tools, applying protocol filtering. """ - tools_dict = await self._load_tools(via_server=True) + tools_dict = await self._load_tools(apply_filtering=True) return list(tools_dict.values()) @property @@ -247,7 +247,7 @@ class ToolManager: else: continue try: - return await mounted.server._call_tool(tool_key, arguments) + return await mounted.server._call_tool_middleware(tool_key, arguments) except NotFoundError: continue diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index c1bd5b69e..0cc5ed960 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -34,7 +34,7 @@ _current_tool: ContextVar[TransformedTool | None] = ContextVar( # type: ignore[ ) -async def forward(**kwargs) -> ToolResult: +async def forward(**kwargs: Any) -> ToolResult: """Forward to parent tool with argument transformation applied. This function can only be called from within a transformed tool's custom @@ -64,7 +64,7 @@ async def forward(**kwargs) -> ToolResult: return await tool.forwarding_fn(**kwargs) -async def forward_raw(**kwargs) -> ToolResult: +async def forward_raw(**kwargs: Any) -> ToolResult: """Forward directly to parent tool without transformation. This function bypasses all argument transformation and validation, calling the parent @@ -681,7 +681,7 @@ class TransformedTool(Tool): schema = compress_schema(schema, prune_defs=True) # Create forwarding function that closes over everything it needs - async def _forward(**kwargs): + async def _forward(**kwargs: Any): # Validate arguments valid_args = set(new_props.keys()) provided_args = set(kwargs.keys()) diff --git a/src/fastmcp/utilities/cli.py b/src/fastmcp/utilities/cli.py index 2ae540d89..63be0ec6c 100644 --- a/src/fastmcp/utilities/cli.py +++ b/src/fastmcp/utilities/cli.py @@ -186,7 +186,7 @@ def log_server_banner( case "stdio": display_transport = "STDIO" - info_table.add_row("🖥️", "Server name:", server.name) + info_table.add_row("🖥", "Server name:", server.name) info_table.add_row("📦", "Transport:", display_transport) # Show connection info based on transport @@ -200,7 +200,7 @@ def log_server_banner( # Add version information with explicit style overrides info_table.add_row("", "", "") info_table.add_row( - "🏎️", + "🏎", "FastMCP version:", Text(fastmcp.__version__, style="dim white", no_wrap=True), ) diff --git a/src/fastmcp/utilities/json_schema.py b/src/fastmcp/utilities/json_schema.py index d0d9e37cd..52a897177 100644 --- a/src/fastmcp/utilities/json_schema.py +++ b/src/fastmcp/utilities/json_schema.py @@ -109,8 +109,25 @@ def _single_pass_optimize( root_refs.add(referenced_def) # Apply cleanups + # Only remove "title" if it's a schema metadata field + # Schema objects have keywords like "type", "properties", "$ref", etc. + # If we see these, then "title" is metadata, not a property name if prune_titles and "title" in node: - node.pop("title") + # Check if this looks like a schema node + if any( + k in node + for k in [ + "type", + "properties", + "$ref", + "items", + "allOf", + "oneOf", + "anyOf", + "required", + ] + ): + node.pop("title") if ( prune_additional_properties diff --git a/src/fastmcp/utilities/logging.py b/src/fastmcp/utilities/logging.py index e27aa4600..ec7f47023 100644 --- a/src/fastmcp/utilities/logging.py +++ b/src/fastmcp/utilities/logging.py @@ -1,11 +1,14 @@ """Logging utilities for FastMCP.""" +import contextlib import logging -from typing import Any, Literal +from typing import Any, Literal, cast from rich.console import Console from rich.logging import RichHandler +import fastmcp + def get_logger(name: str) -> logging.Logger: """Get a logger nested under FastMCP namespace. @@ -16,13 +19,13 @@ def get_logger(name: str) -> logging.Logger: Returns: a configured logger instance """ - return logging.getLogger(f"FastMCP.{name}") + return logging.getLogger(f"fastmcp.{name}") def configure_logging( level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | int = "INFO", logger: logging.Logger | None = None, - enable_rich_tracebacks: bool = True, + enable_rich_tracebacks: bool | None = None, **rich_kwargs: Any, ) -> None: """ @@ -33,26 +36,108 @@ def configure_logging( level: the log level to use rich_kwargs: the parameters to use for creating RichHandler """ + # Check if logging is disabled in settings + if not fastmcp.settings.log_enabled: + return + + # Use settings default if not specified + if enable_rich_tracebacks is None: + enable_rich_tracebacks = fastmcp.settings.enable_rich_tracebacks if logger is None: - logger = logging.getLogger("FastMCP") + logger = logging.getLogger("fastmcp") - # Only configure the FastMCP logger namespace + formatter = logging.Formatter("%(message)s") + + # Don't propagate to the root logger + logger.propagate = False + logger.setLevel(level) + + # Configure the handler for normal logs handler = RichHandler( console=Console(stderr=True), - rich_tracebacks=enable_rich_tracebacks, **rich_kwargs, ) - formatter = logging.Formatter("%(message)s") handler.setFormatter(formatter) - logger.setLevel(level) + # filter to exclude tracebacks + handler.addFilter(lambda record: record.exc_info is None) + + # Configure the handler for tracebacks, for tracebacks we use a compressed format: + # no path or level name to maximize width available for the traceback + # suppress framework frames and limit the number of frames to 3 + + import mcp + import pydantic + + traceback_handler = RichHandler( + console=Console(stderr=True), + show_path=False, + show_level=False, + rich_tracebacks=enable_rich_tracebacks, + tracebacks_max_frames=3, + tracebacks_suppress=[fastmcp, mcp, pydantic], + **rich_kwargs, + ) + traceback_handler.setFormatter(formatter) + + traceback_handler.addFilter(lambda record: record.exc_info is not None) # Remove any existing handlers to avoid duplicates on reconfiguration for hdlr in logger.handlers[:]: logger.removeHandler(hdlr) logger.addHandler(handler) + logger.addHandler(traceback_handler) - # Don't propagate to the root logger - logger.propagate = False + +@contextlib.contextmanager +def temporary_log_level( + level: str | None, + logger: logging.Logger | None = None, + enable_rich_tracebacks: bool | None = None, + **rich_kwargs: Any, +): + """Context manager to temporarily set log level and restore it afterwards. + + Args: + level: The temporary log level to set (e.g., "DEBUG", "INFO") + logger: Optional logger to configure (defaults to FastMCP logger) + enable_rich_tracebacks: Whether to enable rich tracebacks + **rich_kwargs: Additional parameters for RichHandler + + Usage: + with temporary_log_level("DEBUG"): + # Code that runs with DEBUG logging + pass + # Original log level is restored here + """ + if level: + # Get the original log level from settings + original_level = fastmcp.settings.log_level + + # Configure with new level + # Cast to proper type for type checker + log_level_literal = cast( + Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + level.upper(), + ) + configure_logging( + level=log_level_literal, + logger=logger, + enable_rich_tracebacks=enable_rich_tracebacks, + **rich_kwargs, + ) + try: + yield + finally: + # Restore original configuration using configure_logging + # This will respect the log_enabled setting + configure_logging( + level=original_level, + logger=logger, + enable_rich_tracebacks=enable_rich_tracebacks, + **rich_kwargs, + ) + else: + yield diff --git a/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py b/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py index 6f71cd3ac..a88965435 100644 --- a/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py +++ b/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py @@ -28,19 +28,19 @@ class UVEnvironment(Environment): examples=[["fastmcp>=2.0,<3", "httpx", "pandas>=2.0"]], ) - requirements: str | None = Field( + requirements: Path | None = Field( default=None, description="Path to requirements.txt file", examples=["requirements.txt", "../requirements/prod.txt"], ) - project: str | None = Field( + project: Path | None = Field( default=None, description="Path to project directory containing pyproject.toml", examples=[".", "../my-project"], ) - editable: list[str] | None = Field( + editable: list[Path] | None = Field( default=None, description="Directories to install in editable mode", examples=[[".", "../my-package"], ["/path/to/package"]], @@ -64,7 +64,7 @@ class UVEnvironment(Environment): # Add project if specified if self.project: - args.extend(["--project", str(self.project)]) + args.extend(["--project", str(self.project.resolve())]) # Add Python version if specified (only if no project, as project has its own Python) if self.python and not self.project: @@ -78,12 +78,12 @@ class UVEnvironment(Environment): # Add requirements file if self.requirements: - args.extend(["--with-requirements", str(self.requirements)]) + args.extend(["--with-requirements", str(self.requirements.resolve())]) # Add editable packages if self.editable: for editable_path in self.editable: - args.extend(["--with-editable", str(editable_path)]) + args.extend(["--with-editable", str(editable_path.resolve())]) # Add the command args.extend(command) diff --git a/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py b/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py index 0f402e084..1345d7f59 100644 --- a/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +++ b/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py @@ -291,9 +291,9 @@ class MCPServerConfig(BaseModel): environment = UVEnvironment( python=python, dependencies=dependencies, - requirements=requirements, - project=project, - editable=[editable] if editable else None, + requirements=Path(requirements) if requirements else None, + project=Path(project) if project else None, + editable=[Path(editable)] if editable else None, ) # Build deployment config if any deployment args provided @@ -403,7 +403,8 @@ class MCPServerConfig(BaseModel): run_args["port"] = self.deployment.port if self.deployment.path: run_args["path"] = self.deployment.path - # Note: log_level not currently supported by run_async + if self.deployment.log_level: + run_args["log_level"] = self.deployment.log_level # Override with any provided kwargs run_args.update(kwargs) diff --git a/src/fastmcp/utilities/mcp_server_config/v1/schema.json b/src/fastmcp/utilities/mcp_server_config/v1/schema.json index e027cf967..aa1f59ce4 100644 --- a/src/fastmcp/utilities/mcp_server_config/v1/schema.json +++ b/src/fastmcp/utilities/mcp_server_config/v1/schema.json @@ -250,6 +250,7 @@ "requirements": { "anyOf": [ { + "format": "path", "type": "string" }, { @@ -267,6 +268,7 @@ "project": { "anyOf": [ { + "format": "path", "type": "string" }, { @@ -285,6 +287,7 @@ "anyOf": [ { "items": { + "format": "path", "type": "string" }, "type": "array" diff --git a/src/fastmcp/utilities/tests.py b/src/fastmcp/utilities/tests.py index 705e2b22f..ce175e221 100644 --- a/src/fastmcp/utilities/tests.py +++ b/src/fastmcp/utilities/tests.py @@ -66,6 +66,7 @@ def _run_server(mcp_server: FastMCP, transport: Literal["sse"], port: int) -> No host="127.0.0.1", port=port, log_level="error", + ws="websockets-sansio", ) ) uvicorn_server.run() @@ -74,11 +75,11 @@ def _run_server(mcp_server: FastMCP, transport: Literal["sse"], port: int) -> No @contextmanager def run_server_in_process( server_fn: Callable[..., None], - *args, + *args: Any, provide_host_and_port: bool = True, host: str = "127.0.0.1", port: int | None = None, - **kwargs, + **kwargs: Any, ) -> Generator[str, None, None]: """ Context manager that runs a FastMCP server in a separate process and @@ -109,7 +110,7 @@ def run_server_in_process( proc.start() # Wait for server to be running - max_attempts = 10 + max_attempts = 30 attempt = 0 while attempt < max_attempts and proc.is_alive(): try: @@ -117,10 +118,12 @@ def run_server_in_process( s.connect((host, port)) break except ConnectionRefusedError: - if attempt < 3: - time.sleep(0.01) - else: + if attempt < 5: + time.sleep(0.05) + elif attempt < 15: time.sleep(0.1) + else: + time.sleep(0.2) attempt += 1 else: raise RuntimeError(f"Server failed to start after {max_attempts} attempts") @@ -141,10 +144,10 @@ def run_server_in_process( def caplog_for_fastmcp(caplog): """Context manager to capture logs from FastMCP loggers even when propagation is disabled.""" caplog.clear() - logger = logging.getLogger("FastMCP") + logger = logging.getLogger("fastmcp") logger.addHandler(caplog.handler) try: - yield + yield caplog finally: logger.removeHandler(caplog.handler) diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py index 7865e45c5..1a5d307eb 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -66,9 +66,10 @@ class TestEnvironment: env = config.environment assert env.python == "3.12" assert env.dependencies == ["requests", "numpy>=2.0"] - assert env.requirements == "requirements.txt" - assert env.project == "." - assert env.editable == ["../my-package"] + # Paths are stored as Path objects + assert env.requirements == Path("requirements.txt") + assert env.project == Path(".") + assert env.editable == [Path("../my-package")] def test_needs_uv(self): """Test needs_uv() method.""" @@ -112,12 +113,16 @@ class TestEnvironment: assert "--python" not in cmd assert "3.12" not in cmd assert "--project" in cmd - assert "." in cmd + # Project path should be resolved to absolute path + project_idx = cmd.index("--project") + assert Path(cmd[project_idx + 1]).is_absolute() assert "--with" in cmd assert "requests" in cmd assert "numpy" in cmd assert "--with-requirements" in cmd - assert "requirements.txt" in cmd + # Requirements path should be resolved to absolute path + req_idx = cmd.index("--with-requirements") + assert Path(cmd[req_idx + 1]).is_absolute() # Command args should be at the end assert "fastmcp" in cmd[-3:] assert "run" in cmd[-2:] diff --git a/tests/cli/test_mcp_server_config_integration.py b/tests/cli/test_mcp_server_config_integration.py index 05a98687c..219532b53 100644 --- a/tests/cli/test_mcp_server_config_integration.py +++ b/tests/cli/test_mcp_server_config_integration.py @@ -234,10 +234,11 @@ class TestPathResolution: assert config.environment is not None uv_cmd = config.environment.build_command(["fastmcp", "run"]) - # Should include requirements file + # Should include requirements file with absolute path assert "--with-requirements" in uv_cmd req_idx = uv_cmd.index("--with-requirements") + 1 - assert uv_cmd[req_idx] == "requirements.txt" + assert Path(uv_cmd[req_idx]).is_absolute() + assert Path(uv_cmd[req_idx]).name == "requirements.txt" class TestConfigValidation: diff --git a/tests/cli/test_run_config.py b/tests/cli/test_run_config.py index 59313698b..4d1036ae5 100644 --- a/tests/cli/test_run_config.py +++ b/tests/cli/test_run_config.py @@ -206,6 +206,47 @@ def test_load_config_with_server_args(tmp_path): assert config.deployment.args == ["--debug", "--config", "custom.json"] +def test_load_config_with_log_level(tmp_path): + """Test configuration with log_level setting.""" + config_data = { + "source": {"path": "server.py"}, + "deployment": {"log_level": "DEBUG"}, + } + + config_file = tmp_path / "fastmcp.json" + config_file.write_text(json.dumps(config_data)) + + # Create server file + server_file = tmp_path / "server.py" + server_file.write_text("# Server") + + config = load_mcp_server_config(config_file) + + assert config.deployment.log_level == "DEBUG" + + +def test_load_config_with_various_log_levels(tmp_path): + """Test that all valid log levels are accepted.""" + valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + + for level in valid_levels: + config_data = { + "source": {"path": "server.py"}, + "deployment": {"log_level": level}, + } + + config_file = tmp_path / f"fastmcp_{level}.json" + config_file.write_text(json.dumps(config_data)) + + # Create server file + server_file = tmp_path / "server.py" + server_file.write_text("# Server") + + config = load_mcp_server_config(config_file) + + assert config.deployment.log_level == level + + def test_config_subset_independence(tmp_path): """Test that config subsets can be used independently.""" config_data = { diff --git a/tests/client/auth/test_oauth_client.py b/tests/client/auth/test_oauth_client.py index 84c340815..a689da36f 100644 --- a/tests/client/auth/test_oauth_client.py +++ b/tests/client/auth/test_oauth_client.py @@ -39,7 +39,7 @@ def run_server(host: str, port: int, **kwargs) -> None: fastmcp_server(f"http://{host}:{port}").run(host=host, port=port, **kwargs) -@pytest.fixture(scope="module") +@pytest.fixture def streamable_http_server() -> Generator[str, None, None]: with run_server_in_process(run_server, transport="http") as url: yield f"{url}/mcp" diff --git a/tests/client/auth/test_oauth_token_expiry.py b/tests/client/auth/test_oauth_token_expiry.py deleted file mode 100644 index 77e5d552e..000000000 --- a/tests/client/auth/test_oauth_token_expiry.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Test OAuth token expiry handling with absolute timestamps.""" - -import json -from datetime import datetime, timedelta, timezone -from pathlib import Path - -import pytest -from mcp.shared.auth import OAuthToken - -from fastmcp.client.auth.oauth import FileTokenStorage - - -@pytest.mark.asyncio -async def test_token_storage_with_expiry(tmp_path: Path): - """Test that tokens are stored with absolute expiry time and loaded correctly.""" - storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path) - - # Create a token with 3600 seconds expiry - token = OAuthToken( - access_token="test_token", - token_type="Bearer", - expires_in=3600, - refresh_token="refresh_token", - ) - - # Save the token - await storage.set_tokens(token) - - # Check that the file contains the dataclass format - token_file = storage._get_file_path("tokens") - data = json.loads(token_file.read_text()) - - assert "token_payload" in data - assert "expires_at" in data - assert data["expires_at"] is not None - # expires_at should be approximately now + 3600 seconds - expires_at = datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")) - expected = datetime.now(timezone.utc) + timedelta(seconds=3600) - assert abs((expires_at - expected).total_seconds()) < 2 - - # Load the token back - loaded_token = await storage.get_tokens() - assert loaded_token is not None - assert loaded_token.access_token == "test_token" - # expires_in should be recalculated to be approximately 3600 (minus loading time) - assert loaded_token.expires_in is not None - assert 3595 <= loaded_token.expires_in <= 3600 - - -@pytest.mark.asyncio -async def test_expired_token_returns_none(tmp_path: Path): - """Test that expired tokens return None when loaded.""" - storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path) - - # Manually create an already-expired token file - token_file = storage._get_file_path("tokens") - past_expiry = datetime.now(timezone.utc) - timedelta( - seconds=10 - ) # Expired 10 seconds ago - - expired_token = { - "token_payload": { - "access_token": "test_token", - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token": "refresh_token", - }, - "expires_at": past_expiry.isoformat(), - } - token_file.write_text(json.dumps(expired_token, indent=2, default=str)) - - # Load the token - should return None since it's expired - loaded_token = await storage.get_tokens() - assert loaded_token is None - - -@pytest.mark.asyncio -async def test_token_without_expiry(tmp_path: Path): - """Test that tokens without expires_in are handled correctly.""" - storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path) - - # Create a token without expires_in (perpetual token) - token = OAuthToken( - access_token="test_token", - token_type="Bearer", - expires_in=None, - refresh_token="refresh_token", - ) - - # Save the token - await storage.set_tokens(token) - - # Check that expires_at is None in the file - token_file = storage._get_file_path("tokens") - data = json.loads(token_file.read_text()) - assert data["expires_at"] is None - - # Load the token back - should work since no expiry - loaded_token = await storage.get_tokens() - assert loaded_token is not None - assert loaded_token.access_token == "test_token" - assert loaded_token.expires_in is None - - -@pytest.mark.asyncio -async def test_invalid_format_returns_none(tmp_path: Path): - """Test that invalid token format returns None.""" - storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path) - - # Manually write an invalid format token file (missing required fields) - token_file = storage._get_file_path("tokens") - invalid_token = { - "access_token": "invalid_token", - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token": "refresh_token", - } - token_file.write_text(json.dumps(invalid_token, indent=2)) - - # Try to load - should return None - loaded_token = await storage.get_tokens() - assert loaded_token is None - - -@pytest.mark.asyncio -async def test_token_expiry_recalculated_on_load(tmp_path: Path): - """Test that expires_in is correctly recalculated when loading tokens.""" - storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path) - - # Manually create a token file with a specific expires_at - token_file = storage._get_file_path("tokens") - future_expiry = datetime.now(timezone.utc) + timedelta( - seconds=1800 - ) # 30 minutes from now - - stored_token = { - "token_payload": { - "access_token": "test_token", - "token_type": "Bearer", - "expires_in": 3600, # Original value (will be recalculated) - "refresh_token": "refresh_token", - }, - "expires_at": future_expiry.isoformat(), - } - token_file.write_text(json.dumps(stored_token, indent=2, default=str)) - - # Load the token - loaded_token = await storage.get_tokens() - assert loaded_token is not None - # expires_in should be recalculated to approximately 1800 seconds - assert loaded_token.expires_in is not None - assert 1795 <= loaded_token.expires_in <= 1800 diff --git a/tests/client/test_client.py b/tests/client/test_client.py index bae0ca81f..9a41c7600 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -9,6 +9,7 @@ from mcp import McpError from mcp.client.auth import OAuthClientProvider from pydantic import AnyUrl +import fastmcp from fastmcp.client import Client from fastmcp.client.auth.bearer import BearerAuth from fastmcp.client.transports import ( @@ -435,11 +436,8 @@ async def test_server_info_custom_version(): async with client: result = client.initialize_result assert result.serverInfo.name == "DefaultVersionServer" - # Should fall back to MCP library version - assert result.serverInfo.version is not None - assert ( - result.serverInfo.version != "1.2.3" - ) # Should be different from custom version + # Should fall back to FastMCP version + assert result.serverInfo.version == fastmcp.__version__ async def test_client_nested_context_manager(fastmcp_server): @@ -457,12 +455,12 @@ async def test_client_nested_context_manager(fastmcp_server): assert client._session_state.session is not None session = client._session_state.session - # Re-use the same session + # Reuse the same session async with client: assert client.is_connected() assert client._session_state.session is session - # Re-use the same session + # Reuse the same session async with client: assert client.is_connected() assert client._session_state.session is session diff --git a/tests/client/test_openapi_experimental.py b/tests/client/test_openapi_experimental.py index c02bad7f8..6e32024b3 100644 --- a/tests/client/test_openapi_experimental.py +++ b/tests/client/test_openapi_experimental.py @@ -55,19 +55,19 @@ def run_proxy_server(host: str, port: int, shttp_url: str, **kwargs) -> None: app.run(host=host, port=port, **kwargs) -@pytest.fixture(scope="module") +@pytest.fixture def shttp_server() -> Generator[str, None, None]: with run_server_in_process(run_server, transport="http") as url: yield f"{url}/mcp" -@pytest.fixture(scope="module") +@pytest.fixture def sse_server() -> Generator[str, None, None]: with run_server_in_process(run_server, transport="sse") as url: yield f"{url}/sse" -@pytest.fixture(scope="module") +@pytest.fixture def proxy_server(shttp_server: str) -> Generator[str, None, None]: with run_server_in_process( run_proxy_server, diff --git a/tests/client/test_openapi_legacy.py b/tests/client/test_openapi_legacy.py index 2f25d6b22..07915c851 100644 --- a/tests/client/test_openapi_legacy.py +++ b/tests/client/test_openapi_legacy.py @@ -52,19 +52,19 @@ def run_proxy_server(host: str, port: int, shttp_url: str, **kwargs) -> None: app.run(host=host, port=port, **kwargs) -@pytest.fixture(scope="module") +@pytest.fixture def shttp_server() -> Generator[str, None, None]: with run_server_in_process(run_server, transport="http") as url: yield f"{url}/mcp" -@pytest.fixture(scope="module") +@pytest.fixture def sse_server() -> Generator[str, None, None]: with run_server_in_process(run_server, transport="sse") as url: yield f"{url}/sse" -@pytest.fixture(scope="module") +@pytest.fixture def proxy_server(shttp_server: str) -> Generator[str, None, None]: with run_server_in_process( run_proxy_server, diff --git a/tests/client/test_sse.py b/tests/client/test_sse.py index 419af39a8..f2fe86605 100644 --- a/tests/client/test_sse.py +++ b/tests/client/test_sse.py @@ -67,7 +67,7 @@ def run_server(host: str, port: int, **kwargs) -> None: fastmcp_server().run(host=host, port=port, **kwargs) -@pytest.fixture(autouse=True, scope="module") +@pytest.fixture(autouse=True) def sse_server() -> Generator[str, None, None]: with run_server_in_process(run_server, transport="sse") as url: yield f"{url}/sse" @@ -96,7 +96,9 @@ def run_nested_server(host: str, port: int) -> None: mount = Starlette(routes=[Mount("/nest-inner", app=app)]) mount2 = Starlette(routes=[Mount("/nest-outer", app=mount)]) server = uvicorn.Server( - config=uvicorn.Config(app=mount2, host=host, port=port, log_level="error") + config=uvicorn.Config( + app=mount2, host=host, port=port, log_level="error", ws="websockets-sansio" + ) ) server.run() @@ -128,18 +130,18 @@ class TestTimeout: async def test_timeout(self, sse_server: str): with pytest.raises( McpError, - match="Timed out while waiting for response to ClientRequest. Waited 0.01 seconds", + match="Timed out while waiting for response to ClientRequest. Waited 0.03 seconds", ): async with Client( transport=SSETransport(sse_server), - timeout=0.01, + timeout=0.03, ) as client: await client.call_tool("sleep", {"seconds": 0.1}) async def test_timeout_tool_call(self, sse_server: str): async with Client(transport=SSETransport(sse_server)) as client: with pytest.raises(McpError, match="Timed out"): - await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01) + await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.03) async def test_timeout_tool_call_overrides_client_timeout_if_lower( self, sse_server: str @@ -149,7 +151,7 @@ class TestTimeout: timeout=2, ) as client: with pytest.raises(McpError, match="Timed out"): - await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01) + await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.03) async def test_timeout_client_timeout_does_not_override_tool_call_timeout_if_lower( self, sse_server: str @@ -161,6 +163,6 @@ class TestTimeout: """ async with Client( transport=SSETransport(sse_server), - timeout=0.01, + timeout=0.1, ) as client: - await client.call_tool("sleep", {"seconds": 0.1}, timeout=2) + await client.call_tool("sleep", {"seconds": 0.03}, timeout=2) diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index 2286874bb..9c7896330 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -103,6 +103,7 @@ def run_nested_server(host: str, port: int) -> None: port=port, log_level="error", lifespan="on", + ws="websockets-sansio", ) ) server.run() @@ -225,9 +226,9 @@ class TestTimeout: with pytest.raises(McpError, match="Timed out"): async with Client( transport=StreamableHttpTransport(streamable_http_server), - timeout=0.1, + timeout=0.02, ) as client: - await client.call_tool("sleep", {"seconds": 0.2}) + await client.call_tool("sleep", {"seconds": 0.05}) async def test_timeout_tool_call(self, streamable_http_server: str): async with Client( diff --git a/tests/client/transports/test_uv_transport.py b/tests/client/transports/test_uv_transport.py index 45a2c3cfc..020f084ba 100644 --- a/tests/client/transports/test_uv_transport.py +++ b/tests/client/transports/test_uv_transport.py @@ -84,7 +84,7 @@ async def test_uv_transport_module(): with_packages=["fastmcp"], command="my_module", module=True, - project_directory=tmpdir, + project_directory=Path(tmpdir), keep_alive=False, ) ) diff --git a/tests/conftest.py b/tests/conftest.py index cffdbf831..0d7e7c090 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import socket from collections.abc import Callable from typing import Any @@ -22,3 +23,37 @@ def import_rich_rule(): def get_fn_name(fn: Callable[..., Any]) -> str: return fn.__name__ # ty: ignore[unresolved-attribute] + + +@pytest.fixture +def worker_id(request): + """Get the xdist worker ID, or 'master' if not using xdist.""" + return getattr(request.config, "workerinput", {}).get("workerid", "master") + + +@pytest.fixture +def free_port(): + """Get a free port for the test to use.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + s.listen(1) + port = s.getsockname()[1] + return port + + +@pytest.fixture +def free_port_factory(worker_id): + """Factory to get free ports that tracks used ports per test session.""" + used_ports = set() + + def get_port(): + while True: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + s.listen(1) + port = s.getsockname()[1] + if port not in used_ports: + used_ports.add(port) + return port + + return get_port diff --git a/tests/contrib/test_bulk_tool_caller.py b/tests/contrib/test_bulk_tool_caller.py index 7a0ce4415..eb641550b 100644 --- a/tests/contrib/test_bulk_tool_caller.py +++ b/tests/contrib/test_bulk_tool_caller.py @@ -67,7 +67,7 @@ def no_return_tool_result_factory(arg1: str) -> CallToolRequestResult: ) -@pytest.fixture(scope="module") +@pytest.fixture def live_server_with_tool() -> FastMCP: """Fixture to create a FastMCP server instance with the echo_tool registered.""" server = FastMCP() diff --git a/tests/experimental/openapi_parser/server/openapi/test_openapi_features.py b/tests/experimental/openapi_parser/server/openapi/test_openapi_features.py index abbf3c926..c656c25d3 100644 --- a/tests/experimental/openapi_parser/server/openapi/test_openapi_features.py +++ b/tests/experimental/openapi_parser/server/openapi/test_openapi_features.py @@ -154,6 +154,20 @@ class TestParameterHandling: assert "tags" in properties assert "X-API-Key" in properties + # Check that parameter descriptions are included + assert "description" in properties["query"], ( + "Query parameter should have description" + ) + assert properties["query"]["description"] == "Search query" + assert "description" in properties["limit"], ( + "Limit parameter should have description" + ) + assert properties["limit"]["description"] == "Maximum number of results" + assert "description" in properties["tags"], ( + "Tags parameter should have description" + ) + assert properties["tags"]["description"] == "Filter by tags" + # Check that required parameters are marked as required required = params.get("required", []) assert "query" in required diff --git a/tests/experimental/openapi_parser/utilities/openapi/test_direct_array_schemas.py b/tests/experimental/openapi_parser/utilities/openapi/test_direct_array_schemas.py new file mode 100644 index 000000000..02d641e08 --- /dev/null +++ b/tests/experimental/openapi_parser/utilities/openapi/test_direct_array_schemas.py @@ -0,0 +1,323 @@ +"""Test handling of direct array schemas in request bodies (FastAPI list parameters).""" + +from fastmcp.experimental.utilities.openapi.models import ( + HTTPRoute, + ParameterInfo, + RequestBodyInfo, +) +from fastmcp.experimental.utilities.openapi.schemas import ( + _combine_schemas_and_map_params, +) + + +class TestDirectArraySchemas: + """Test handling of direct array schemas like those generated by FastAPI for list[str] parameters.""" + + def test_simple_direct_array_schema(self): + """Test route with direct array request body schema.""" + route = HTTPRoute( + path="/simple_list", + method="POST", + operation_id="simple_list_tool", + summary="Simple List Tool", + description="A simple tool that takes a list of strings.", + parameters=[], + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json": { + "items": {"type": "string"}, + "type": "array", + "title": "Values", + } + }, + ), + ) + + combined_schema, param_map = _combine_schemas_and_map_params(route) + + # Should create a single parameter from the title + assert combined_schema["type"] == "object" + assert "values" in combined_schema["properties"] + assert "values" in combined_schema["required"] + + # Check the array schema is preserved + values_schema = combined_schema["properties"]["values"] + assert values_schema["type"] == "array" + assert values_schema["items"]["type"] == "string" + assert values_schema["title"] == "Values" + + # Check parameter mapping + assert "values" in param_map + assert param_map["values"]["location"] == "body" + assert param_map["values"]["openapi_name"] == "values" + + def test_int_array_schema(self): + """Test route with integer array request body schema.""" + route = HTTPRoute( + path="/int_list", + method="POST", + operation_id="int_list_tool", + summary="Integer List Tool", + description="A simple tool that takes a list of integers.", + parameters=[], + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json": { + "items": {"type": "integer"}, + "type": "array", + "title": "Numbers", + } + }, + ), + ) + + combined_schema, param_map = _combine_schemas_and_map_params(route) + + # Should create a single parameter from the title + assert combined_schema["type"] == "object" + assert "numbers" in combined_schema["properties"] + assert "numbers" in combined_schema["required"] + + # Check the array schema is preserved + numbers_schema = combined_schema["properties"]["numbers"] + assert numbers_schema["type"] == "array" + assert numbers_schema["items"]["type"] == "integer" + assert numbers_schema["title"] == "Numbers" + + def test_mixed_params_with_direct_array(self): + """Test route with both URL parameters and direct array request body.""" + route = HTTPRoute( + path="/mixed_params", + method="POST", + operation_id="mixed_params_tool", + summary="Mixed Params Tool", + description="A tool with both list and simple parameters.", + parameters=[ + ParameterInfo( + name="prefix", + location="query", + required=True, + schema={"type": "string", "title": "Prefix"}, + ) + ], + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json": { + "type": "array", + "items": {"type": "string"}, + "title": "Values", + } + }, + ), + ) + + combined_schema, param_map = _combine_schemas_and_map_params(route) + + # Should have both parameters + assert combined_schema["type"] == "object" + assert "prefix" in combined_schema["properties"] # From query param + assert "values" in combined_schema["properties"] # From body array + assert "prefix" in combined_schema["required"] + assert "values" in combined_schema["required"] + + # Check query parameter + prefix_schema = combined_schema["properties"]["prefix"] + assert prefix_schema["type"] == "string" + + # Check body array parameter + values_schema = combined_schema["properties"]["values"] + assert values_schema["type"] == "array" + assert values_schema["items"]["type"] == "string" + + # Check parameter mappings + assert param_map["prefix"]["location"] == "query" + assert param_map["values"]["location"] == "body" + + def test_direct_array_no_title(self): + """Test direct array schema without title.""" + route = HTTPRoute( + path="/no_title", + method="POST", + operation_id="no_title_tool", + parameters=[], + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json": { + "type": "array", + "items": {"type": "string"}, + # No title + } + }, + ), + ) + + combined_schema, param_map = _combine_schemas_and_map_params(route) + + # Should fall back to "body" (default title falls back) + assert combined_schema["type"] == "object" + assert "body" in combined_schema["properties"] + assert "body" in combined_schema["required"] + + # Check parameter mapping + assert param_map["body"]["location"] == "body" + assert param_map["body"]["openapi_name"] == "body" + + def test_direct_primitive_schema(self): + """Test direct primitive (non-array) request body schema.""" + route = HTTPRoute( + path="/primitive", + method="POST", + operation_id="primitive_tool", + parameters=[], + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json": {"type": "string", "title": "Message"} + }, + ), + ) + + combined_schema, param_map = _combine_schemas_and_map_params(route) + + # Should create a single parameter from the title + assert combined_schema["type"] == "object" + assert "message" in combined_schema["properties"] + assert "message" in combined_schema["required"] + + # Check the primitive schema is preserved + message_schema = combined_schema["properties"]["message"] + assert message_schema["type"] == "string" + assert message_schema["title"] == "Message" + + def test_direct_array_optional(self): + """Test direct array schema that's not required.""" + route = HTTPRoute( + path="/optional_list", + method="POST", + operation_id="optional_list_tool", + parameters=[], + request_body=RequestBodyInfo( + required=False, # Not required + content_schema={ + "application/json": { + "type": "array", + "items": {"type": "string"}, + "title": "OptionalValues", + } + }, + ), + ) + + combined_schema, param_map = _combine_schemas_and_map_params(route) + + # Should create parameter but not mark as required + assert combined_schema["type"] == "object" + assert "optionalvalues" in combined_schema["properties"] + assert "optionalvalues" not in combined_schema["required"] + + def test_direct_array_title_sanitization(self): + """Test that titles with special characters are sanitized.""" + route = HTTPRoute( + path="/special_chars", + method="POST", + operation_id="special_chars_tool", + parameters=[], + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json": { + "type": "array", + "items": {"type": "string"}, + "title": "Special-Chars & Spaces!", + } + }, + ), + ) + + combined_schema, param_map = _combine_schemas_and_map_params(route) + + # Should sanitize the title to a valid parameter name (& becomes multiple underscores) + assert combined_schema["type"] == "object" + assert "special_chars___spaces_" in combined_schema["properties"] + + def test_direct_array_title_starting_with_number(self): + """Test that titles starting with numbers are handled.""" + route = HTTPRoute( + path="/number_start", + method="POST", + operation_id="number_start_tool", + parameters=[], + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json": { + "type": "array", + "items": {"type": "string"}, + "title": "123Values", + } + }, + ), + ) + + combined_schema, param_map = _combine_schemas_and_map_params(route) + + # Should fall back to "body_data" since "123values" starts with a number + assert combined_schema["type"] == "object" + assert "body_data" in combined_schema["properties"] + + def test_preserve_existing_behavior_object_body(self): + """Test that existing object-based request bodies still work.""" + route = HTTPRoute( + path="/object_body", + method="POST", + operation_id="object_body_tool", + parameters=[], + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + }, + "required": ["name"], + } + }, + ), + ) + + combined_schema, param_map = _combine_schemas_and_map_params(route) + + # Should handle object bodies as before + assert combined_schema["type"] == "object" + assert "name" in combined_schema["properties"] + assert "age" in combined_schema["properties"] + assert "name" in combined_schema["required"] + assert "age" not in combined_schema["required"] + + def test_preserve_existing_behavior_ref_body(self): + """Test that $ref-based request bodies still work.""" + route = HTTPRoute( + path="/ref_body", + method="POST", + operation_id="ref_body_tool", + parameters=[], + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json": {"$ref": "#/components/schemas/UserCreate"} + }, + ), + ) + + combined_schema, param_map = _combine_schemas_and_map_params(route) + + # Should handle $ref bodies as before (refs get converted to $defs) + assert combined_schema["type"] == "object" + assert "body" in combined_schema["properties"] + assert combined_schema["properties"]["body"]["$ref"] == "#/$defs/UserCreate" diff --git a/tests/experimental/openapi_parser/utilities/openapi/test_schemas.py b/tests/experimental/openapi_parser/utilities/openapi/test_schemas.py index ea8c8e2f6..1f4966d6e 100644 --- a/tests/experimental/openapi_parser/utilities/openapi/test_schemas.py +++ b/tests/experimental/openapi_parser/utilities/openapi/test_schemas.py @@ -286,6 +286,107 @@ class TestSchemaProcessing: ] assert array_item_prop["$ref"] == "#/$defs/RefProp" + def test_replace_ref_with_defs_in_additional_properties(self): + """Test replacing $ref deeply in 'additionalProperties'.""" + + add_props_schema = { + "description": "An invoice with a fixed header and a flexible set of line items.", + "type": "object", + "properties": { + "invoice_number": { + "type": "string", + "description": "The unique identifier for the invoice.", + }, + "customer_name": { + "type": "string", + "description": "The name of the customer.", + }, + "total_amount": { + "type": "number", + "description": "The total amount of the invoice.", + }, + }, + "required": ["invoice_number", "customer_name", "total_amount"], + "additionalProperties": {"$ref": "#/components/schemas/Link"}, + } + + # Use our recursive replacement approach + result = _replace_ref_with_defs(add_props_schema) + + # Check additional properties + add_props = result["additionalProperties"] + assert add_props["$ref"] == "#/$defs/Link" + + def test_replace_ref_with_defs_with_bool_additional_properties(self): + """Test replacing a bool 'additionalProperties'.""" + + add_props_schema = { + "description": "An invoice with a fixed header and a flexible set of line items.", + "type": "object", + "properties": { + "invoice_number": { + "type": "string", + "description": "The unique identifier for the invoice.", + }, + "customer_name": { + "type": "string", + "description": "The name of the customer.", + }, + "total_amount": { + "type": "number", + "description": "The total amount of the invoice.", + }, + }, + "required": ["invoice_number", "customer_name", "total_amount"], + "additionalProperties": False, + } + + # Use our recursive replacement approach + result = _replace_ref_with_defs(add_props_schema) + + # Check additional properties + add_props = result["additionalProperties"] + assert add_props is False + + def test_replace_ref_with_defs_with_inner_schema_additional_properties(self): + """Test replacing a inner schema 'additionalProperties'.""" + + add_props_schema = { + "description": "An invoice with a fixed header and a flexible set of line items.", + "type": "object", + "properties": { + "invoice_number": { + "type": "string", + "description": "The unique identifier for the invoice.", + }, + "customer_name": { + "type": "string", + "description": "The name of the customer.", + }, + "total_amount": { + "type": "number", + "description": "The total amount of the invoice.", + }, + }, + "required": ["invoice_number", "customer_name", "total_amount"], + "additionalProperties": { + "type": "integer", + "format": "int32", + "description": "The total amount of the invoice.", + }, + } + + # Use our recursive replacement approach + result = _replace_ref_with_defs(add_props_schema) + + # Check additional properties + add_props = result["additionalProperties"] + assert add_props == { + "type": "integer", + "format": "int32", + "description": "The total amount of the invoice.", + } + def test_parameter_collision_suffixing_logic(self): """Test the specific logic for parameter collision suffixing.""" # Create a route that would definitely cause collisions diff --git a/tests/integration_tests/test_github_mcp_remote.py b/tests/integration_tests/test_github_mcp_remote.py index fb122a2e6..6d8b3e2d5 100644 --- a/tests/integration_tests/test_github_mcp_remote.py +++ b/tests/integration_tests/test_github_mcp_remote.py @@ -34,84 +34,90 @@ def fixture_streamable_http_client() -> Client[StreamableHttpTransport]: ) -async def test_connect_disconnect( - streamable_http_client: Client[StreamableHttpTransport], -): - async with streamable_http_client: - assert streamable_http_client.is_connected() is True - await streamable_http_client._disconnect() # pylint: disable=W0212 (protected-access) - assert streamable_http_client.is_connected() is False +@pytest.mark.flaky(retries=2, delay=1) +class TestGithubMCPRemote: + async def test_connect_disconnect( + self, + streamable_http_client: Client[StreamableHttpTransport], + ): + async with streamable_http_client: + assert streamable_http_client.is_connected() is True + await streamable_http_client._disconnect() # pylint: disable=W0212 (protected-access) + assert streamable_http_client.is_connected() is False + async def test_ping(self, streamable_http_client: Client[StreamableHttpTransport]): + """Test pinging the server.""" + async with streamable_http_client: + assert streamable_http_client.is_connected() is True + result = await streamable_http_client.ping() + assert result is True -async def test_ping(streamable_http_client: Client[StreamableHttpTransport]): - """Test pinging the server.""" - async with streamable_http_client: - assert streamable_http_client.is_connected() is True - result = await streamable_http_client.ping() - assert result is True + async def test_list_tools( + self, streamable_http_client: Client[StreamableHttpTransport] + ): + """Test listing the MCP tools""" + async with streamable_http_client: + assert streamable_http_client.is_connected() + tools = await streamable_http_client.list_tools() + assert isinstance(tools, list) + assert len(tools) > 0 # Ensure the tools list is non-empty + for tool in tools: + assert isinstance(tool, Tool) + assert len(tool.name) > 0 + assert tool.description is not None and len(tool.description) > 0 + assert isinstance(tool.inputSchema, dict) + assert len(tool.inputSchema) > 0 + async def test_list_resources( + self, streamable_http_client: Client[StreamableHttpTransport] + ): + """Test listing the MCP resources""" + async with streamable_http_client: + assert streamable_http_client.is_connected() + resources = await streamable_http_client.list_resources() + assert isinstance(resources, list) + assert len(resources) == 0 -async def test_list_tools(streamable_http_client: Client[StreamableHttpTransport]): - """Test listing the MCP tools""" - async with streamable_http_client: - assert streamable_http_client.is_connected() - tools = await streamable_http_client.list_tools() - assert isinstance(tools, list) - assert len(tools) > 0 # Ensure the tools list is non-empty - for tool in tools: - assert isinstance(tool, Tool) - assert len(tool.name) > 0 - assert tool.description is not None and len(tool.description) > 0 - assert isinstance(tool.inputSchema, dict) - assert len(tool.inputSchema) > 0 + async def test_list_prompts( + self, streamable_http_client: Client[StreamableHttpTransport] + ): + """Test listing the MCP prompts""" + async with streamable_http_client: + assert streamable_http_client.is_connected() + prompts = await streamable_http_client.list_prompts() + # there is at least one prompt (as of July 2025) + assert len(prompts) >= 1 + async def test_call_tool_ko( + self, streamable_http_client: Client[StreamableHttpTransport] + ): + """Test calling a non-existing tool""" + async with streamable_http_client: + assert streamable_http_client.is_connected() + with pytest.raises(McpError, match="tool not found"): + await streamable_http_client.call_tool("foo") -async def test_list_resources(streamable_http_client: Client[StreamableHttpTransport]): - """Test listing the MCP resources""" - async with streamable_http_client: - assert streamable_http_client.is_connected() - resources = await streamable_http_client.list_resources() - assert isinstance(resources, list) - assert len(resources) == 0 + async def test_call_tool_list_commits( + self, + streamable_http_client: Client[StreamableHttpTransport], + ): + """Test calling a list_commit tool""" + async with streamable_http_client: + assert streamable_http_client.is_connected() + result = await streamable_http_client.call_tool( + "list_commits", {"owner": "jlowin", "repo": "fastmcp"} + ) - -async def test_list_prompts(streamable_http_client: Client[StreamableHttpTransport]): - """Test listing the MCP prompts""" - async with streamable_http_client: - assert streamable_http_client.is_connected() - prompts = await streamable_http_client.list_prompts() - # there is at least one prompt (as of July 2025) - assert len(prompts) >= 1 - - -async def test_call_tool_ko(streamable_http_client: Client[StreamableHttpTransport]): - """Test calling a non-existing tool""" - async with streamable_http_client: - assert streamable_http_client.is_connected() - with pytest.raises(McpError, match="tool not found"): - await streamable_http_client.call_tool("foo") - - -async def test_call_tool_list_commits( - streamable_http_client: Client[StreamableHttpTransport], -): - """Test calling a list_commit tool""" - async with streamable_http_client: - assert streamable_http_client.is_connected() - result = await streamable_http_client.call_tool( - "list_commits", {"owner": "jlowin", "repo": "fastmcp"} - ) - - # at this time, the github server does not support structured content - assert result.structured_content is None - assert isinstance(result.content, list) - assert len(result.content) == 1 - commits = json.loads(result.content[0].text) # type: ignore[attr-defined] - for commit in commits: - assert isinstance(commit, dict) - assert "sha" in commit - assert "commit" in commit - assert "author" in commit["commit"] - assert len(commit["commit"]["author"]["date"]) > 0 - assert len(commit["commit"]["author"]["name"]) > 0 - assert len(commit["commit"]["author"]["email"]) > 0 + # at this time, the github server does not support structured content + assert result.structured_content is None + assert isinstance(result.content, list) + assert len(result.content) == 1 + commits = json.loads(result.content[0].text) # type: ignore[attr-defined] + for commit in commits: + assert isinstance(commit, dict) + assert "sha" in commit + assert "commit" in commit + assert "author" in commit["commit"] + assert len(commit["commit"]["author"]["date"]) > 0 + assert len(commit["commit"]["author"]["name"]) > 0 + assert len(commit["commit"]["author"]["email"]) > 0 diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index 68178208c..16abb5c75 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -103,7 +103,7 @@ class TestResourceTemplate: # This should fail - 'unknown' is not a function parameter with pytest.raises( ValueError, - match="Required function arguments .* must be a subset of the URI parameters", + match="Required function arguments .* must be a subset of the URI path parameters", ): ResourceTemplate.from_function( fn=my_func, @@ -131,7 +131,7 @@ class TestResourceTemplate: # This should fail - required param is not in URI with pytest.raises( ValueError, - match="Required function arguments .* must be a subset of the URI parameters", + match="Required function arguments .* must be a subset of the URI path parameters", ): ResourceTemplate.from_function( fn=func_with_required, @@ -156,7 +156,7 @@ class TestResourceTemplate: # This fails - missing one required param with pytest.raises( ValueError, - match="Required function arguments .* must be a subset of the URI parameters", + match="Required function arguments .* must be a subset of the URI path parameters", ): ResourceTemplate.from_function( fn=multi_required, @@ -707,3 +707,250 @@ class TestContextHandling: assert isinstance(resource, FunctionResource) content = await resource.read() assert content == "42" + + +class TestQueryParameterExtraction: + """Test basic query parameter extraction from URIs.""" + + async def test_single_query_param(self): + """Test resource template with single query parameter.""" + + def get_data(id: str, format: str = "json") -> str: + return f"Data {id} in {format}" + + template = ResourceTemplate.from_function( + fn=get_data, + uri_template="data://{id}{?format}", + name="test", + ) + + # Match without query param (uses default) + params = template.matches("data://123") + assert params == {"id": "123"} + + # Match with query param + params = template.matches("data://123?format=xml") + assert params == {"id": "123", "format": "xml"} + + async def test_multiple_query_params(self): + """Test resource template with multiple query parameters.""" + + def get_items(category: str, page: int = 1, limit: int = 10) -> str: + return f"Category {category}, page {page}, limit {limit}" + + template = ResourceTemplate.from_function( + fn=get_items, + uri_template="items://{category}{?page,limit}", + name="test", + ) + + # No query params + params = template.matches("items://books") + assert params == {"category": "books"} + + # One query param + params = template.matches("items://books?page=2") + assert params == {"category": "books", "page": "2"} + + # Both query params + params = template.matches("items://books?page=2&limit=20") + assert params == {"category": "books", "page": "2", "limit": "20"} + + +class TestQueryParameterTypeCoercion: + """Test type coercion for query parameters.""" + + async def test_int_coercion(self): + """Test integer type coercion for query parameters.""" + + def get_page(resource: str, page: int = 1) -> dict: + return {"resource": resource, "page": page, "type": type(page).__name__} + + template = ResourceTemplate.from_function( + fn=get_page, + uri_template="resource://{resource}{?page}", + name="test", + ) + + # Create resource with string query param + resource = await template.create_resource( + "resource://docs?page=5", + {"resource": "docs", "page": "5"}, + ) + + content = await resource.read() + assert '"page":5' in content + assert '"type":"int"' in content + + async def test_bool_coercion(self): + """Test boolean type coercion for query parameters.""" + + def get_config(name: str, enabled: bool = False) -> dict: + return {"name": name, "enabled": enabled, "type": type(enabled).__name__} + + template = ResourceTemplate.from_function( + fn=get_config, + uri_template="config://{name}{?enabled}", + name="test", + ) + + # Test true value + resource = await template.create_resource( + "config://feature?enabled=true", + {"name": "feature", "enabled": "true"}, + ) + content = await resource.read() + assert '"enabled":true' in content + + # Test false value + resource = await template.create_resource( + "config://feature?enabled=false", + {"name": "feature", "enabled": "false"}, + ) + content = await resource.read() + assert '"enabled":false' in content + + async def test_float_coercion(self): + """Test float type coercion for query parameters.""" + + def get_metrics(service: str, threshold: float = 0.5) -> dict: + return { + "service": service, + "threshold": threshold, + "type": type(threshold).__name__, + } + + template = ResourceTemplate.from_function( + fn=get_metrics, + uri_template="metrics://{service}{?threshold}", + name="test", + ) + + resource = await template.create_resource( + "metrics://api?threshold=0.95", + {"service": "api", "threshold": "0.95"}, + ) + + content = await resource.read() + assert '"threshold":0.95' in content + assert '"type":"float"' in content + + +class TestQueryParameterValidation: + """Test validation rules for query parameters.""" + + def test_query_params_must_be_optional(self): + """Test that query parameters must have default values.""" + + def invalid_func(id: str, format: str) -> str: + return f"Data {id} in {format}" + + with pytest.raises( + ValueError, + match="Query parameters .* must be optional function parameters with default values", + ): + ResourceTemplate.from_function( + fn=invalid_func, + uri_template="data://{id}{?format}", + name="test", + ) + + def test_required_params_in_path(self): + """Test that required parameters must be in path.""" + + def valid_func(id: str, format: str = "json") -> str: + return f"Data {id} in {format}" + + # This should work - required param in path, optional in query + template = ResourceTemplate.from_function( + fn=valid_func, + uri_template="data://{id}{?format}", + name="test", + ) + assert template.uri_template == "data://{id}{?format}" + + +class TestQueryParameterWithDefaults: + """Test that missing query parameters use default values.""" + + async def test_missing_query_param_uses_default(self): + """Test that missing query parameters fall back to defaults.""" + + def get_data(id: str, format: str = "json", verbose: bool = False) -> dict: + return {"id": id, "format": format, "verbose": verbose} + + template = ResourceTemplate.from_function( + fn=get_data, + uri_template="data://{id}{?format,verbose}", + name="test", + ) + + # No query params - should use defaults + resource = await template.create_resource( + "data://123", + {"id": "123"}, + ) + + content = await resource.read() + assert '"format":"json"' in content + assert '"verbose":false' in content + + async def test_partial_query_params(self): + """Test providing only some query parameters.""" + + def get_data( + id: str, format: str = "json", limit: int = 10, offset: int = 0 + ) -> dict: + return {"id": id, "format": format, "limit": limit, "offset": offset} + + template = ResourceTemplate.from_function( + fn=get_data, + uri_template="data://{id}{?format,limit,offset}", + name="test", + ) + + # Provide only some query params + resource = await template.create_resource( + "data://123?limit=20", + {"id": "123", "limit": "20"}, + ) + + content = await resource.read() + assert '"format":"json"' in content # default + assert '"limit":20' in content # provided + assert '"offset":0' in content # default + + +class TestQueryParameterWithWildcards: + """Test query parameters combined with wildcard path parameters.""" + + async def test_wildcard_with_query_params(self): + """Test combining wildcard path params with query params.""" + + def get_file(path: str, encoding: str = "utf-8", lines: int = 100) -> dict: + return {"path": path, "encoding": encoding, "lines": lines} + + template = ResourceTemplate.from_function( + fn=get_file, + uri_template="files://{path*}{?encoding,lines}", + name="test", + ) + + # Match path with query params + params = template.matches("files://src/test/data.txt?encoding=ascii&lines=50") + assert params == { + "path": "src/test/data.txt", + "encoding": "ascii", + "lines": "50", + } + + # Create resource + resource = await template.create_resource( + "files://src/test/data.txt?lines=50", + {"path": "src/test/data.txt", "lines": "50"}, + ) + + content = await resource.read() + assert '"path":"src/test/data.txt"' in content + assert '"encoding":"utf-8"' in content # default + assert '"lines":50' in content # provided diff --git a/tests/resources/test_resources.py b/tests/resources/test_resources.py index 1e165eea9..3e30be1d8 100644 --- a/tests/resources/test_resources.py +++ b/tests/resources/test_resources.py @@ -85,14 +85,15 @@ class TestResourceValidation: ) assert resource.mime_type == "application/json" - async def test_resource_read_abstract(self): - """Test that Resource.read() is abstract.""" + async def test_resource_read_not_implemented(self): + """Test that Resource.read() raises NotImplementedError.""" class ConcreteResource(Resource): pass - with pytest.raises(TypeError, match="abstract method"): - ConcreteResource(uri=AnyUrl("test://test"), name="test") # type: ignore + resource = ConcreteResource(uri=AnyUrl("test://test"), name="test") # type: ignore + with pytest.raises(NotImplementedError, match="Subclasses must implement read"): + await resource.read() def test_resource_meta_parameter(self): """Test that meta parameter is properly handled.""" diff --git a/tests/server/auth/providers/test_aws.py b/tests/server/auth/providers/test_aws.py new file mode 100644 index 000000000..ec48a5bc7 --- /dev/null +++ b/tests/server/auth/providers/test_aws.py @@ -0,0 +1,245 @@ +"""Unit tests for AWS Cognito OAuth provider.""" + +import os +from contextlib import contextmanager +from unittest.mock import patch + +import pytest + +from fastmcp.server.auth.providers.aws import ( + AWSCognitoProvider, + AWSCognitoProviderSettings, +) + + +@contextmanager +def mock_cognito_oidc_discovery(): + """Context manager to mock AWS Cognito OIDC discovery endpoint.""" + mock_oidc_config = { + "issuer": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXXXXXX", + "authorization_endpoint": "https://test.auth.us-east-1.amazoncognito.com/oauth2/authorize", + "token_endpoint": "https://test.auth.us-east-1.amazoncognito.com/oauth2/token", + "jwks_uri": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXXXXXX/.well-known/jwks.json", + "userinfo_endpoint": "https://test.auth.us-east-1.amazoncognito.com/oauth2/userInfo", + "response_types_supported": ["code", "token"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], + "scopes_supported": ["openid", "email", "phone", "profile"], + "token_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post", + ], + } + + with patch("httpx.get") as mock_get: + mock_response = mock_get.return_value + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_oidc_config + yield + + +class TestAWSCognitoProviderSettings: + """Test settings for AWS Cognito OAuth provider.""" + + def test_settings_from_env_vars(self): + """Test that settings can be loaded from environment variables.""" + with patch.dict( + os.environ, + { + "FASTMCP_SERVER_AUTH_AWS_COGNITO_USER_POOL_ID": "us-east-1_XXXXXXXXX", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_AWS_REGION": "us-east-1", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_ID": "env_client_id", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_SECRET": "env_secret", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_BASE_URL": "https://example.com", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_REDIRECT_PATH": "/custom/callback", + }, + ): + settings = AWSCognitoProviderSettings() + + assert settings.user_pool_id == "us-east-1_XXXXXXXXX" + assert settings.aws_region == "us-east-1" + assert settings.client_id == "env_client_id" + assert ( + settings.client_secret + and settings.client_secret.get_secret_value() == "env_secret" + ) + assert settings.base_url == "https://example.com" + assert settings.redirect_path == "/custom/callback" + + def test_settings_explicit_override_env(self): + """Test that explicit settings override environment variables.""" + with patch.dict( + os.environ, + { + "FASTMCP_SERVER_AUTH_AWS_COGNITO_USER_POOL_ID": "env_pool_id", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_ID": "env_client_id", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_SECRET": "env_secret", + }, + ): + settings = AWSCognitoProviderSettings.model_validate( + { + "user_pool_id": "explicit_pool_id", + "client_id": "explicit_client_id", + "client_secret": "explicit_secret", + } + ) + + assert settings.user_pool_id == "explicit_pool_id" + assert settings.client_id == "explicit_client_id" + assert ( + settings.client_secret + and settings.client_secret.get_secret_value() == "explicit_secret" + ) + + +class TestAWSCognitoProvider: + """Test AWSCognitoProvider initialization.""" + + def test_init_with_explicit_params(self): + """Test initialization with explicit parameters.""" + with mock_cognito_oidc_discovery(): + provider = AWSCognitoProvider( + user_pool_id="us-east-1_XXXXXXXXX", + aws_region="us-east-1", + client_id="test_client", + client_secret="test_secret", + base_url="https://example.com", + redirect_path="/custom/callback", + required_scopes=["openid", "email"], + ) + + # Check that the provider was initialized correctly + assert provider._upstream_client_id == "test_client" + assert provider._upstream_client_secret.get_secret_value() == "test_secret" + assert ( + str(provider.base_url) == "https://example.com/" + ) # URLs get normalized with trailing slash + assert provider._redirect_path == "/custom/callback" + # OIDC provider should have discovered the endpoints automatically + assert ( + provider._upstream_authorization_endpoint + == "https://test.auth.us-east-1.amazoncognito.com/oauth2/authorize" + ) + assert ( + provider._upstream_token_endpoint + == "https://test.auth.us-east-1.amazoncognito.com/oauth2/token" + ) + + @pytest.mark.parametrize( + "scopes_env", + [ + "openid,email", + '["openid", "email"]', + ], + ) + def test_init_with_env_vars(self, scopes_env): + """Test initialization with environment variables.""" + with patch.dict( + os.environ, + { + "FASTMCP_SERVER_AUTH_AWS_COGNITO_USER_POOL_ID": "us-east-1_XXXXXXXXX", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_AWS_REGION": "us-east-1", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_ID": "env_client_id", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_SECRET": "env_secret", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_BASE_URL": "https://env-example.com", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_REQUIRED_SCOPES": scopes_env, + }, + ): + with mock_cognito_oidc_discovery(): + provider = AWSCognitoProvider() + + assert provider._upstream_client_id == "env_client_id" + assert ( + provider._upstream_client_secret.get_secret_value() == "env_secret" + ) + assert str(provider.base_url) == "https://env-example.com/" + assert provider._token_validator.required_scopes == ["openid", "email"] + + def test_init_explicit_overrides_env(self): + """Test that explicit parameters override environment variables.""" + with patch.dict( + os.environ, + { + "FASTMCP_SERVER_AUTH_AWS_COGNITO_USER_POOL_ID": "env_pool_id", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_ID": "env_client_id", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_SECRET": "env_secret", + }, + ): + with mock_cognito_oidc_discovery(): + provider = AWSCognitoProvider( + user_pool_id="explicit_pool_id", + client_id="explicit_client", + client_secret="explicit_secret", + base_url="https://example.com", + ) + + assert provider._upstream_client_id == "explicit_client" + assert ( + provider._upstream_client_secret.get_secret_value() + == "explicit_secret" + ) + # OIDC discovery should have configured the endpoints automatically + assert provider._upstream_authorization_endpoint is not None + + def test_init_missing_user_pool_id_raises_error(self): + """Test that missing user_pool_id raises ValueError.""" + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(ValueError, match="user_pool_id is required"): + AWSCognitoProvider( + client_id="test_client", + client_secret="test_secret", + ) + + def test_init_missing_client_id_raises_error(self): + """Test that missing client_id raises ValueError.""" + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(ValueError, match="client_id is required"): + AWSCognitoProvider( + user_pool_id="us-east-1_XXXXXXXXX", + client_secret="test_secret", + ) + + def test_init_missing_client_secret_raises_error(self): + """Test that missing client_secret raises ValueError.""" + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(ValueError, match="client_secret is required"): + AWSCognitoProvider( + user_pool_id="us-east-1_XXXXXXXXX", + client_id="test_client", + ) + + def test_init_defaults(self): + """Test that default values are applied correctly.""" + with mock_cognito_oidc_discovery(): + provider = AWSCognitoProvider( + user_pool_id="us-east-1_XXXXXXXXX", + client_id="test_client", + client_secret="test_secret", + base_url="https://example.com", + ) + + # Check defaults + assert str(provider.base_url) == "https://example.com/" + assert provider._redirect_path == "/auth/callback" + assert provider._token_validator.required_scopes == ["openid"] + assert provider.aws_region == "eu-central-1" + + def test_oidc_discovery_integration(self): + """Test that OIDC discovery endpoints are used correctly.""" + with mock_cognito_oidc_discovery(): + provider = AWSCognitoProvider( + user_pool_id="us-west-2_YYYYYYYY", + aws_region="us-west-2", + client_id="test_client", + client_secret="test_secret", + base_url="https://example.com", + ) + + # OIDC discovery should have configured the endpoints automatically + assert provider._upstream_authorization_endpoint is not None + assert provider._upstream_token_endpoint is not None + assert "amazoncognito.com" in provider._upstream_authorization_endpoint + + +# Token verification functionality is now tested as part of the OIDC provider integration +# The CognitoTokenVerifier class is an internal implementation detail diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index 2c08df8d2..ec360403a 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -2,11 +2,15 @@ import os from unittest.mock import patch -from urllib.parse import urlparse +from urllib.parse import parse_qs, urlparse import pytest +from mcp.server.auth.provider import AuthorizationParams +from mcp.shared.auth import OAuthClientInformationFull +from pydantic import AnyUrl from fastmcp.server.auth.providers.azure import AzureProvider +from fastmcp.server.auth.providers.jwt import JWTVerifier class TestAzureProvider: @@ -95,6 +99,7 @@ class TestAzureProvider: client_id="test_client", client_secret="test_secret", tenant_id="test-tenant", + required_scopes=["User.Read"], ) # Check defaults @@ -109,6 +114,7 @@ class TestAzureProvider: client_secret="test_secret", tenant_id="my-tenant-id", base_url="https://myserver.com", + required_scopes=["User.Read"], ) # Check that endpoints use the correct Azure OAuth2 v2.0 endpoints with tenant @@ -131,6 +137,7 @@ class TestAzureProvider: client_id="test_client", client_secret="test_secret", tenant_id="organizations", + required_scopes=["User.Read"], ) parsed = urlparse(provider1._upstream_authorization_endpoint) assert "/organizations/" in parsed.path @@ -140,6 +147,7 @@ class TestAzureProvider: client_id="test_client", client_secret="test_secret", tenant_id="consumers", + required_scopes=["User.Read"], ) parsed = urlparse(provider2._upstream_authorization_endpoint) assert "/consumers/" in parsed.path @@ -162,3 +170,107 @@ class TestAzureProvider: # Provider should initialize successfully with these scopes assert provider is not None + + def test_init_does_not_require_api_client_id_anymore(self): + """API client ID is no longer required; audience is client_id.""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="test-tenant", + required_scopes=["User.Read"], + ) + assert provider is not None + + def test_init_with_custom_audience_uses_jwt_verifier(self): + """When audience is provided, JWTVerifier is configured with JWKS and issuer.""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="my-tenant", + identifier_uri="api://my-api", + required_scopes=[".default"], + ) + + assert provider._token_validator is not None + assert isinstance(provider._token_validator, JWTVerifier) + verifier = provider._token_validator + assert verifier.jwks_uri is not None + assert verifier.jwks_uri.startswith( + "https://login.microsoftonline.com/my-tenant/discovery/v2.0/keys" + ) + assert verifier.issuer == "https://login.microsoftonline.com/my-tenant/v2.0" + assert verifier.audience == "test_client" + + @pytest.mark.asyncio + async def test_authorize_filters_resource_and_prefixes_scopes_with_audience(self): + """authorize() should drop resource and prefix non-openid scopes with audience.""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="common", + identifier_uri="api://my-api", + required_scopes=["read", "write"], + base_url="https://srv.example", + ) + + client = OAuthClientInformationFull( + client_id="dummy", + client_secret="secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + scopes=["read", "profile"], + state="abc", + code_challenge="xyz", + resource="https://should.be.ignored", + ) + + url = await provider.authorize(client, params) + + parsed = urlparse(url) + qs = parse_qs(parsed.query) + assert "resource" not in qs + scope_value = qs.get("scope", [""])[0] + scope_parts = scope_value.split(" ") if scope_value else [] + assert "api://my-api/read" in scope_parts + assert "api://my-api/profile" in scope_parts + + @pytest.mark.asyncio + async def test_authorize_appends_unprefixed_additional_scopes(self): + """authorize() should append additional_authorize_scopes without prefixing them.""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="common", + identifier_uri="api://my-api", + required_scopes=["read"], + base_url="https://srv.example", + additional_authorize_scopes=["Mail.Read", "User.Read"], + ) + + client = OAuthClientInformationFull( + client_id="dummy", + client_secret="secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + scopes=["read"], + state="abc", + code_challenge="xyz", + ) + + url = await provider.authorize(client, params) + + parsed = urlparse(url) + qs = parse_qs(parsed.query) + scope_value = qs.get("scope", [""])[0] + scope_parts = scope_value.split(" ") if scope_value else [] + assert "api://my-api/read" in scope_parts + assert "Mail.Read" in scope_parts + assert "User.Read" in scope_parts diff --git a/tests/server/auth/providers/test_descope.py b/tests/server/auth/providers/test_descope.py new file mode 100644 index 000000000..3df78e052 --- /dev/null +++ b/tests/server/auth/providers/test_descope.py @@ -0,0 +1,170 @@ +"""Tests for Descope OAuth provider.""" + +import os +from collections.abc import Generator +from unittest.mock import patch + +import httpx +import pytest + +from fastmcp import Client, FastMCP +from fastmcp.client.transports import StreamableHttpTransport +from fastmcp.server.auth.providers.descope import DescopeProvider +from fastmcp.utilities.tests import HeadlessOAuth, run_server_in_process + + +class TestDescopeProvider: + """Test Descope OAuth provider functionality.""" + + def test_init_with_explicit_params(self): + """Test DescopeProvider initialization with explicit parameters.""" + provider = DescopeProvider( + project_id="P2abc123", + base_url="https://myserver.com", + descope_base_url="https://api.descope.com", + ) + + assert provider.project_id == "P2abc123" + assert str(provider.base_url) == "https://myserver.com/" + assert str(provider.descope_base_url) == "https://api.descope.com" + + @pytest.mark.parametrize( + "scopes_env", + [ + "openid,email", + '["openid", "email"]', + ], + ) + def test_init_with_env_vars(self, scopes_env): + """Test DescopeProvider initialization from environment variables.""" + with patch.dict( + os.environ, + { + "FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_PROJECT_ID": "P2env123", + "FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_BASE_URL": "https://envserver.com", + "FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_DESCOPE_BASE_URL": "https://api.descope.com", + }, + ): + provider = DescopeProvider() + + assert provider.project_id == "P2env123" + assert str(provider.base_url) == "https://envserver.com/" + assert str(provider.descope_base_url) == "https://api.descope.com" + + def test_environment_variable_loading(self): + """Test that environment variables are loaded correctly.""" + # This test verifies that the provider can be created with environment variables + provider = DescopeProvider( + project_id="P2env123", base_url="http://env-server.com" + ) + + # Should have loaded from environment + assert provider.project_id == "P2env123" + assert str(provider.base_url) == "http://env-server.com/" + assert str(provider.descope_base_url) == "https://api.descope.com" + + def test_descope_base_url_https_prefix_handling(self): + """Test that descope_base_url handles missing https:// prefix.""" + # Without https:// - should add it + provider1 = DescopeProvider( + project_id="P2abc123", + base_url="https://myserver.com", + descope_base_url="https://api.descope.com", + ) + assert str(provider1.descope_base_url) == "https://api.descope.com" + + # With https:// - should keep it + provider2 = DescopeProvider( + project_id="P2abc123", + base_url="https://myserver.com", + descope_base_url="https://api.descope.com", + ) + assert str(provider2.descope_base_url) == "https://api.descope.com" + + # With http:// - should be preserved + provider3 = DescopeProvider( + project_id="P2abc123", + base_url="https://myserver.com", + descope_base_url="http://localhost:8080", + ) + assert str(provider3.descope_base_url) == "http://localhost:8080" + + def test_init_defaults(self): + """Test that default values are applied correctly.""" + provider = DescopeProvider( + project_id="P2abc123", + base_url="https://myserver.com", + ) + + # Check defaults + assert str(provider.descope_base_url) == "https://api.descope.com" + + def test_jwt_verifier_configured_correctly(self): + """Test that JWT verifier is configured correctly.""" + provider = DescopeProvider( + project_id="P2abc123", + base_url="https://myserver.com", + descope_base_url="https://api.descope.com", + ) + + # Check that JWT verifier uses the correct endpoints + assert ( + provider.token_verifier.jwks_uri # type: ignore[attr-defined] + == "https://api.descope.com/P2abc123/.well-known/jwks.json" + ) + assert ( + provider.token_verifier.issuer == "https://api.descope.com/v1/apps/P2abc123" # type: ignore[attr-defined] + ) + assert provider.token_verifier.audience == "P2abc123" # type: ignore[attr-defined] + + +def run_mcp_server(host: str, port: int) -> None: + mcp = FastMCP( + auth=DescopeProvider( + project_id="P2test123", + base_url="http://localhost:4321", + descope_base_url="https://api.descope.com", + ) + ) + + @mcp.tool + def add(a: int, b: int) -> int: + return a + b + + mcp.run(host=host, port=port, transport="http") + + +@pytest.fixture +def mcp_server_url() -> Generator[str]: + with run_server_in_process(run_mcp_server) as url: + yield f"{url}/mcp" + + +@pytest.fixture() +def client_with_headless_oauth( + mcp_server_url: str, +) -> Generator[Client, None, None]: + """Client with headless OAuth that bypasses browser interaction.""" + client = Client( + transport=StreamableHttpTransport(mcp_server_url), + auth=HeadlessOAuth(mcp_url=mcp_server_url), + ) + yield client + + +class TestDescopeProviderIntegration: + async def test_unauthorized_access(self, mcp_server_url: str): + with pytest.raises(httpx.HTTPStatusError) as exc_info: + async with Client(mcp_server_url) as client: + tools = await client.list_tools() # noqa: F841 + + assert isinstance(exc_info.value, httpx.HTTPStatusError) + assert exc_info.value.response.status_code == 401 + assert "tools" not in locals() + + # async def test_authorized_access(self, client_with_headless_oauth: Client): + # async with client_with_headless_oauth: + # tools = await client_with_headless_oauth.list_tools() + # assert tools is not None + # assert len(tools) > 0 + # assert "add" in tools diff --git a/tests/server/auth/providers/test_scalekit.py b/tests/server/auth/providers/test_scalekit.py new file mode 100644 index 000000000..d7f7a6546 --- /dev/null +++ b/tests/server/auth/providers/test_scalekit.py @@ -0,0 +1,162 @@ +"""Tests for Scalekit OAuth provider.""" + +import os +from collections.abc import Generator +from unittest.mock import patch + +import httpx +import pytest + +from fastmcp import Client, FastMCP +from fastmcp.client.transports import StreamableHttpTransport +from fastmcp.server.auth.providers.scalekit import ScalekitProvider +from fastmcp.utilities.tests import HeadlessOAuth, run_server_in_process + + +class TestScalekitProvider: + """Test Scalekit OAuth provider functionality.""" + + def test_init_with_explicit_params(self): + """Test ScalekitProvider initialization with explicit parameters.""" + provider = ScalekitProvider( + environment_url="https://my-env.scalekit.com", + client_id="sk_client_123", + resource_id="sk_resource_456", + mcp_url="https://myserver.com/", + ) + + assert provider.environment_url == "https://my-env.scalekit.com" + assert provider.client_id == "sk_client_123" + assert provider.resource_id == "sk_resource_456" + assert str(provider.mcp_url) == "https://myserver.com/" + + def test_init_with_env_vars(self): + """Test ScalekitProvider initialization from environment variables.""" + with patch.dict( + os.environ, + { + "FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_ENVIRONMENT_URL": "https://env-scalekit.com", + "FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_CLIENT_ID": "skc_123", + "FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_RESOURCE_ID": "res_456", + "FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_MCP_URL": "https://envserver.com/mcp", + }, + ): + provider = ScalekitProvider() + + assert provider.environment_url == "https://env-scalekit.com" + assert provider.client_id == "skc_123" + assert provider.resource_id == "res_456" + assert str(provider.mcp_url) == "https://envserver.com/mcp" + + def test_environment_variable_loading(self): + """Test that environment variables are loaded correctly.""" + provider = ScalekitProvider( + environment_url="https://test-env.scalekit.com", + client_id="sk_client_test_123", + resource_id="sk_resource_test_456", + mcp_url="http://test-server.com", + ) + + assert provider.environment_url == "https://test-env.scalekit.com" + assert provider.client_id == "sk_client_test_123" + assert provider.resource_id == "sk_resource_test_456" + assert str(provider.mcp_url) == "http://test-server.com/" + + def test_url_trailing_slash_handling(self): + """Test that URLs handle trailing slashes correctly.""" + provider = ScalekitProvider( + environment_url="https://my-env.scalekit.com/", + client_id="sk_client_123", + resource_id="sk_resource_456", + mcp_url="https://myserver.com/", + ) + + assert provider.environment_url == "https://my-env.scalekit.com" + assert str(provider.mcp_url) == "https://myserver.com/" + + def test_jwt_verifier_configured_correctly(self): + """Test that JWT verifier is configured correctly.""" + provider = ScalekitProvider( + environment_url="https://my-env.scalekit.com", + client_id="sk_client_123", + resource_id="sk_resource_456", + mcp_url="https://myserver.com/", + ) + + # Check that JWT verifier uses the correct endpoints + assert ( + provider.token_verifier.jwks_uri # type: ignore[attr-defined] + == "https://my-env.scalekit.com/keys" + ) + assert ( + provider.token_verifier.issuer == "https://my-env.scalekit.com" # type: ignore[attr-defined] + ) + assert provider.token_verifier.audience == "https://myserver.com/" # type: ignore[attr-defined] + + def test_authorization_servers_configuration(self): + """Test that authorization servers are configured correctly.""" + provider = ScalekitProvider( + environment_url="https://my-env.scalekit.com", + client_id="sk_client_123", + resource_id="sk_resource_456", + mcp_url="https://myserver.com/", + ) + + assert len(provider.authorization_servers) == 1 + assert ( + str(provider.authorization_servers[0]) + == "https://my-env.scalekit.com/resources/sk_resource_456" + ) + + +def run_mcp_server(host: str, port: int) -> None: + mcp = FastMCP( + auth=ScalekitProvider( + environment_url="https://test-env.scalekit.com", + client_id="sk_client_test_123", + resource_id="sk_resource_test_456", + mcp_url="http://localhost:4321", + ) + ) + + @mcp.tool + def add(a: int, b: int) -> int: + return a + b + + mcp.run(host=host, port=port, transport="http") + + +@pytest.fixture +def mcp_server_url() -> Generator[str]: + with run_server_in_process(run_mcp_server) as url: + yield f"{url}/mcp" + + +@pytest.fixture() +def client_with_headless_oauth( + mcp_server_url: str, +) -> Generator[Client, None, None]: + """Client with headless OAuth that bypasses browser interaction.""" + client = Client( + transport=StreamableHttpTransport(mcp_server_url), + auth=HeadlessOAuth(mcp_url=mcp_server_url), + ) + yield client + + +class TestScalekitProviderIntegration: + async def test_unauthorized_access(self, mcp_server_url: str): + with pytest.raises(httpx.HTTPStatusError) as exc_info: + async with Client(mcp_server_url) as client: + tools = await client.list_tools() # noqa: F841 + + assert isinstance(exc_info.value, httpx.HTTPStatusError) + assert exc_info.value.response.status_code == 401 + assert "tools" not in locals() + + # async def test_authorized_access(self, client_with_headless_oauth: Client): + # async with client_with_headless_oauth: + # tools = await client_with_headless_oauth.list_tools() + # assert tools is not None + # assert len(tools) > 0 + # assert "add" in tools diff --git a/tests/server/auth/providers/test_supabase.py b/tests/server/auth/providers/test_supabase.py new file mode 100644 index 000000000..973f29679 --- /dev/null +++ b/tests/server/auth/providers/test_supabase.py @@ -0,0 +1,165 @@ +"""Tests for Supabase Auth provider.""" + +import os +from collections.abc import Generator +from unittest.mock import patch + +import httpx +import pytest + +from fastmcp import Client, FastMCP +from fastmcp.client.transports import StreamableHttpTransport +from fastmcp.server.auth.providers.supabase import SupabaseProvider +from fastmcp.utilities.tests import HeadlessOAuth, run_server_in_process + + +class TestSupabaseProvider: + """Test Supabase Auth provider functionality.""" + + def test_init_with_explicit_params(self): + """Test SupabaseProvider initialization with explicit parameters.""" + provider = SupabaseProvider( + project_url="https://abc123.supabase.co", + base_url="https://myserver.com", + ) + + assert provider.project_url == "https://abc123.supabase.co" + assert str(provider.base_url) == "https://myserver.com/" + + @pytest.mark.parametrize( + "scopes_env", + [ + "openid,email", + '["openid", "email"]', + ], + ) + def test_init_with_env_vars(self, scopes_env): + """Test SupabaseProvider initialization from environment variables.""" + with patch.dict( + os.environ, + { + "FASTMCP_SERVER_AUTH_SUPABASE_PROJECT_URL": "https://env123.supabase.co", + "FASTMCP_SERVER_AUTH_SUPABASE_BASE_URL": "https://envserver.com", + }, + ): + provider = SupabaseProvider() + + assert provider.project_url == "https://env123.supabase.co" + assert str(provider.base_url) == "https://envserver.com/" + + def test_environment_variable_loading(self): + """Test that environment variables are loaded correctly.""" + provider = SupabaseProvider( + project_url="https://env123.supabase.co", + base_url="http://env-server.com", + ) + + assert provider.project_url == "https://env123.supabase.co" + assert str(provider.base_url) == "http://env-server.com/" + + def test_project_url_normalization(self): + """Test that project_url handles trailing slashes correctly.""" + # Without trailing slash + provider1 = SupabaseProvider( + project_url="https://abc123.supabase.co", + base_url="https://myserver.com", + ) + assert provider1.project_url == "https://abc123.supabase.co" + + # With trailing slash - should be stripped + provider2 = SupabaseProvider( + project_url="https://abc123.supabase.co/", + base_url="https://myserver.com", + ) + assert provider2.project_url == "https://abc123.supabase.co" + + def test_jwt_verifier_configured_correctly(self): + """Test that JWT verifier is configured correctly.""" + provider = SupabaseProvider( + project_url="https://abc123.supabase.co", + base_url="https://myserver.com", + ) + + # Check that JWT verifier uses the correct endpoints + assert ( + provider.token_verifier.jwks_uri # type: ignore[attr-defined] + == "https://abc123.supabase.co/auth/v1/.well-known/jwks.json" + ) + assert ( + provider.token_verifier.issuer == "https://abc123.supabase.co/auth/v1" # type: ignore[attr-defined] + ) + assert provider.token_verifier.algorithm == "ES256" # type: ignore[attr-defined] + + def test_jwt_verifier_with_required_scopes(self): + """Test that JWT verifier respects required_scopes.""" + provider = SupabaseProvider( + project_url="https://abc123.supabase.co", + base_url="https://myserver.com", + required_scopes=["openid", "email"], + ) + + assert provider.token_verifier.required_scopes == ["openid", "email"] # type: ignore[attr-defined] + + def test_authorization_servers_configured(self): + """Test that authorization servers list is configured correctly.""" + provider = SupabaseProvider( + project_url="https://abc123.supabase.co", + base_url="https://myserver.com", + ) + + assert len(provider.authorization_servers) == 1 + assert ( + str(provider.authorization_servers[0]) + == "https://abc123.supabase.co/auth/v1" + ) + + +def run_mcp_server(host: str, port: int) -> None: + mcp = FastMCP( + auth=SupabaseProvider( + project_url="https://test123.supabase.co", + base_url="http://localhost:4321", + ) + ) + + @mcp.tool + def add(a: int, b: int) -> int: + return a + b + + mcp.run(host=host, port=port, transport="http") + + +@pytest.fixture +def mcp_server_url() -> Generator[str]: + with run_server_in_process(run_mcp_server) as url: + yield f"{url}/mcp" + + +@pytest.fixture() +def client_with_headless_oauth( + mcp_server_url: str, +) -> Generator[Client, None, None]: + """Client with headless OAuth that bypasses browser interaction.""" + client = Client( + transport=StreamableHttpTransport(mcp_server_url), + auth=HeadlessOAuth(mcp_url=mcp_server_url), + ) + yield client + + +class TestSupabaseProviderIntegration: + async def test_unauthorized_access(self, mcp_server_url: str): + with pytest.raises(httpx.HTTPStatusError) as exc_info: + async with Client(mcp_server_url) as client: + tools = await client.list_tools() # noqa: F841 + + assert isinstance(exc_info.value, httpx.HTTPStatusError) + assert exc_info.value.response.status_code == 401 + assert "tools" not in locals() + + # async def test_authorized_access(self, client_with_headless_oauth: Client): + # async with client_with_headless_oauth: + # tools = await client_with_headless_oauth.list_tools() + # assert tools is not None + # assert len(tools) > 0 + # assert "add" in tools diff --git a/tests/server/auth/providers/test_workos.py b/tests/server/auth/providers/test_workos.py index e32187009..b4d7f3c6f 100644 --- a/tests/server/auth/providers/test_workos.py +++ b/tests/server/auth/providers/test_workos.py @@ -172,7 +172,7 @@ def run_mcp_server(host: str, port: int) -> None: mcp.run(host=host, port=port, transport="http") -@pytest.fixture(scope="module") +@pytest.fixture def mcp_server_url() -> Generator[str]: with run_server_in_process(run_mcp_server) as url: yield f"{url}/mcp" diff --git a/tests/server/auth/test_jwt_provider.py b/tests/server/auth/test_jwt_provider.py index d20159460..7d9d9837b 100644 --- a/tests/server/auth/test_jwt_provider.py +++ b/tests/server/auth/test_jwt_provider.py @@ -132,7 +132,7 @@ def run_mcp_server( mcp.run(host=host, port=port, **run_kwargs or {}) -@pytest.fixture(scope="module") +@pytest.fixture def mcp_server_url(rsa_key_pair: RSAKeyPair) -> Generator[str]: with run_server_in_process( run_mcp_server, diff --git a/tests/server/auth/test_oauth_proxy.py b/tests/server/auth/test_oauth_proxy.py index 4fffb1cf8..c171547eb 100644 --- a/tests/server/auth/test_oauth_proxy.py +++ b/tests/server/auth/test_oauth_proxy.py @@ -44,7 +44,7 @@ class MockOAuthProvider: - Network calls to external services """ - def __init__(self, port: int = 9999): + def __init__(self, port: int = 0): self.port = port self.base_url = f"http://localhost:{port}" self.app = None @@ -229,23 +229,40 @@ class MockOAuthProvider: async def start(self): """Start the mock OAuth server.""" + import socket + from uvicorn import Config, Server self.app = self.create_app() - config = Config(self.app, host="localhost", port=self.port, log_level="error") + + # If port is 0, find an available port + if self.port == 0: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + s.listen(1) + self.port = s.getsockname()[1] + + self.base_url = f"http://localhost:{self.port}" + config = Config( + self.app, + host="localhost", + port=self.port, + log_level="error", + ws="websockets-sansio", + ) self.server = Server(config) # Start server in background asyncio.create_task(self.server.serve()) # Wait for server to be ready - await asyncio.sleep(0.5) + await asyncio.sleep(0.05) async def stop(self): """Stop the mock OAuth server.""" if self.server: self.server.should_exit = True - await asyncio.sleep(0.1) + await asyncio.sleep(0.01) def reset(self): """Reset all state for next test.""" @@ -308,7 +325,7 @@ def oauth_proxy(jwt_verifier): @pytest.fixture async def mock_oauth_provider(): """Create and start a mock OAuth provider.""" - provider = MockOAuthProvider(port=9999) + provider = MockOAuthProvider() await provider.start() yield provider await provider.stop() @@ -395,8 +412,8 @@ class TestOAuthProxyClientRegistration: await oauth_proxy.register_client(client_info) - # Client should be stored with original credentials - stored = oauth_proxy._clients.get("original-client") + # Client should be retrievable with original credentials + stored = await oauth_proxy.get_client("original-client") assert stored is not None assert stored.client_id == "original-client" assert stored.client_secret == "original-secret" @@ -962,3 +979,126 @@ class TestParameterForwarding: assert query_params["audience"][0] == "https://api.example.com" assert query_params["prompt"][0] == "consent" assert query_params["max_age"][0] == "3600" + + @pytest.mark.asyncio + async def test_token_endpoint_invalid_client_error(self, jwt_verifier): + """Test that invalid client_id returns OAuth 2.1 compliant error response. + + When a client ID is not found during token exchange, the proxy should: + 1. Return HTTP 401 status code + 2. Use 'invalid_client' error code instead of 'unauthorized_client' + + This aligns with OAuth 2.1 spec and enables Claude's automatic client re-registration. + """ + from starlette.applications import Starlette + from starlette.testclient import TestClient + + proxy = OAuthProxy( + upstream_authorization_endpoint="https://oauth.example.com/authorize", + upstream_token_endpoint="https://oauth.example.com/token", + upstream_client_id="upstream-client", + upstream_client_secret="upstream-secret", + token_verifier=jwt_verifier, + base_url="https://proxy.example.com", + ) + + # Create a test app with OAuth routes + app = Starlette(routes=proxy.get_routes()) + + # Test the token endpoint with an invalid (non-existent) client_id + with TestClient(app) as client: + response = client.post( + "/token", + data={ + "grant_type": "authorization_code", + "code": "test-auth-code", + "client_id": "non-existent-client-id", + "code_verifier": "test-code-verifier", + "redirect_uri": "http://localhost:12345/callback", + }, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + + # Verify OAuth 2.1 compliant error response + assert response.status_code == 401, ( + f"Expected 401 but got {response.status_code}" + ) + + error_data = response.json() + assert error_data["error"] == "invalid_client", ( + f"Expected 'invalid_client' but got '{error_data.get('error')}'" + ) + assert "Invalid client_id" in error_data["error_description"] + + # Verify proper cache headers are set + assert response.headers.get("Cache-Control") == "no-store" + assert response.headers.get("Pragma") == "no-cache" + + +class TestTokenHandlerErrorTransformation: + """Tests for TokenHandler's OAuth 2.1 compliant error transformation.""" + + def test_transforms_client_auth_failure_to_invalid_client_401(self): + """Test that client authentication failures return invalid_client with 401.""" + from mcp.server.auth.handlers.token import TokenErrorResponse + + from fastmcp.server.auth.oauth_proxy import TokenHandler + + handler = TokenHandler(provider=Mock(), client_authenticator=Mock()) + + # Simulate error from ClientAuthenticator.authenticate() failure + error_response = TokenErrorResponse( + error="unauthorized_client", + error_description="Invalid client_id 'test-client-id'", + ) + + response = handler.response(error_response) + + # Should transform to OAuth 2.1 compliant response + assert response.status_code == 401 + assert b'"error":"invalid_client"' in response.body + assert ( + b'"error_description":"Invalid client_id \'test-client-id\'"' + in response.body + ) + + def test_does_not_transform_grant_type_unauthorized_to_invalid_client(self): + """Test that grant type authorization errors stay as unauthorized_client with 400.""" + from mcp.server.auth.handlers.token import TokenErrorResponse + + from fastmcp.server.auth.oauth_proxy import TokenHandler + + handler = TokenHandler(provider=Mock(), client_authenticator=Mock()) + + # Simulate error from grant_type not in client_info.grant_types + error_response = TokenErrorResponse( + error="unauthorized_client", + error_description="Client not authorized for this grant type", + ) + + response = handler.response(error_response) + + # Should NOT transform - keep as 400 unauthorized_client + assert response.status_code == 400 + assert b'"error":"unauthorized_client"' in response.body + + def test_does_not_transform_other_errors(self): + """Test that other error types pass through unchanged.""" + from mcp.server.auth.handlers.token import TokenErrorResponse + + from fastmcp.server.auth.oauth_proxy import TokenHandler + + handler = TokenHandler(provider=Mock(), client_authenticator=Mock()) + + error_response = TokenErrorResponse( + error="invalid_grant", + error_description="Authorization code has expired", + ) + + response = handler.response(error_response) + + # Should pass through unchanged + assert response.status_code == 400 + assert b'"error":"invalid_grant"' in response.body diff --git a/tests/server/auth/test_oauth_proxy_redirect_validation.py b/tests/server/auth/test_oauth_proxy_redirect_validation.py index 8a2ab8564..a4185538f 100644 --- a/tests/server/auth/test_oauth_proxy_redirect_validation.py +++ b/tests/server/auth/test_oauth_proxy_redirect_validation.py @@ -176,7 +176,7 @@ class TestOAuthProxyRedirectValidation: "new-client" ) # Use the client ID we registered assert isinstance(registered, ProxyDCRClient) - assert registered._allowed_redirect_uri_patterns == custom_patterns + assert registered.allowed_redirect_uri_patterns == custom_patterns @pytest.mark.asyncio async def test_proxy_unregistered_client_returns_none(self): diff --git a/tests/server/auth/test_oauth_proxy_storage.py b/tests/server/auth/test_oauth_proxy_storage.py new file mode 100644 index 000000000..629708427 --- /dev/null +++ b/tests/server/auth/test_oauth_proxy_storage.py @@ -0,0 +1,204 @@ +"""Tests for OAuth proxy with persistent storage.""" + +from collections.abc import AsyncGenerator +from pathlib import Path +from unittest.mock import AsyncMock, Mock + +import pytest +from diskcache.core import tempfile +from inline_snapshot import snapshot +from key_value.aio.stores.disk import MultiDiskStore +from key_value.aio.stores.memory import MemoryStore +from mcp.shared.auth import OAuthClientInformationFull +from pydantic import AnyUrl + +from fastmcp.server.auth.oauth_proxy import OAuthProxy + + +class TestOAuthProxyStorage: + """Tests for OAuth proxy client storage functionality.""" + + @pytest.fixture + def jwt_verifier(self): + """Create a mock JWT verifier.""" + verifier = Mock() + verifier.required_scopes = ["read", "write"] + verifier.verify_token = AsyncMock(return_value=None) + return verifier + + @pytest.fixture + async def temp_storage(self) -> AsyncGenerator[MultiDiskStore, None]: + """Create file-based storage for testing.""" + with tempfile.TemporaryDirectory() as temp_dir: + disk_store = MultiDiskStore(base_directory=Path(temp_dir)) + yield disk_store + await disk_store.close() + + @pytest.fixture + def memory_storage(self) -> MemoryStore: + """Create in-memory storage for testing.""" + return MemoryStore() + + def create_proxy(self, jwt_verifier, storage=None) -> OAuthProxy: + """Create an OAuth proxy with specified storage.""" + return OAuthProxy( + upstream_authorization_endpoint="https://github.com/login/oauth/authorize", + upstream_token_endpoint="https://github.com/login/oauth/access_token", + upstream_client_id="test-client-id", + upstream_client_secret="test-client-secret", + token_verifier=jwt_verifier, + base_url="https://myserver.com", + redirect_path="/auth/callback", + client_storage=storage, + ) + + async def test_default_storage_is_file_based(self, jwt_verifier): + """Test that proxy defaults to file-based storage.""" + proxy = self.create_proxy(jwt_verifier, storage=None) + assert isinstance(proxy._client_storage, MemoryStore) + + async def test_register_and_get_client(self, jwt_verifier, temp_storage): + """Test registering and retrieving a client.""" + proxy = self.create_proxy(jwt_verifier, storage=temp_storage) + + # Register client + client_info = OAuthClientInformationFull( + client_id="test-client-123", + client_secret="secret-456", + redirect_uris=[AnyUrl("http://localhost:8080/callback")], + grant_types=["authorization_code", "refresh_token"], + scope="read write", + ) + await proxy.register_client(client_info) + + # Get client back + client = await proxy.get_client("test-client-123") + assert client is not None + assert client.client_id == "test-client-123" + assert client.client_secret == "secret-456" + assert client.scope == "read write" + + async def test_client_persists_across_proxy_instances( + self, jwt_verifier, temp_storage + ): + """Test that clients persist when proxy is recreated.""" + # First proxy registers client + proxy1 = self.create_proxy(jwt_verifier, storage=temp_storage) + client_info = OAuthClientInformationFull( + client_id="persistent-client", + client_secret="persistent-secret", + redirect_uris=[AnyUrl("http://localhost:9999/callback")], + scope="openid profile", + ) + await proxy1.register_client(client_info) + + # Second proxy can retrieve it + proxy2 = self.create_proxy(jwt_verifier, storage=temp_storage) + client = await proxy2.get_client("persistent-client") + assert client is not None + assert client.client_secret == "persistent-secret" + assert client.scope == "openid profile" + + async def test_nonexistent_client_returns_none(self, jwt_verifier, temp_storage): + """Test that requesting non-existent client returns None.""" + proxy = self.create_proxy(jwt_verifier, storage=temp_storage) + client = await proxy.get_client("does-not-exist") + assert client is None + + async def test_proxy_dcr_client_redirect_validation( + self, jwt_verifier, temp_storage + ): + """Test that ProxyDCRClient is created with redirect URI patterns.""" + proxy = OAuthProxy( + upstream_authorization_endpoint="https://github.com/login/oauth/authorize", + upstream_token_endpoint="https://github.com/login/oauth/access_token", + upstream_client_id="test-client-id", + upstream_client_secret="test-client-secret", + token_verifier=jwt_verifier, + base_url="https://myserver.com", + allowed_client_redirect_uris=["http://localhost:*"], + client_storage=temp_storage, + ) + + client_info = OAuthClientInformationFull( + client_id="test-proxy-client", + client_secret="secret", + redirect_uris=[AnyUrl("http://localhost:8080/callback")], + ) + await proxy.register_client(client_info) + + # Get client back - should be ProxyDCRClient + client = await proxy.get_client("test-proxy-client") + assert client is not None + + # ProxyDCRClient should validate dynamic localhost ports + validated = client.validate_redirect_uri( + AnyUrl("http://localhost:12345/callback") + ) + assert validated is not None + + async def test_in_memory_storage_option(self, jwt_verifier): + """Test using in-memory storage explicitly.""" + storage = MemoryStore() + proxy = self.create_proxy(jwt_verifier, storage=storage) + + client_info = OAuthClientInformationFull( + client_id="memory-client", + client_secret="memory-secret", + redirect_uris=[AnyUrl("http://localhost:8080/callback")], + ) + await proxy.register_client(client_info) + + client = await proxy.get_client("memory-client") + assert client is not None + + # Create new proxy with same storage instance + proxy2 = self.create_proxy(jwt_verifier, storage=storage) + client2 = await proxy2.get_client("memory-client") + assert client2 is not None + + # But new storage instance won't have it + proxy3 = self.create_proxy(jwt_verifier, storage=MemoryStore()) + client3 = await proxy3.get_client("memory-client") + assert client3 is None + + async def test_storage_data_structure(self, jwt_verifier, temp_storage): + """Test that storage uses proper structured format.""" + proxy = self.create_proxy(jwt_verifier, storage=temp_storage) + + client_info = OAuthClientInformationFull( + client_id="structured-client", + client_secret="secret", + redirect_uris=[AnyUrl("http://localhost:8080/callback")], + ) + await proxy.register_client(client_info) + + # Check raw storage data + raw_data = await temp_storage.get( + collection="mcp-oauth-proxy-clients", key="structured-client" + ) + assert raw_data is not None + assert raw_data == snapshot( + { + "redirect_uris": ["http://localhost:8080/callback"], + "token_endpoint_auth_method": "none", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "scope": "read write", + "client_name": None, + "client_uri": None, + "logo_uri": None, + "contacts": None, + "tos_uri": None, + "policy_uri": None, + "jwks_uri": None, + "jwks": None, + "software_id": None, + "software_version": None, + "client_id": "structured-client", + "client_secret": "secret", + "client_id_issued_at": None, + "client_secret_expires_at": None, + "allowed_redirect_uri_patterns": None, + } + ) diff --git a/tests/server/auth/test_oidc_proxy.py b/tests/server/auth/test_oidc_proxy.py index 5652adbdd..f45f835be 100644 --- a/tests/server/auth/test_oidc_proxy.py +++ b/tests/server/auth/test_oidc_proxy.py @@ -1,5 +1,6 @@ """Comprehensive tests for OIDC Proxy Provider functionality.""" +import json from unittest.mock import MagicMock, patch import pytest @@ -9,6 +10,7 @@ from pydantic import AnyHttpUrl from fastmcp.server.auth.oidc_proxy import OIDCConfiguration, OIDCProxy from fastmcp.server.auth.providers.jwt import JWTVerifier +TEST_ISSUER = "https://example.com" TEST_AUTHORIZATION_ENDPOINT = "https://example.com/authorize" TEST_TOKEN_ENDPOINT = "https://example.com/oauth/token" @@ -27,7 +29,7 @@ TEST_BASE_URL = "https://example.com:8000/" def valid_oidc_configuration_dict(): """Create a valid OIDC configuration dict for testing.""" return { - "issuer": "https://example.com/", + "issuer": TEST_ISSUER, "authorization_endpoint": TEST_AUTHORIZATION_ENDPOINT, "token_endpoint": TEST_TOKEN_ENDPOINT, "jwks_uri": "https://example.com/.well-known/jwks.json", @@ -41,27 +43,218 @@ def valid_oidc_configuration_dict(): def invalid_oidc_configuration_dict(): """Create an invalid OIDC configuration dict for testing.""" return { - "issuer": "https://example.com/", + "issuer": TEST_ISSUER, "authorization_endpoint": TEST_AUTHORIZATION_ENDPOINT, "token_endpoint": TEST_TOKEN_ENDPOINT, "jwks_uri": "https://example.com/.well-known/jwks.json", } +@pytest.fixture +def valid_google_oidc_configuration_dict(): + """Create a valid Google OIDC configuration dict for testing. + + See: https://accounts.google.com/.well-known/openid-configuration + """ + google_config_str = """ + { + "issuer": "https://accounts.google.com", + "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth", + "device_authorization_endpoint": "https://oauth2.googleapis.com/device/code", + "token_endpoint": "https://oauth2.googleapis.com/token", + "userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo", + "revocation_endpoint": "https://oauth2.googleapis.com/revoke", + "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs", + "response_types_supported": [ + "code", + "token", + "id_token", + "code token", + "code id_token", + "token id_token", + "code token id_token", + "none" + ], + "response_modes_supported": [ + "query", + "fragment", + "form_post" + ], + "subject_types_supported": [ + "public" + ], + "id_token_signing_alg_values_supported": [ + "RS256" + ], + "scopes_supported": [ + "openid", + "email", + "profile" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_post", + "client_secret_basic" + ], + "claims_supported": [ + "aud", + "email", + "email_verified", + "exp", + "family_name", + "given_name", + "iat", + "iss", + "name", + "picture", + "sub" + ], + "code_challenge_methods_supported": [ + "plain", + "S256" + ], + "grant_types_supported": [ + "authorization_code", + "refresh_token", + "urn:ietf:params:oauth:grant-type:device_code", + "urn:ietf:params:oauth:grant-type:jwt-bearer" + ] + } + """ + + return json.loads(google_config_str) + + +@pytest.fixture +def valid_auth0_oidc_configuration_dict(): + """Create a valid Auth0 OIDC configuration dict for testing. + + See: https://.us.auth0.com/.well-known/openid-configuration + """ + auth0_config_str = """ + { + "issuer": "https://example.us.auth0.com/", + "authorization_endpoint": "https://example.us.auth0.com/authorize", + "token_endpoint": "https://example.us.auth0.com/oauth/token", + "device_authorization_endpoint": "https://example.us.auth0.com/oauth/device/code", + "userinfo_endpoint": "https://example.us.auth0.com/userinfo", + "mfa_challenge_endpoint": "https://example.us.auth0.com/mfa/challenge", + "jwks_uri": "https://example.us.auth0.com/.well-known/jwks.json", + "registration_endpoint": "https://example.us.auth0.com/oidc/register", + "revocation_endpoint": "https://example.us.auth0.com/oauth/revoke", + "scopes_supported": [ + "openid", + "profile", + "offline_access", + "name", + "given_name", + "family_name", + "nickname", + "email", + "email_verified", + "picture", + "created_at", + "identities", + "phone", + "address" + ], + "response_types_supported": [ + "code", + "token", + "id_token", + "code token", + "code id_token", + "token id_token", + "code token id_token" + ], + "code_challenge_methods_supported": [ + "S256", + "plain" + ], + "response_modes_supported": [ + "query", + "fragment", + "form_post" + ], + "subject_types_supported": [ + "public" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post", + "private_key_jwt", + "tls_client_auth", + "self_signed_tls_client_auth" + ], + "token_endpoint_auth_signing_alg_values_supported": [ + "RS256", + "RS384", + "PS256" + ], + "claims_supported": [ + "aud", + "auth_time", + "created_at", + "email", + "email_verified", + "exp", + "family_name", + "given_name", + "iat", + "identities", + "iss", + "name", + "nickname", + "phone_number", + "picture", + "sub" + ], + "request_uri_parameter_supported": false, + "request_parameter_supported": true, + "id_token_signing_alg_values_supported": [ + "HS256", + "RS256", + "PS256" + ], + "tls_client_certificate_bound_access_tokens": true, + "request_object_signing_alg_values_supported": [ + "RS256", + "RS384", + "PS256" + ], + "backchannel_logout_supported": true, + "backchannel_logout_session_supported": true, + "end_session_endpoint": "https://example.us.auth0.com/oidc/logout", + "backchannel_authentication_endpoint": "https://example.us.auth0.com/bc-authorize", + "backchannel_token_delivery_modes_supported": [ + "poll" + ], + "global_token_revocation_endpoint": "https://example.us.auth0.com/oauth/global-token-revocation/connection/{connectionName}", + "global_token_revocation_endpoint_auth_methods_supported": [ + "global-token-revocation+jwt" + ] + } + """ + + return json.loads(auth0_config_str) + + # ============================================================================= # Test Classes # ============================================================================= -def validate_config(config): - """Validate an OIDC configuration.""" - assert str(config.issuer) == "https://example.com/" - assert str(config.authorization_endpoint) == TEST_AUTHORIZATION_ENDPOINT - assert str(config.token_endpoint) == TEST_TOKEN_ENDPOINT - assert str(config.jwks_uri) == "https://example.com/.well-known/jwks.json" - assert config.response_types_supported == ["code"] - assert config.subject_types_supported == ["public"] - assert config.id_token_signing_alg_values_supported == ["RS256"] +def validate_config(config, source_dict): + """Validate an OIDC configuration against the source dict.""" + for source_key, source_value in source_dict.items(): + config_value = getattr(config, source_key, None) + if not hasattr(config, source_key): + continue + + config_value = getattr(config, source_key, None) + if isinstance(config_value, AnyHttpUrl): + config_value = str(config_value) + + assert config_value == source_value class TestOIDCConfiguration: @@ -70,13 +263,29 @@ class TestOIDCConfiguration: def test_default_configuration(self, valid_oidc_configuration_dict): """Test default configuration with valid dict.""" config = OIDCConfiguration.model_validate(valid_oidc_configuration_dict) - validate_config(config) + validate_config(config, valid_oidc_configuration_dict) + + def test_default_configuration_with_issuer_trailing_slash( + self, valid_oidc_configuration_dict + ): + """Test default configuration with valid dict and issuer trailing slash.""" + valid_oidc_configuration_dict["issuer"] += "/" + config = OIDCConfiguration.model_validate(valid_oidc_configuration_dict) + validate_config(config, valid_oidc_configuration_dict) def test_explicit_strict_configuration(self, valid_oidc_configuration_dict): """Test default configuration with explicit True strict setting and valid dict.""" valid_oidc_configuration_dict["strict"] = True config = OIDCConfiguration.model_validate(valid_oidc_configuration_dict) - validate_config(config) + validate_config(config, valid_oidc_configuration_dict) + + def test_explicit_strict_configuration_with_issuer_trailing_slash( + self, valid_oidc_configuration_dict + ): + """Test default configuration with explicit True strict setting, valid dict and issuer trailing slash.""" + valid_oidc_configuration_dict["issuer"] += "/" + config = OIDCConfiguration.model_validate(valid_oidc_configuration_dict) + validate_config(config, valid_oidc_configuration_dict) def test_default_configuration_raises_error(self, invalid_oidc_configuration_dict): """Test default configuration with invalid dict.""" @@ -91,6 +300,21 @@ class TestOIDCConfiguration: with pytest.raises(ValueError, match="Missing required configuration metadata"): OIDCConfiguration.model_validate(invalid_oidc_configuration_dict) + def test_bad_url_raises_error(self, valid_oidc_configuration_dict): + """Test default configuration with bad URL setting.""" + valid_oidc_configuration_dict["issuer"] = "not-a-URL" + with pytest.raises(ValueError, match="Invalid URL for configuration metadata"): + OIDCConfiguration.model_validate(valid_oidc_configuration_dict) + + def test_explict_strict_with_bad_url_raises_error( + self, valid_oidc_configuration_dict + ): + """Test default configuration with explicit True strict setting and bad URL setting.""" + valid_oidc_configuration_dict["strict"] = True + valid_oidc_configuration_dict["issuer"] = "not-a-URL" + with pytest.raises(ValueError, match="Invalid URL for configuration metadata"): + OIDCConfiguration.model_validate(valid_oidc_configuration_dict) + def test_not_strict_configuration(self): """Test default configuration with explicit False strict setting.""" config = OIDCConfiguration.model_validate({"strict": False}) @@ -103,6 +327,35 @@ class TestOIDCConfiguration: assert config.subject_types_supported is None assert config.id_token_signing_alg_values_supported is None + def test_not_strict_configuration_with_invalid_config( + self, invalid_oidc_configuration_dict + ): + """Test default configuration with explicit False strict setting.""" + invalid_oidc_configuration_dict["strict"] = False + config = OIDCConfiguration.model_validate(invalid_oidc_configuration_dict) + + validate_config(config, invalid_oidc_configuration_dict) + + def test_not_strict_configuration_with_bad_url(self, valid_oidc_configuration_dict): + """Test default configuration with explicit False strict setting.""" + valid_oidc_configuration_dict["strict"] = False + valid_oidc_configuration_dict["issuer"] = "not-a-url" + config = OIDCConfiguration.model_validate(valid_oidc_configuration_dict) + + validate_config(config, valid_oidc_configuration_dict) + + def test_google_configuration(self, valid_google_oidc_configuration_dict): + """Test Google configuration.""" + config = OIDCConfiguration.model_validate(valid_google_oidc_configuration_dict) + + validate_config(config, valid_google_oidc_configuration_dict) + + def test_auth0_configuration(self, valid_auth0_oidc_configuration_dict): + """Test Auth0 configuration.""" + config = OIDCConfiguration.model_validate(valid_auth0_oidc_configuration_dict) + + validate_config(config, valid_auth0_oidc_configuration_dict) + def validate_get_oidc_configuration(oidc_configuration, strict, timeout_seconds): """Validate get_oidc_configuation call.""" @@ -117,7 +370,7 @@ def validate_get_oidc_configuration(oidc_configuration, strict, timeout_seconds) timeout_seconds=timeout_seconds, ) - validate_config(config) + validate_config(config, oidc_configuration) mock_get.assert_called_once() diff --git a/tests/server/auth/test_remote_auth_provider.py b/tests/server/auth/test_remote_auth_provider.py index ac6471575..eedb871b3 100644 --- a/tests/server/auth/test_remote_auth_provider.py +++ b/tests/server/auth/test_remote_auth_provider.py @@ -105,6 +105,28 @@ class TestRemoteAuthProvider: "https://api.example.com/.well-known/oauth-protected-resource" ) + def test_get_resource_url_with_nested_base_url(self): + """Test _get_resource_url returns correct URL for .well-known path with nested base_url.""" + tokens = { + "test_token": { + "client_id": "test-client", + "scopes": ["read"], + } + } + token_verifier = StaticTokenVerifier(tokens=tokens) + provider = RemoteAuthProvider( + token_verifier=token_verifier, + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + base_url="https://api.example.com/v1/", + ) + + metadata_url = provider._get_resource_url( + "/.well-known/oauth-protected-resource" + ) + assert metadata_url == AnyHttpUrl( + "https://api.example.com/v1/.well-known/oauth-protected-resource" + ) + def test_get_resource_url_handles_trailing_slash(self): """Test _get_resource_url handles trailing slash correctly.""" tokens = { @@ -216,6 +238,7 @@ class TestRemoteAuthProviderIntegration: [ ("https://api.example.com", "https://api.example.com/mcp"), ("https://api.example.com/", "https://api.example.com/mcp"), + ("https://api.example.com/v1/", "https://api.example.com/v1/mcp"), ], ) async def test_base_url_configurations(self, base_url: str, expected_resource: str): diff --git a/tests/server/http/test_custom_routes.py b/tests/server/http/test_custom_routes.py index c43444756..d0a757be4 100644 --- a/tests/server/http/test_custom_routes.py +++ b/tests/server/http/test_custom_routes.py @@ -19,7 +19,7 @@ class TestCustomRoutes: return server - def test_custom_routes_via_server_http_app(self, server_with_custom_route): + def test_custom_routes_apply_filtering_http_app(self, server_with_custom_route): """Test that custom routes are included when using server.http_app().""" # Get the app via server.http_app() app = server_with_custom_route.http_app() diff --git a/tests/server/http/test_http_dependencies.py b/tests/server/http/test_http_dependencies.py index 91101d11a..d0b3afb96 100644 --- a/tests/server/http/test_http_dependencies.py +++ b/tests/server/http/test_http_dependencies.py @@ -42,13 +42,13 @@ def run_server(host: str, port: int, **kwargs) -> None: fastmcp_server().run(host=host, port=port, **kwargs) -@pytest.fixture(autouse=True, scope="module") +@pytest.fixture(autouse=True) def shttp_server() -> Generator[str, None, None]: with run_server_in_process(run_server, transport="http") as url: yield f"{url}/mcp" -@pytest.fixture(autouse=True, scope="module") +@pytest.fixture(autouse=True) def sse_server() -> Generator[str, None, None]: with run_server_in_process(run_server, transport="sse") as url: yield f"{url}/sse" diff --git a/tests/server/middleware/test_error_handling.py b/tests/server/middleware/test_error_handling.py index 0983cfb1c..6c7c3cde3 100644 --- a/tests/server/middleware/test_error_handling.py +++ b/tests/server/middleware/test_error_handling.py @@ -11,6 +11,7 @@ from fastmcp.server.middleware.error_handling import ( RetryMiddleware, ) from fastmcp.server.middleware.middleware import MiddlewareContext +from fastmcp.utilities.tests import caplog_for_fastmcp @pytest.fixture @@ -60,8 +61,9 @@ class TestErrorHandlingMiddleware: middleware = ErrorHandlingMiddleware() error = ValueError("test error") - with caplog.at_level(logging.ERROR): - middleware._log_error(error, mock_context) + with caplog_for_fastmcp(caplog): + with caplog.at_level(logging.ERROR): + middleware._log_error(error, mock_context) assert "Error in test_method: ValueError: test error" in caplog.text assert "ValueError:test_method" in middleware.error_counts @@ -72,8 +74,9 @@ class TestErrorHandlingMiddleware: middleware = ErrorHandlingMiddleware(include_traceback=True) error = ValueError("test error") - with caplog.at_level(logging.ERROR): - middleware._log_error(error, mock_context) + with caplog_for_fastmcp(caplog): + with caplog.at_level(logging.ERROR): + middleware._log_error(error, mock_context) assert "Error in test_method: ValueError: test error" in caplog.text # The traceback is added to the log message @@ -95,8 +98,9 @@ class TestErrorHandlingMiddleware: middleware = ErrorHandlingMiddleware(error_callback=callback) error = ValueError("test error") - with caplog.at_level(logging.ERROR): - middleware._log_error(error, mock_context) + with caplog_for_fastmcp(caplog): + with caplog.at_level(logging.ERROR): + middleware._log_error(error, mock_context) assert "Error in error callback: callback error" in caplog.text @@ -189,9 +193,10 @@ class TestErrorHandlingMiddleware: middleware = ErrorHandlingMiddleware() mock_call_next = AsyncMock(side_effect=ValueError("test error")) - with caplog.at_level(logging.ERROR): - with pytest.raises(McpError) as exc_info: - await middleware.on_message(mock_context, mock_call_next) + with caplog_for_fastmcp(caplog): + with caplog.at_level(logging.ERROR): + with pytest.raises(McpError) as exc_info: + await middleware.on_message(mock_context, mock_call_next) assert isinstance(exc_info.value, McpError) assert exc_info.value.error.code == -32602 @@ -293,8 +298,9 @@ class TestRetryMiddleware: ] ) - with caplog.at_level(logging.WARNING): - result = await middleware.on_request(mock_context, mock_call_next) + with caplog_for_fastmcp(caplog): + with caplog.at_level(logging.WARNING): + result = await middleware.on_request(mock_context, mock_call_next) assert result == "test_result" assert mock_call_next.call_count == 3 @@ -307,9 +313,10 @@ class TestRetryMiddleware: # Fail all attempts mock_call_next = AsyncMock(side_effect=ConnectionError("connection failed")) - with caplog.at_level(logging.WARNING): - with pytest.raises(ConnectionError): - await middleware.on_request(mock_context, mock_call_next) + with caplog_for_fastmcp(caplog): + with caplog.at_level(logging.WARNING): + with pytest.raises(ConnectionError): + await middleware.on_request(mock_context, mock_call_next) assert mock_call_next.call_count == 3 # initial + 2 retries assert "Retrying in" in caplog.text @@ -385,14 +392,19 @@ class TestErrorHandlingMiddlewareIntegration: error_handling_server.add_middleware(ErrorHandlingMiddleware()) - with caplog.at_level(logging.ERROR): - async with Client(error_handling_server) as client: - # Test different types of errors - with pytest.raises(Exception): - await client.call_tool("failing_operation", {"error_type": "value"}) + with caplog_for_fastmcp(caplog): + with caplog.at_level(logging.ERROR): + async with Client(error_handling_server) as client: + # Test different types of errors + with pytest.raises(Exception): + await client.call_tool( + "failing_operation", {"error_type": "value"} + ) - with pytest.raises(Exception): - await client.call_tool("failing_operation", {"error_type": "file"}) + with pytest.raises(Exception): + await client.call_tool( + "failing_operation", {"error_type": "file"} + ) log_text = caplog.text @@ -443,17 +455,20 @@ class TestErrorHandlingMiddlewareIntegration: error_handling_server.add_middleware(ErrorHandlingMiddleware()) - with caplog.at_level(logging.ERROR): - async with Client(error_handling_server) as client: - # Successful operation (should not generate error logs) - await client.call_tool("reliable_operation", {"data": "test"}) + with caplog_for_fastmcp(caplog): + with caplog.at_level(logging.ERROR): + async with Client(error_handling_server) as client: + # Successful operation (should not generate error logs) + await client.call_tool("reliable_operation", {"data": "test"}) - # Failed operation (should generate error log) - with pytest.raises(Exception): - await client.call_tool("failing_operation", {"error_type": "value"}) + # Failed operation (should generate error log) + with pytest.raises(Exception): + await client.call_tool( + "failing_operation", {"error_type": "value"} + ) - # Another successful operation - await client.call_tool("reliable_operation", {"data": "test2"}) + # Another successful operation + await client.call_tool("reliable_operation", {"data": "test2"}) log_text = caplog.text @@ -533,18 +548,19 @@ class TestRetryMiddlewareIntegration: ) ) - with caplog.at_level(logging.WARNING): - async with Client(error_handling_server) as client: - # This operation fails intermittently - try several times - success_count = 0 - for _ in range(5): - try: - await client.call_tool( - "intermittent_operation", {"fail_rate": 0.7} - ) - success_count += 1 - except Exception: - pass # Some failures expected even with retries + with caplog_for_fastmcp(caplog): + with caplog.at_level(logging.WARNING): + async with Client(error_handling_server) as client: + # This operation fails intermittently - try several times + success_count = 0 + for _ in range(5): + try: + await client.call_tool( + "intermittent_operation", {"fail_rate": 0.7} + ) + success_count += 1 + except Exception: + pass # Some failures expected even with retries # Should have some retry log messages # Note: Retry logs might not appear if the underlying errors are wrapped by FastMCP @@ -584,17 +600,22 @@ class TestRetryMiddlewareIntegration: ) ) - with caplog.at_level(logging.ERROR): - async with Client(error_handling_server) as client: - # Try intermittent operation - try: - await client.call_tool("intermittent_operation", {"fail_rate": 0.9}) - except Exception: - pass # May still fail even with retries + with caplog_for_fastmcp(caplog): + with caplog.at_level(logging.ERROR): + async with Client(error_handling_server) as client: + # Try intermittent operation + try: + await client.call_tool( + "intermittent_operation", {"fail_rate": 0.9} + ) + except Exception: + pass # May still fail even with retries - # Try permanent failure - with pytest.raises(Exception): - await client.call_tool("failing_operation", {"error_type": "value"}) + # Try permanent failure + with pytest.raises(Exception): + await client.call_tool( + "failing_operation", {"error_type": "value"} + ) log_text = caplog.text diff --git a/tests/server/middleware/test_initialization_middleware.py b/tests/server/middleware/test_initialization_middleware.py new file mode 100644 index 000000000..06776e16e --- /dev/null +++ b/tests/server/middleware/test_initialization_middleware.py @@ -0,0 +1,251 @@ +"""Tests for middleware support during initialization.""" + +from typing import Any + +import mcp.types as mt + +from fastmcp import Client, FastMCP +from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext + + +class InitializationMiddleware(Middleware): + """Middleware that captures initialization details.""" + + def __init__(self): + super().__init__() + self.initialized = False + self.client_info = None + self.session_data = {} + + async def on_initialize( + self, + context: MiddlewareContext[mt.InitializeRequest], + call_next: CallNext[mt.InitializeRequest, None], + ) -> None: + """Capture initialization details and store session data.""" + self.initialized = True + + # Extract client info from the initialize params + if hasattr(context.message, "params") and hasattr( + context.message.params, "clientInfo" + ): + self.client_info = context.message.params.clientInfo + + # Store data in the context state for cross-request access + if context.fastmcp_context: + context.fastmcp_context.set_state("client_initialized", True) + if self.client_info: + context.fastmcp_context.set_state( + "client_name", getattr(self.client_info, "name", "unknown") + ) + + return await call_next(context) + + +class ClientDetectionMiddleware(Middleware): + """Middleware that detects specific clients and modifies behavior. + + This demonstrates storing data in the middleware instance itself + for cross-request access, since context state is request-scoped. + """ + + def __init__(self): + super().__init__() + self.is_test_client = False + self.tools_modified = False + self.initialization_called = False + + async def on_initialize( + self, + context: MiddlewareContext[mt.InitializeRequest], + call_next: CallNext[mt.InitializeRequest, None], + ) -> None: + """Detect test client during initialization.""" + self.initialization_called = True + + # For testing purposes, always set it to true + # Store in instance variable for cross-request access + self.is_test_client = True + + return await call_next(context) + + async def on_list_tools( + self, + context: MiddlewareContext[mt.ListToolsRequest], + call_next: CallNext[mt.ListToolsRequest, list], + ) -> list: + """Modify tools based on client detection.""" + tools = await call_next(context) + + # Use the instance variable set during initialization + if self.is_test_client: + # Add a special annotation to tools for test clients + for tool in tools: + if not hasattr(tool, "annotations"): + tool.annotations = mt.ToolAnnotations() + if tool.annotations is None: + tool.annotations = mt.ToolAnnotations() + # Mark as read-only for test clients + tool.annotations.readOnlyHint = True + self.tools_modified = True + + return tools + + +async def test_simple_initialization_hook(): + """Test that the on_initialize hook is called.""" + server = FastMCP("TestServer") + + class SimpleInitMiddleware(Middleware): + def __init__(self): + super().__init__() + self.called = False + + async def on_initialize( + self, + context: MiddlewareContext[mt.InitializeRequest], + call_next: CallNext[mt.InitializeRequest, None], + ) -> None: + self.called = True + return await call_next(context) + + middleware = SimpleInitMiddleware() + server.add_middleware(middleware) + + # Connect client + async with Client(server): + # Middleware should have been called + assert middleware.called is True, "on_initialize was not called" + + +async def test_middleware_receives_initialization(): + """Test that middleware can intercept initialization requests.""" + server = FastMCP("TestServer") + middleware = InitializationMiddleware() + server.add_middleware(middleware) + + @server.tool + def test_tool(x: int) -> str: + return f"Result: {x}" + + # Connect client + async with Client(server) as client: + # Middleware should have been called during initialization + assert middleware.initialized is True + + # Test that the tool still works + result = await client.call_tool("test_tool", {"x": 42}) + assert result.content[0].text == "Result: 42" # type: ignore[attr-defined] + + +async def test_client_detection_middleware(): + """Test middleware that detects specific clients and modifies behavior.""" + server = FastMCP("TestServer") + middleware = ClientDetectionMiddleware() + server.add_middleware(middleware) + + @server.tool + def example_tool() -> str: + return "example" + + # Connect with a client + async with Client(server) as client: + # Middleware should have been called during initialization + assert middleware.initialization_called is True + assert middleware.is_test_client is True + + # List tools to trigger modification + tools = await client.list_tools() + assert len(tools) == 1 + assert middleware.tools_modified is True + + # Check that the tool has the modified annotation + tool = tools[0] + assert tool.annotations is not None + assert tool.annotations.readOnlyHint is True + + +async def test_multiple_middleware_initialization(): + """Test that multiple middleware can handle initialization.""" + server = FastMCP("TestServer") + + init_mw = InitializationMiddleware() + detect_mw = ClientDetectionMiddleware() + + server.add_middleware(init_mw) + server.add_middleware(detect_mw) + + @server.tool + def test_tool() -> str: + return "test" + + async with Client(server) as client: + # Both middleware should have processed initialization + assert init_mw.initialized is True + assert detect_mw.initialization_called is True + assert detect_mw.is_test_client is True + + # List tools to check detection worked + await client.list_tools() + assert detect_mw.tools_modified is True + + +async def test_initialization_middleware_with_state_sharing(): + """Test that state set during initialization is available in later requests.""" + server = FastMCP("TestServer") + + class StateTrackingMiddleware(Middleware): + def __init__(self): + super().__init__() + self.init_state = {} + self.tool_state = {} + + async def on_initialize( + self, + context: MiddlewareContext[mt.InitializeRequest], + call_next: CallNext[mt.InitializeRequest, None], + ) -> None: + # Store some state during initialization + if context.fastmcp_context: + context.fastmcp_context.set_state("init_timestamp", "2024-01-01") + context.fastmcp_context.set_state("client_id", "test-123") + self.init_state["timestamp"] = "2024-01-01" + self.init_state["client_id"] = "test-123" + + return await call_next(context) + + async def on_call_tool( + self, + context: MiddlewareContext[mt.CallToolRequestParams], + call_next: CallNext[mt.CallToolRequestParams, Any], + ) -> Any: + # Try to access state from initialization + if context.fastmcp_context: + timestamp = context.fastmcp_context.get_state("init_timestamp") + client_id = context.fastmcp_context.get_state("client_id") + self.tool_state["timestamp"] = timestamp + self.tool_state["client_id"] = client_id + + return await call_next(context) + + middleware = StateTrackingMiddleware() + server.add_middleware(middleware) + + @server.tool + def test_tool() -> str: + return "success" + + async with Client(server) as client: + # Initialization should have set state + assert middleware.init_state["timestamp"] == "2024-01-01" + assert middleware.init_state["client_id"] == "test-123" + + # Call a tool - state should be accessible + result = await client.call_tool("test_tool", {}) + assert result.content[0].text == "success" # type: ignore[attr-defined] + + # State should have been accessible during tool call + # Note: State is request-scoped, so it won't persist across requests + # This test shows the pattern, but actual cross-request state would need + # external storage (Redis, DB, etc.) + # The middleware.tool_state might be None if state doesn't persist diff --git a/tests/server/middleware/test_logging.py b/tests/server/middleware/test_logging.py index aca1b5767..f63165a66 100644 --- a/tests/server/middleware/test_logging.py +++ b/tests/server/middleware/test_logging.py @@ -2,37 +2,40 @@ import datetime import logging -import re +from collections.abc import Generator from typing import Any, Literal, TypeVar -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import mcp import mcp.types import pytest from inline_snapshot import snapshot +from pydantic import AnyUrl from fastmcp import FastMCP from fastmcp.client import Client +from fastmcp.resources.template import ResourceTemplate from fastmcp.server.middleware.logging import ( LoggingMiddleware, StructuredLoggingMiddleware, ) -from fastmcp.server.middleware.middleware import MiddlewareContext +from fastmcp.server.middleware.middleware import CallNext, MiddlewareContext +from fastmcp.utilities.tests import caplog_for_fastmcp FIXED_DATE = datetime.datetime(2023, 1, 1, tzinfo=datetime.timezone.utc) T = TypeVar("T") -def remove_line_numbers(logs: str) -> str: - """Remove line numbers from log messages.""" - trimmed_logs = "" - lines = logs.split("\n") - for line in lines: - # Match only the first `:\d+ ` - line = re.sub(pattern=r":\d+ ", repl=":LINE_NUMBER ", string=line, count=1) - trimmed_logs += line + "\n" - return trimmed_logs +def get_log_lines( + caplog: pytest.LogCaptureFixture, module: str | None = None +) -> list[str]: + """Get log lines from a caplog fixture.""" + return [ + record.message + for record in caplog.records + if (module or "logging") in record.name + ] def new_mock_context( @@ -51,6 +54,17 @@ def new_mock_context( return context +@pytest.fixture(autouse=True) +def mock_duration_ms() -> Generator[float, None]: + """Mock duration_ms.""" + patched = patch( + "fastmcp.server.middleware.logging._get_duration_ms", return_value=0.02 + ) + patched.start() + yield + patched.stop() + + @pytest.fixture def mock_context(): """Create a mock middleware context.""" @@ -77,15 +91,14 @@ class TestStructuredLoggingMiddleware: def test_init_default(self): """Test default initialization.""" - middleware = LoggingMiddleware() + middleware = StructuredLoggingMiddleware() - assert middleware.logger.name == "fastmcp.requests" + assert middleware.logger.name == "fastmcp.middleware.structured_logging" assert middleware.log_level == logging.INFO assert middleware.include_payloads is False - assert middleware.max_payload_length == 1000 assert middleware.include_payload_length is False assert middleware.estimate_payload_tokens is False - assert middleware.structured_logging is False + assert middleware.structured_logging is True def test_init_custom(self): """Test custom initialization.""" @@ -108,14 +121,12 @@ class TestStructuredLoggingMiddleware: """Test message formatting without payloads.""" middleware = StructuredLoggingMiddleware() - message = middleware._create_before_message(mock_context, "test_event") + message = middleware._create_before_message(mock_context) assert message == snapshot( { - "event": "test_event", - "timestamp": "2023-01-01T00:00:00+00:00", + "event": "request_start", "source": "client", - "type": "request", "method": "test_method", } ) @@ -126,14 +137,12 @@ class TestStructuredLoggingMiddleware: """Test message formatting with payloads.""" middleware = StructuredLoggingMiddleware(include_payloads=True) - message = middleware._create_before_message(mock_context, "test_event") + message = middleware._create_before_message(mock_context) assert message == snapshot( { - "event": "test_event", - "timestamp": "2023-01-01T00:00:00+00:00", + "event": "request_start", "source": "client", - "type": "request", "method": "test_method", "payload": '{"method":"tools/call","params":{"_meta":null,"name":"test_method","arguments":{"param":"value"}}}', "payload_type": "CallToolRequest", @@ -143,14 +152,12 @@ class TestStructuredLoggingMiddleware: def test_calculate_response_size(self, mock_context: MiddlewareContext[Any]): """Test response size calculation.""" middleware = StructuredLoggingMiddleware(include_payload_length=True) - message = middleware._create_before_message(mock_context, "test_event") + message = middleware._create_before_message(mock_context) assert message == snapshot( { - "event": "test_event", - "timestamp": "2023-01-01T00:00:00+00:00", + "event": "request_start", "source": "client", - "type": "request", "method": "test_method", "payload_length": 98, } @@ -163,14 +170,12 @@ class TestStructuredLoggingMiddleware: middleware = StructuredLoggingMiddleware( include_payload_length=True, estimate_payload_tokens=True ) - message = middleware._create_before_message(mock_context, "test_event") + message = middleware._create_before_message(mock_context) assert message == snapshot( { - "event": "test_event", - "timestamp": "2023-01-01T00:00:00+00:00", + "event": "request_start", "source": "client", - "type": "request", "method": "test_method", "payload_tokens": 24, "payload_length": 98, @@ -186,16 +191,18 @@ class TestStructuredLoggingMiddleware: middleware = StructuredLoggingMiddleware() mock_call_next = AsyncMock(return_value="test_result") - with caplog.at_level(logging.INFO): + with caplog_for_fastmcp(caplog): result = await middleware.on_message(mock_context, mock_call_next) assert result == "test_result" assert mock_call_next.called - assert remove_line_numbers(caplog.text) == snapshot("""\ -INFO fastmcp.structured:logging.py:LINE_NUMBER Processing message: {"event": "request_start", "timestamp": "2023-01-01T00:00:00+00:00", "method": "test_method", "type": "request", "source": "client"} -INFO fastmcp.structured:logging.py:LINE_NUMBER Completed message: {"event": "request_success", "timestamp": "2023-01-01T00:00:00+00:00", "method": "test_method", "type": "request", "source": "client"} -""") + assert get_log_lines(caplog) == snapshot( + [ + '{"event": "request_start", "method": "test_method", "source": "client"}', + '{"event": "request_success", "method": "test_method", "source": "client", "duration_ms": 0.02}', + ] + ) async def test_on_message_failure( self, mock_context: MiddlewareContext[Any], caplog: pytest.LogCaptureFixture @@ -204,12 +211,16 @@ INFO fastmcp.structured:logging.py:LINE_NUMBER Completed message: {"event": middleware = StructuredLoggingMiddleware() mock_call_next = AsyncMock(side_effect=ValueError("test error")) - with caplog.at_level(logging.INFO): + with caplog_for_fastmcp(caplog): with pytest.raises(ValueError): await middleware.on_message(mock_context, mock_call_next) - assert "Processing message:" in caplog.text - assert "Failed message: test_method - test error" in caplog.text + assert get_log_lines(caplog) == snapshot( + [ + '{"event": "request_start", "method": "test_method", "source": "client"}', + '{"event": "request_error", "method": "test_method", "source": "client", "duration_ms": 0.02, "error": "test error"}', + ] + ) class TestLoggingMiddleware: @@ -218,7 +229,7 @@ class TestLoggingMiddleware: def test_init_default(self): """Test default initialization.""" middleware = LoggingMiddleware() - assert middleware.logger.name == "fastmcp.requests" + assert middleware.logger.name == "fastmcp.middleware.logging" assert middleware.log_level == logging.INFO assert middleware.include_payloads is False assert middleware.include_payload_length is False @@ -227,11 +238,11 @@ class TestLoggingMiddleware: def test_format_message(self, mock_context: MiddlewareContext[Any]): """Test message formatting.""" middleware = LoggingMiddleware() - message = middleware._create_before_message(mock_context, "test_event") + message = middleware._create_before_message(mock_context) formatted = middleware._format_message(message) assert formatted == snapshot( - "event=test_event timestamp=2023-01-01T00:00:00+00:00 method=test_method type=request source=client" + "event=request_start method=test_method source=client" ) def test_create_before_message_long_payload( @@ -240,12 +251,196 @@ class TestLoggingMiddleware: """Test message formatting with long payload truncation.""" middleware = LoggingMiddleware(include_payloads=True, max_payload_length=10) - message = middleware._create_before_message(mock_context, "test_event") + message = middleware._create_before_message(mock_context) formatted = middleware._format_message(message) - assert "payload=" in formatted - assert "..." in formatted + assert formatted == snapshot( + 'event=request_start method=test_method source=client payload={"method":... payload_type=CallToolRequest' + ) + + async def test_on_message_failure( + self, mock_context: MiddlewareContext[Any], caplog: pytest.LogCaptureFixture + ): + """Test structured logging of failed messages.""" + middleware = StructuredLoggingMiddleware() + mock_call_next = AsyncMock(side_effect=ValueError("test error")) + + with caplog_for_fastmcp(caplog): + with pytest.raises(ValueError): + await middleware.on_message(mock_context, mock_call_next) + + # Check that we have structured JSON logs + assert get_log_lines(caplog) == snapshot( + [ + '{"event": "request_start", "method": "test_method", "source": "client"}', + '{"event": "request_error", "method": "test_method", "source": "client", "duration_ms": 0.02, "error": "test error"}', + ] + ) + + async def test_on_message_with_pydantic_types_in_payload( + self, + mock_call_next: CallNext[Any, Any], + caplog: pytest.LogCaptureFixture, + ): + """Ensure Pydantic AnyUrl in payload serializes correctly when include_payloads=True.""" + + mock_context = new_mock_context( + message=mcp.types.ReadResourceRequest( + method="resources/read", + params=mcp.types.ReadResourceRequestParams( + uri=AnyUrl("test://example/1"), + ), + ) + ) + + middleware = StructuredLoggingMiddleware(include_payloads=True) + + with caplog_for_fastmcp(caplog): + result = await middleware.on_message(mock_context, mock_call_next) + + assert result == "test_result" + + assert get_log_lines(caplog) == snapshot( + [ + '{"event": "request_start", "method": "test_method", "source": "client", "payload": "{\\"method\\":\\"resources/read\\",\\"params\\":{\\"_meta\\":null,\\"uri\\":\\"test://example/1\\"}}", "payload_type": "ReadResourceRequest"}', + '{"event": "request_success", "method": "test_method", "source": "client", "duration_ms": 0.02}', + ] + ) + + async def test_on_message_with_resource_template_in_payload( + self, + mock_call_next: CallNext[Any, Any], + caplog: pytest.LogCaptureFixture, + ): + """Ensure ResourceTemplate in payload serializes via pydantic conversion without errors.""" + + mock_context = new_mock_context( + message=ResourceTemplate( + name="tmpl", + uri_template="tmpl://{id}", + parameters={"id": {"type": "string"}}, + ) + ) + + middleware = StructuredLoggingMiddleware(include_payloads=True) + + with caplog_for_fastmcp(caplog): + result = await middleware.on_message(mock_context, mock_call_next) + + assert result == "test_result" + + assert get_log_lines(caplog) == snapshot( + [ + '{"event": "request_start", "method": "test_method", "source": "client", "payload": "{\\"name\\":\\"tmpl\\",\\"title\\":null,\\"description\\":null,\\"tags\\":[],\\"meta\\":null,\\"enabled\\":true,\\"uri_template\\":\\"tmpl://{id}\\",\\"mime_type\\":\\"text/plain\\",\\"parameters\\":{\\"id\\":{\\"type\\":\\"string\\"}},\\"annotations\\":null}", "payload_type": "ResourceTemplate"}', + '{"event": "request_success", "method": "test_method", "source": "client", "duration_ms": 0.02}', + ] + ) + + async def test_on_message_with_nonserializable_payload_falls_back_to_str( + self, mock_call_next: CallNext[Any, Any], caplog: pytest.LogCaptureFixture + ): + """Ensure non-JSONable objects fall back to string serialization in payload.""" + + class NonSerializable: + def __str__(self) -> str: + return "NON_SERIALIZABLE" + + mock_context = new_mock_context( + message=mcp.types.CallToolRequest( + method="tools/call", + params=mcp.types.CallToolRequestParams( + name="test_method", + arguments={"obj": NonSerializable()}, + ), + ) + ) + + middleware = StructuredLoggingMiddleware(include_payloads=True) + + with caplog_for_fastmcp(caplog): + result = await middleware.on_message(mock_context, mock_call_next) + + assert result == "test_result" + + assert get_log_lines(caplog) == snapshot( + [ + '{"event": "request_start", "method": "test_method", "source": "client", "payload": "{\\"method\\":\\"tools/call\\",\\"params\\":{\\"_meta\\":null,\\"name\\":\\"test_method\\",\\"arguments\\":{\\"obj\\":\\"NON_SERIALIZABLE\\"}}}", "payload_type": "CallToolRequest"}', + '{"event": "request_success", "method": "test_method", "source": "client", "duration_ms": 0.02}', + ] + ) + + async def test_on_message_with_custom_serializer_applied( + self, mock_call_next: CallNext[Any, Any], caplog: pytest.LogCaptureFixture + ): + """Ensure a custom serializer is used for non-JSONable payloads.""" + + # Provide a serializer that replaces entire payload with a fixed string + def custom_serializer(_: Any) -> str: + return "CUSTOM_PAYLOAD" + + mock_context = new_mock_context( + message=mcp.types.CallToolRequest( + method="tools/call", + params=mcp.types.CallToolRequestParams( + name="test_method", + arguments={"obj": "OBJECT"}, + ), + ) + ) + + middleware = StructuredLoggingMiddleware( + include_payloads=True, payload_serializer=custom_serializer + ) + + with caplog_for_fastmcp(caplog): + result = await middleware.on_message(mock_context, mock_call_next) + + assert result == "test_result" + + assert get_log_lines(caplog) == snapshot( + [ + '{"event": "request_start", "method": "test_method", "source": "client", "payload": "CUSTOM_PAYLOAD", "payload_type": "CallToolRequest"}', + '{"event": "request_success", "method": "test_method", "source": "client", "duration_ms": 0.02}', + ] + ) + + +@pytest.fixture +def logging_server(): + """Create a FastMCP server specifically for logging middleware tests.""" + from fastmcp import FastMCP + + mcp = FastMCP("LoggingTestServer") + + @mcp.tool + def simple_operation(data: str) -> str: + """A simple operation for testing logging.""" + return f"Processed: {data}" + + @mcp.tool + def complex_operation(items: list[str], mode: str = "default") -> dict: + """A complex operation with structured data.""" + return {"processed_items": len(items), "mode": mode, "result": "success"} + + @mcp.tool + def operation_with_error(should_fail: bool = False) -> str: + """An operation that can be made to fail.""" + if should_fail: + raise ValueError("Operation failed intentionally") + return "Operation completed successfully" + + @mcp.resource("log://test") + def test_resource() -> str: + """A test resource for logging.""" + return "Test resource content" + + @mcp.prompt + def test_prompt() -> str: + """A test prompt for logging.""" + return "Test prompt content" + + return mcp class TestLoggingMiddlewareIntegration: @@ -290,33 +485,29 @@ class TestLoggingMiddlewareIntegration: ): """Test that logging middleware captures successful operations.""" logging_middleware = LoggingMiddleware(methods=["tools/call"]) - logging_middleware._get_timestamp_from_context = ( # ty: ignore[invalid-assignment] - lambda _: FIXED_DATE.isoformat() - ) logging_server.add_middleware(logging_middleware) - with caplog.at_level(logging.INFO): - async with Client(logging_server) as client: - await client.call_tool( - name="simple_operation", arguments={"data": "test_data"} - ) - await client.call_tool( - name="complex_operation", - arguments={"items": ["a", "b", "c"], "mode": "batch"}, - ) + with caplog_for_fastmcp(caplog): + with caplog.at_level(logging.INFO): + async with Client(logging_server) as client: + await client.call_tool( + name="simple_operation", arguments={"data": "test_data"} + ) + await client.call_tool( + name="complex_operation", + arguments={"items": ["a", "b", "c"], "mode": "batch"}, + ) # Should have processing and completion logs for both operations - assert remove_line_numbers(caplog.text) == snapshot("""\ -INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of type CallToolRequest -INFO fastmcp.requests:logging.py:LINE_NUMBER Processing message: event=request_start timestamp=2023-01-01T00:00:00+00:00 method=tools/call type=request source=client -INFO fastmcp.requests:logging.py:LINE_NUMBER Completed message: event=request_success timestamp=2023-01-01T00:00:00+00:00 method=tools/call type=request source=client -INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of type ListToolsRequest -INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of type CallToolRequest -INFO fastmcp.requests:logging.py:LINE_NUMBER Processing message: event=request_start timestamp=2023-01-01T00:00:00+00:00 method=tools/call type=request source=client -INFO fastmcp.requests:logging.py:LINE_NUMBER Completed message: event=request_success timestamp=2023-01-01T00:00:00+00:00 method=tools/call type=request source=client - -""") + assert get_log_lines(caplog) == snapshot( + [ + "event=request_start method=tools/call source=client", + "event=request_success method=tools/call source=client duration_ms=0.02", + "event=request_start method=tools/call source=client", + "event=request_success method=tools/call source=client duration_ms=0.02", + ] + ) async def test_logging_middleware_logs_failures( self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture @@ -324,7 +515,7 @@ INFO fastmcp.requests:logging.py:LINE_NUMBER Completed message: event=reques """Test that logging middleware captures failed operations.""" logging_server.add_middleware(LoggingMiddleware(methods=["tools/call"])) - with caplog.at_level(logging.INFO): + with caplog_for_fastmcp(caplog): async with Client(logging_server) as client: # This should fail and be logged with pytest.raises(Exception): @@ -335,8 +526,9 @@ INFO fastmcp.requests:logging.py:LINE_NUMBER Completed message: event=reques log_text = caplog.text # Should have processing and failure logs - assert "Processing message:" in log_text - assert "Failed message: tools/call" in log_text + assert log_text.splitlines()[-1] == snapshot( + "ERROR fastmcp.middleware.logging:logging.py:122 event=request_error method=tools/call source=client duration_ms=0.02 error=Error calling tool 'operation_with_error': Operation failed intentionally" + ) async def test_logging_middleware_with_payloads( self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture @@ -346,24 +538,18 @@ INFO fastmcp.requests:logging.py:LINE_NUMBER Completed message: event=reques middleware = LoggingMiddleware( include_payloads=True, max_payload_length=500, methods=["tools/call"] ) - middleware._get_timestamp_from_context = ( # ty: ignore[invalid-assignment] - lambda _: FIXED_DATE.isoformat() - ) logging_server.add_middleware(middleware) - with caplog.at_level(logging.INFO): + with caplog_for_fastmcp(caplog): async with Client(logging_server) as client: await client.call_tool("simple_operation", {"data": "payload_test"}) - log_text = caplog.text - - assert remove_line_numbers(log_text) == snapshot("""\ -INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of type CallToolRequest -INFO fastmcp.requests:logging.py:LINE_NUMBER Processing message: event=request_start timestamp=2023-01-01T00:00:00+00:00 method=tools/call type=request source=client payload={"_meta":null,"name":"simple_operation","arguments":{"data":"payload_test"}} payload_type=CallToolRequestParams -INFO fastmcp.requests:logging.py:LINE_NUMBER Completed message: event=request_success timestamp=2023-01-01T00:00:00+00:00 method=tools/call type=request source=client -INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of type ListToolsRequest - -""") + assert get_log_lines(caplog) == snapshot( + [ + 'event=request_start method=tools/call source=client payload={"_meta":null,"name":"simple_operation","arguments":{"data":"payload_test"}} payload_type=CallToolRequestParams', + "event=request_success method=tools/call source=client duration_ms=0.02", + ] + ) async def test_structured_logging_middleware_produces_json( self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture @@ -373,34 +559,21 @@ INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of logging_middleware = StructuredLoggingMiddleware( include_payloads=True, methods=["tools/call"] ) - logging_middleware._get_timestamp_from_context = ( # ty: ignore[invalid-assignment] - lambda _: FIXED_DATE.isoformat() - ) logging_server.add_middleware(logging_middleware) - with caplog.at_level(logging.INFO): + with caplog_for_fastmcp(caplog): async with Client(logging_server) as client: await client.call_tool( name="simple_operation", arguments={"data": "json_test"} ) - # Extract JSON log entries - log_lines = [ - record.message - for record in caplog.records - if record.name == "fastmcp.structured" - ] - - assert len(log_lines) >= 2 # Should have start and success entries - - assert remove_line_numbers(caplog.text) == snapshot("""\ -INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of type CallToolRequest -INFO fastmcp.structured:logging.py:LINE_NUMBER Processing message: {"event": "request_start", "timestamp": "2023-01-01T00:00:00+00:00", "method": "tools/call", "type": "request", "source": "client", "payload": "{\\"_meta\\":null,\\"name\\":\\"simple_operation\\",\\"arguments\\":{\\"data\\":\\"json_test\\"}}", "payload_type": "CallToolRequestParams"} -INFO fastmcp.structured:logging.py:LINE_NUMBER Completed message: {"event": "request_success", "timestamp": "2023-01-01T00:00:00+00:00", "method": "tools/call", "type": "request", "source": "client"} -INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of type ListToolsRequest - -""") + assert get_log_lines(caplog) == snapshot( + [ + '{"event": "request_start", "method": "tools/call", "source": "client", "payload": "{\\"_meta\\":null,\\"name\\":\\"simple_operation\\",\\"arguments\\":{\\"data\\":\\"json_test\\"}}", "payload_type": "CallToolRequestParams"}', + '{"event": "request_success", "method": "tools/call", "source": "client", "duration_ms": 0.02}', + ] + ) async def test_structured_logging_middleware_handles_errors( self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture @@ -408,25 +581,23 @@ INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of """Test structured logging of errors with JSON format.""" logging_middleware = StructuredLoggingMiddleware(methods=["tools/call"]) - logging_middleware._get_timestamp_from_context = ( # ty: ignore[invalid-assignment] - lambda _: FIXED_DATE.isoformat() - ) logging_server.add_middleware(logging_middleware) - with caplog.at_level(logging.INFO): - async with Client(logging_server) as client: - with pytest.raises(Exception): - await client.call_tool( - "operation_with_error", {"should_fail": True} - ) + with caplog_for_fastmcp(caplog): + with caplog.at_level(logging.INFO): + async with Client(logging_server) as client: + with pytest.raises(Exception): + await client.call_tool( + "operation_with_error", {"should_fail": True} + ) - assert remove_line_numbers(caplog.text) == snapshot("""\ -INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of type CallToolRequest -INFO fastmcp.structured:logging.py:LINE_NUMBER Processing message: {"event": "request_start", "timestamp": "2023-01-01T00:00:00+00:00", "method": "tools/call", "type": "request", "source": "client"} -ERROR fastmcp.structured:logging.py:LINE_NUMBER Failed message: tools/call - Error calling tool 'operation_with_error': Operation failed intentionally - -""") + assert get_log_lines(caplog) == snapshot( + [ + '{"event": "request_start", "method": "tools/call", "source": "client"}', + '{"event": "request_error", "method": "tools/call", "source": "client", "duration_ms": 0.02, "error": "Error calling tool \'operation_with_error\': Operation failed intentionally"}', + ] + ) async def test_logging_middleware_with_different_operations( self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture @@ -444,7 +615,7 @@ ERROR fastmcp.structured:logging.py:LINE_NUMBER Failed message: tools/call - ) ) - with caplog.at_level(logging.INFO): + with caplog_for_fastmcp(caplog): async with Client(logging_server) as client: # Test different operation types await client.call_tool("simple_operation", {"data": "test"}) @@ -452,16 +623,18 @@ ERROR fastmcp.structured:logging.py:LINE_NUMBER Failed message: tools/call - await client.get_prompt("test_prompt") await client.list_resources() - log_text = caplog.text - - # Should have logs for all different operation types - # Note: Different operations may have different method names - processing_count = log_text.count("Processing message:") - completion_count = log_text.count("Completed message:") - - # Should have processed all 4 operations - assert processing_count == 4 - assert completion_count == 4 + assert get_log_lines(caplog) == snapshot( + [ + "event=request_start method=tools/call source=client", + "event=request_success method=tools/call source=client duration_ms=0.02", + "event=request_start method=resources/read source=client", + "event=request_success method=resources/read source=client duration_ms=0.02", + "event=request_start method=prompts/get source=client", + "event=request_success method=prompts/get source=client duration_ms=0.02", + "event=request_start method=resources/list source=client", + "event=request_success method=resources/list source=client duration_ms=0.02", + ] + ) async def test_logging_middleware_custom_configuration( self, logging_server: FastMCP @@ -491,5 +664,7 @@ ERROR fastmcp.structured:logging.py:LINE_NUMBER Failed message: tools/call - # Check that our custom logger captured the logs log_output = log_buffer.getvalue() - assert "Processing message:" in log_output - assert "payload=" in log_output + assert log_output == snapshot("""\ +event=request_start method=tools/call source=client payload={"_meta":null,"name":"simple_operation","arguments":{"data":"custom_test"}} payload_type=CallToolRequestParams +event=request_success method=tools/call source=client duration_ms=0.02 +""") diff --git a/tests/server/middleware/test_middleware.py b/tests/server/middleware/test_middleware.py index 8469df424..d93b6a202 100644 --- a/tests/server/middleware/test_middleware.py +++ b/tests/server/middleware/test_middleware.py @@ -293,6 +293,17 @@ class TestMiddlewareHooks: result = list_prompts_calls[0].result assert isinstance(result, list) + async def test_initialize( + self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware + ): + async with Client(mcp_server) as client: + await client.ping() + + assert recording_middleware.assert_called(at_least=1) + assert recording_middleware.assert_called(hook="on_message", at_least=1) + assert recording_middleware.assert_called(hook="on_request", at_least=1) + assert recording_middleware.assert_called(hook="on_initialize", at_least=1) + async def test_list_tools_filtering_middleware(self): """Test that middleware can filter tools.""" diff --git a/tests/server/middleware/test_rate_limiting.py b/tests/server/middleware/test_rate_limiting.py index 0a4e1d67b..1f17d7095 100644 --- a/tests/server/middleware/test_rate_limiting.py +++ b/tests/server/middleware/test_rate_limiting.py @@ -306,9 +306,10 @@ class TestRateLimitingMiddlewareIntegration: async def test_rate_limiting_blocks_rapid_requests(self, rate_limit_server): """Test that rate limiting blocks rapid successive requests.""" - # Very restrictive rate limit (accounting for extra list_tools calls per tool call) + # Very restrictive rate limit (accounting for initialization and list_tools calls) + # Requests: 1 initialize + 1 list_tools + 4 call_tools = 6 total before limit rate_limit_server.add_middleware( - RateLimitingMiddleware(max_requests_per_second=10.0, burst_capacity=5) + RateLimitingMiddleware(max_requests_per_second=10.0, burst_capacity=6) ) async with Client(rate_limit_server) as client: @@ -356,7 +357,7 @@ class TestRateLimitingMiddlewareIntegration: """Test sliding window rate limiting implementation.""" rate_limit_server.add_middleware( SlidingWindowRateLimitingMiddleware( - max_requests=5, # Accounting for extra list_tools calls + max_requests=6, # 1 init + 1 list_tools + 3 calls + 1 to fail window_minutes=1, # 1-minute window ) ) @@ -374,7 +375,7 @@ class TestRateLimitingMiddlewareIntegration: async def test_rate_limiting_with_different_operations(self, rate_limit_server): """Test that rate limiting applies to all types of operations.""" rate_limit_server.add_middleware( - RateLimitingMiddleware(max_requests_per_second=9.0, burst_capacity=4) + RateLimitingMiddleware(max_requests_per_second=9.0, burst_capacity=5) ) async with Client(rate_limit_server) as client: @@ -395,8 +396,8 @@ class TestRateLimitingMiddlewareIntegration: rate_limit_server.add_middleware( RateLimitingMiddleware( - max_requests_per_second=6.0, # Accounting for extra list_tools calls - burst_capacity=3, + max_requests_per_second=6.0, # Accounting for initialization and list_tools calls + burst_capacity=4, get_client_id=get_client_id, ) ) @@ -416,8 +417,8 @@ class TestRateLimitingMiddlewareIntegration: rate_limit_server.add_middleware( RateLimitingMiddleware( max_requests_per_second=6.0, - burst_capacity=4, - global_limit=True, # Accounting for extra list_tools calls + burst_capacity=5, # 1 init + 2 list_tools + 2 calls before limit + global_limit=True, # Accounting for initialization and list_tools calls ) ) @@ -435,7 +436,7 @@ class TestRateLimitingMiddlewareIntegration: rate_limit_server.add_middleware( RateLimitingMiddleware( max_requests_per_second=10.0, # 10 per second = 1 every 100ms - burst_capacity=3, + burst_capacity=4, ) ) diff --git a/tests/server/middleware/test_timing.py b/tests/server/middleware/test_timing.py index b7dc45195..db726b13e 100644 --- a/tests/server/middleware/test_timing.py +++ b/tests/server/middleware/test_timing.py @@ -11,6 +11,7 @@ from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.server.middleware.middleware import MiddlewareContext from fastmcp.server.middleware.timing import DetailedTimingMiddleware, TimingMiddleware +from fastmcp.utilities.tests import caplog_for_fastmcp @pytest.fixture @@ -47,7 +48,7 @@ class TestTimingMiddleware: """Test timing successful requests.""" middleware = TimingMiddleware() - with caplog.at_level(logging.INFO): + with caplog_for_fastmcp(caplog): result = await middleware.on_request(mock_context, mock_call_next) assert result == "test_result" @@ -60,7 +61,7 @@ class TestTimingMiddleware: middleware = TimingMiddleware() mock_call_next = AsyncMock(side_effect=ValueError("test error")) - with caplog.at_level(logging.INFO): + with caplog_for_fastmcp(caplog): with pytest.raises(ValueError): await middleware.on_request(mock_context, mock_call_next) @@ -84,7 +85,7 @@ class TestDetailedTimingMiddleware: context.message.name = "test_tool" mock_call_next = AsyncMock(return_value="tool_result") - with caplog.at_level(logging.INFO): + with caplog_for_fastmcp(caplog): result = await middleware.on_call_tool(context, mock_call_next) assert result == "tool_result" @@ -97,7 +98,7 @@ class TestDetailedTimingMiddleware: context.message.uri = "test://resource" mock_call_next = AsyncMock(return_value="resource_result") - with caplog.at_level(logging.INFO): + with caplog_for_fastmcp(caplog): result = await middleware.on_read_resource(context, mock_call_next) assert result == "resource_result" @@ -110,7 +111,7 @@ class TestDetailedTimingMiddleware: context.message.name = "test_prompt" mock_call_next = AsyncMock(return_value="prompt_result") - with caplog.at_level(logging.INFO): + with caplog_for_fastmcp(caplog): result = await middleware.on_get_prompt(context, mock_call_next) assert result == "prompt_result" @@ -122,7 +123,7 @@ class TestDetailedTimingMiddleware: context = MagicMock() mock_call_next = AsyncMock(return_value="tools_result") - with caplog.at_level(logging.INFO): + with caplog_for_fastmcp(caplog): result = await middleware.on_list_tools(context, mock_call_next) assert result == "tools_result" @@ -135,7 +136,7 @@ class TestDetailedTimingMiddleware: context.message.name = "failing_tool" mock_call_next = AsyncMock(side_effect=RuntimeError("operation failed")) - with caplog.at_level(logging.INFO): + with caplog_for_fastmcp(caplog): with pytest.raises(RuntimeError): await middleware.on_call_tool(context, mock_call_next) @@ -155,15 +156,15 @@ def timing_server(): @mcp.tool def short_task() -> str: - """A task that takes 0.1 seconds.""" - time.sleep(0.1) - return "Done after 0.1s" + """A task that takes 0.01 seconds.""" + time.sleep(0.01) + return "Done after 0.01 seconds" @mcp.tool def medium_task() -> str: - """A task that takes 0.15 seconds.""" - time.sleep(0.15) - return "Done after 0.15s" + """A task that takes 0.02 seconds.""" + time.sleep(0.02) + return "Done after 0.02 seconds" @mcp.tool def failing_task() -> str: @@ -173,14 +174,14 @@ def timing_server(): @mcp.resource("timer://test") def test_resource() -> str: """A resource that takes time to read.""" - time.sleep(0.05) - return "Resource content after 0.05s" + time.sleep(0.005) + return "Resource content after 0.005 seconds" @mcp.prompt def test_prompt() -> str: """A prompt that takes time to generate.""" - time.sleep(0.08) - return "Prompt content after 0.08s" + time.sleep(0.008) + return "Prompt content after 0.008 seconds" return mcp @@ -194,7 +195,7 @@ class TestTimingMiddlewareIntegration: """Test that timing middleware accurately measures tool execution times.""" timing_server.add_middleware(TimingMiddleware()) - with caplog.at_level(logging.INFO): + with caplog_for_fastmcp(caplog): async with Client(timing_server) as client: # Test instant task await client.call_tool("instant_task") @@ -225,7 +226,7 @@ class TestTimingMiddlewareIntegration: """Test that timing middleware measures time even for failed operations.""" timing_server.add_middleware(TimingMiddleware()) - with caplog.at_level(logging.INFO): + with caplog_for_fastmcp(caplog): async with Client(timing_server) as client: # This should fail but still be timed with pytest.raises(Exception): @@ -241,7 +242,7 @@ class TestTimingMiddlewareIntegration: """Test that detailed timing middleware provides operation-specific timing.""" timing_server.add_middleware(DetailedTimingMiddleware()) - with caplog.at_level(logging.INFO): + with caplog_for_fastmcp(caplog): async with Client(timing_server) as client: # Test tool call await client.call_tool("short_task") @@ -271,7 +272,7 @@ class TestTimingMiddlewareIntegration: """Test timing middleware with concurrent operations.""" timing_server.add_middleware(TimingMiddleware()) - with caplog.at_level(logging.INFO): + with caplog_for_fastmcp(caplog): async with Client(timing_server) as client: # Run multiple operations concurrently tasks = [ @@ -290,7 +291,7 @@ class TestTimingMiddlewareIntegration: len(timing_logs) >= 3 ) # At least 3 tool calls, may have additional list_tools calls - async def test_timing_middleware_custom_logger(self, timing_server): + async def test_timing_middleware_custom_logger(self, timing_server, caplog): """Test timing middleware with custom logger configuration.""" import io import logging diff --git a/tests/server/openapi/test_openapi_path_parameters.py b/tests/server/openapi/test_openapi_path_parameters.py index a9ec8348d..db782b938 100644 --- a/tests/server/openapi/test_openapi_path_parameters.py +++ b/tests/server/openapi/test_openapi_path_parameters.py @@ -163,7 +163,7 @@ async def test_integration_array_path_parameter(array_path_spec, mock_client): mcp = FastMCP.from_openapi(array_path_spec, client=mock_client) # Call the tool with a single value - await mcp._mcp_call_tool("test_operation", {"days": ["monday"]}) + await mcp._call_tool_mcp("test_operation", {"days": ["monday"]}) # Check the request was made correctly mock_client.request.assert_called_with( @@ -177,7 +177,7 @@ async def test_integration_array_path_parameter(array_path_spec, mock_client): mock_client.request.reset_mock() # Call the tool with multiple values - await mcp._mcp_call_tool("test_operation", {"days": ["monday", "tuesday"]}) + await mcp._call_tool_mcp("test_operation", {"days": ["monday", "tuesday"]}) # Check the request was made correctly mock_client.request.assert_called_with( diff --git a/tests/server/proxy/test_proxy_server.py b/tests/server/proxy/test_proxy_server.py index 561353084..fd82a7f9b 100644 --- a/tests/server/proxy/test_proxy_server.py +++ b/tests/server/proxy/test_proxy_server.py @@ -173,15 +173,15 @@ class TestTools: async def test_list_tools_same_as_original(self, fastmcp_server, proxy_server): assert ( - await proxy_server._mcp_list_tools() - == await fastmcp_server._mcp_list_tools() + await proxy_server._list_tools_mcp() + == await fastmcp_server._list_tools_mcp() ) async def test_call_tool_result_same_as_original( self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy ): - result = await fastmcp_server._mcp_call_tool("greet", {"name": "Alice"}) - proxy_result = await proxy_server._mcp_call_tool("greet", {"name": "Alice"}) + result = await fastmcp_server._call_tool_mcp("greet", {"name": "Alice"}) + proxy_result = await proxy_server._call_tool_mcp("greet", {"name": "Alice"}) assert result == proxy_result @@ -267,8 +267,8 @@ class TestResources: async def test_list_resources_same_as_original(self, fastmcp_server, proxy_server): assert ( - await proxy_server._mcp_list_resources() - == await fastmcp_server._mcp_list_resources() + await proxy_server._list_resources_mcp() + == await fastmcp_server._list_resources_mcp() ) async def test_read_resource(self, proxy_server: FastMCPProxy): @@ -367,8 +367,8 @@ class TestResourceTemplates: async def test_list_resource_templates_same_as_original( self, fastmcp_server, proxy_server ): - result = await fastmcp_server._mcp_list_resource_templates() - proxy_result = await proxy_server._mcp_list_resource_templates() + result = await fastmcp_server._list_resource_templates_mcp() + proxy_result = await proxy_server._list_resource_templates_mcp() assert proxy_result == result @pytest.mark.parametrize("id", [1, 2, 3]) diff --git a/tests/server/test_file_server.py b/tests/server/test_file_server.py index c10b44519..592803d78 100644 --- a/tests/server/test_file_server.py +++ b/tests/server/test_file_server.py @@ -74,7 +74,7 @@ def tools(mcp: FastMCP, test_dir: Path) -> FastMCP: async def test_list_resources(mcp: FastMCP): - resources = await mcp._mcp_list_resources() + resources = await mcp._list_resources_mcp() assert len(resources) == 4 assert [str(r.uri) for r in resources] == [ @@ -86,7 +86,7 @@ async def test_list_resources(mcp: FastMCP): async def test_read_resource_dir(mcp: FastMCP): - res_iter = await mcp._mcp_read_resource("dir://test_dir") + res_iter = await mcp._read_resource_mcp("dir://test_dir") res_list = list(res_iter) assert len(res_list) == 1 res = res_list[0] @@ -102,7 +102,7 @@ async def test_read_resource_dir(mcp: FastMCP): async def test_read_resource_file(mcp: FastMCP): - res_iter = await mcp._mcp_read_resource("file://test_dir/example.py") + res_iter = await mcp._read_resource_mcp("file://test_dir/example.py") res_list = list(res_iter) assert len(res_list) == 1 res = res_list[0] @@ -110,17 +110,17 @@ async def test_read_resource_file(mcp: FastMCP): async def test_delete_file(mcp: FastMCP, test_dir: Path): - await mcp._mcp_call_tool( + await mcp._call_tool_mcp( "delete_file", arguments=dict(path=str(test_dir / "example.py")) ) assert not (test_dir / "example.py").exists() async def test_delete_file_and_check_resources(mcp: FastMCP, test_dir: Path): - await mcp._mcp_call_tool( + await mcp._call_tool_mcp( "delete_file", arguments=dict(path=str(test_dir / "example.py")) ) - res_iter = await mcp._mcp_read_resource("file://test_dir/example.py") + res_iter = await mcp._read_resource_mcp("file://test_dir/example.py") res_list = list(res_iter) assert len(res_list) == 1 res = res_list[0] diff --git a/tests/server/test_log_level.py b/tests/server/test_log_level.py new file mode 100644 index 000000000..4a26f4daa --- /dev/null +++ b/tests/server/test_log_level.py @@ -0,0 +1,88 @@ +"""Test log_level parameter support in FastMCP server.""" + +import asyncio +from unittest.mock import AsyncMock, patch + +from fastmcp import FastMCP + + +class TestLogLevelParameter: + """Test that log_level parameter is properly accepted by run methods.""" + + async def test_run_stdio_accepts_log_level(self): + """Test that run_stdio_async accepts log_level parameter.""" + server = FastMCP("TestServer") + + # Mock the stdio_server to avoid actual stdio operations + with patch("fastmcp.server.server.stdio_server") as mock_stdio: + mock_stdio.return_value.__aenter__ = AsyncMock( + return_value=(AsyncMock(), AsyncMock()) + ) + mock_stdio.return_value.__aexit__ = AsyncMock() + + # Mock the underlying MCP server run method + with patch.object(server._mcp_server, "run", new_callable=AsyncMock): + try: + # This should accept the log_level parameter without error + await asyncio.wait_for( + server.run_stdio_async(log_level="DEBUG", show_banner=False), + timeout=0.1, + ) + except asyncio.TimeoutError: + pass # Expected since we're mocking + + async def test_run_http_accepts_log_level(self): + """Test that run_http_async accepts log_level parameter.""" + server = FastMCP("TestServer") + + # Mock uvicorn to avoid actual server start + with patch("fastmcp.server.server.uvicorn.Server") as mock_server_class: + mock_instance = mock_server_class.return_value + mock_instance.serve = AsyncMock() + + # This should accept the log_level parameter without error + await server.run_http_async( + log_level="INFO", show_banner=False, host="127.0.0.1", port=8000 + ) + + # Verify serve was called + mock_instance.serve.assert_called_once() + + async def test_run_async_passes_log_level(self): + """Test that run_async passes log_level to transport methods.""" + server = FastMCP("TestServer") + + # Test stdio transport + with patch.object( + server, "run_stdio_async", new_callable=AsyncMock + ) as mock_stdio: + await server.run_async(transport="stdio", log_level="WARNING") + mock_stdio.assert_called_once_with(show_banner=True, log_level="WARNING") + + # Test http transport + with patch.object( + server, "run_http_async", new_callable=AsyncMock + ) as mock_http: + await server.run_async(transport="http", log_level="ERROR") + mock_http.assert_called_once_with( + transport="http", show_banner=True, log_level="ERROR" + ) + + def test_sync_run_accepts_log_level(self): + """Test that the synchronous run method accepts log_level.""" + server = FastMCP("TestServer") + + with patch.object(server, "run_async", new_callable=AsyncMock): + # Mock anyio.run to avoid actual async execution + with patch("anyio.run") as mock_anyio_run: + server.run(transport="stdio", log_level="CRITICAL") + + # Verify anyio.run was called + mock_anyio_run.assert_called_once() + + # Get the function that was passed to anyio.run + called_func = mock_anyio_run.call_args[0][0] + + # The function should be a partial that includes log_level + assert hasattr(called_func, "keywords") + assert called_func.keywords.get("log_level") == "CRITICAL" diff --git a/tests/server/test_server.py b/tests/server/test_server.py index af0b9dcd8..819cb2146 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -76,7 +76,7 @@ class TestTools: def fn(x: int) -> int: return x + 1 - mcp_tools = await mcp._mcp_list_tools() + mcp_tools = await mcp._list_tools_mcp() assert len(mcp_tools) == 1 assert mcp_tools[0].name == "fn" @@ -89,7 +89,7 @@ class TestTools: def fn(x: int) -> int: return x + 1 - mcp_tools = await mcp._mcp_list_tools() + mcp_tools = await mcp._list_tools_mcp() assert len(mcp_tools) == 1 assert mcp_tools[0].name == "custom_name" @@ -110,7 +110,7 @@ class TestTools: assert "adder" not in mcp_tools with pytest.raises(NotFoundError, match="Unknown tool: adder"): - await mcp._mcp_call_tool("adder", {"a": 1, "b": 2}) + await mcp._call_tool_mcp("adder", {"a": 1, "b": 2}) async def test_add_tool_at_init(self): def f(x: int) -> int: @@ -136,7 +136,7 @@ class TestToolDecorator: mcp = FastMCP() with pytest.raises(NotFoundError, match="Unknown tool: add"): - await mcp._mcp_call_tool("add", {"x": 1, "y": 2}) + await mcp._call_tool_mcp("add", {"x": 1, "y": 2}) async def test_tool_decorator(self): mcp = FastMCP() @@ -185,7 +185,7 @@ class TestToolDecorator: def add(x: int, y: int) -> int: return x + y - tools = await mcp._mcp_list_tools() + tools = await mcp._list_tools_mcp() assert len(tools) == 1 tool = tools[0] assert tool.description == "Add two numbers" diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index dd5be983f..030cd4223 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -935,12 +935,13 @@ class TestToolOutputSchema: assert len(tools) == 1 type_schema = TypeAdapter(annotation).json_schema() + # Remove title fields from the schema for comparison (title pruning is enabled) + type_schema = compress_schema(type_schema, prune_titles=True) # this line will fail until MCP adds output schemas!! assert tools[0].outputSchema == { "type": "object", - "properties": {"result": {**type_schema, "title": "Result"}}, + "properties": {"result": type_schema}, "required": ["result"], - "title": "_WrappedResult", "x-fastmcp-wrap-result": True, } @@ -958,7 +959,9 @@ class TestToolOutputSchema: async with Client(mcp) as client: tools = await client.list_tools() - type_schema = compress_schema(TypeAdapter(annotation).json_schema()) + type_schema = compress_schema( + TypeAdapter(annotation).json_schema(), prune_titles=True + ) assert len(tools) == 1 # Normalize anyOf ordering for comparison since union type order @@ -1071,9 +1074,8 @@ class TestToolOutputSchema: tool = next(t for t in tools if t.name == "primitive_tool") expected_schema = { "type": "object", - "properties": {"result": {"type": "string", "title": "Result"}}, + "properties": {"result": {"type": "string"}}, "required": ["result"], - "title": "_WrappedResult", "x-fastmcp-wrap-result": True, } assert tool.outputSchema == expected_schema @@ -1095,12 +1097,13 @@ class TestToolOutputSchema: # List tools and verify schema shows wrapped array tools = await client.list_tools() tool = next(t for t in tools if t.name == "complex_tool") - expected_inner_schema = TypeAdapter(list[dict[str, int]]).json_schema() + expected_inner_schema = compress_schema( + TypeAdapter(list[dict[str, int]]).json_schema(), prune_titles=True + ) expected_schema = { "type": "object", - "properties": {"result": {**expected_inner_schema, "title": "Result"}}, + "properties": {"result": expected_inner_schema}, "required": ["result"], - "title": "_WrappedResult", "x-fastmcp-wrap-result": True, } assert tool.outputSchema == expected_schema @@ -1129,7 +1132,9 @@ class TestToolOutputSchema: # List tools and verify schema is object type (not wrapped) tools = await client.list_tools() tool = next(t for t in tools if t.name == "dataclass_tool") - expected_schema = compress_schema(TypeAdapter(User).json_schema()) + expected_schema = compress_schema( + TypeAdapter(User).json_schema(), prune_titles=True + ) assert tool.outputSchema == expected_schema assert ( tool.outputSchema and "x-fastmcp-wrap-result" not in tool.outputSchema @@ -1743,7 +1748,7 @@ class TestResourceTemplates: with pytest.raises( ValueError, - match="Required function arguments .* must be a subset of the URI parameters", + match="Required function arguments .* must be a subset of the URI path parameters", ): @mcp.resource("resource://{name}/data") @@ -1770,7 +1775,7 @@ class TestResourceTemplates: with pytest.raises( ValueError, - match="Required function arguments .* must be a subset of the URI parameters", + match="Required function arguments .* must be a subset of the URI path parameters", ): @mcp.resource("resource://{org}/{repo}/data") @@ -1864,6 +1869,29 @@ class TestResourceTemplates: result = await client.read_resource(AnyUrl("resource://test/data")) assert result[0].text == "Template resource: test/data" # type: ignore[attr-defined] + async def test_template_with_query_params(self): + """Test RFC 6570 query parameters in resource templates.""" + mcp = FastMCP() + + @mcp.resource("data://{id}{?format,limit}") + def get_data(id: str, format: str = "json", limit: int = 10) -> str: + return f"id={id}, format={format}, limit={limit}" + + async with Client(mcp) as client: + # No query params - uses defaults + result = await client.read_resource(AnyUrl("data://123")) + assert result[0].text == "id=123, format=json, limit=10" # type: ignore[attr-defined] + + # One query param + result = await client.read_resource(AnyUrl("data://123?format=xml")) + assert result[0].text == "id=123, format=xml, limit=10" # type: ignore[attr-defined] + + # Multiple query params + result = await client.read_resource( + AnyUrl("data://123?format=csv&limit=50") + ) + assert result[0].text == "id=123, format=csv, limit=50" # type: ignore[attr-defined] + async def test_templates_match_in_order_of_definition(self): """ If a wildcard template is defined first, it will take priority over another diff --git a/tests/server/test_tool_annotations.py b/tests/server/test_tool_annotations.py index d0f03876c..cbe47465b 100644 --- a/tests/server/test_tool_annotations.py +++ b/tests/server/test_tool_annotations.py @@ -47,7 +47,7 @@ async def test_tool_annotations_in_mcp_protocol(): return message # Check via MCP protocol - mcp_tools = await mcp._mcp_list_tools() + mcp_tools = await mcp._list_tools_mcp() assert len(mcp_tools) == 1 assert mcp_tools[0].annotations is not None assert mcp_tools[0].annotations.title == "Echo Tool" diff --git a/tests/server/test_tool_transformation.py b/tests/server/test_tool_transformation.py index 977d4abab..a35289fc1 100644 --- a/tests/server/test_tool_transformation.py +++ b/tests/server/test_tool_transformation.py @@ -29,14 +29,14 @@ async def test_transformed_tool_filtering(): """Echo back the message provided.""" return message - tools = list(await mcp._list_tools()) + tools = list(await mcp._list_tools_middleware()) assert len(tools) == 0 mcp.add_tool_transformation( "echo", ToolTransformConfig(name="echo_transformed", tags={"enabled_tools"}) ) - tools = list(await mcp._list_tools()) + tools = list(await mcp._list_tools_middleware()) assert len(tools) == 1 diff --git a/tests/test_mcp_config.py b/tests/test_mcp_config.py index bd2cf6280..d0cac215e 100644 --- a/tests/test_mcp_config.py +++ b/tests/test_mcp_config.py @@ -345,12 +345,12 @@ async def test_multi_client_lifespan(tmp_path: Path): with pytest.raises(psutil.NoSuchProcess): while True: psutil.Process(pid_1) - await asyncio.sleep(0.1) + await asyncio.sleep(0.01) with pytest.raises(psutil.NoSuchProcess): while True: psutil.Process(pid_2) - await asyncio.sleep(0.1) + await asyncio.sleep(0.01) @pytest.mark.skipif( diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index 2746d369d..dc815b5bf 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -40,16 +40,15 @@ class TestToolFromFunction: "enabled": True, "parameters": { "properties": { - "a": {"title": "A", "type": "integer"}, - "b": {"title": "B", "type": "integer"}, + "a": {"type": "integer"}, + "b": {"type": "integer"}, }, "required": ["a", "b"], "type": "object", }, "output_schema": { - "properties": {"result": {"title": "Result", "type": "integer"}}, + "properties": {"result": {"type": "integer"}}, "required": ["result"], - "title": "_WrappedResult", "type": "object", "x-fastmcp-wrap-result": True, }, @@ -90,14 +89,13 @@ class TestToolFromFunction: "tags": set(), "enabled": True, "parameters": { - "properties": {"url": {"title": "Url", "type": "string"}}, + "properties": {"url": {"type": "string"}}, "required": ["url"], "type": "object", }, "output_schema": { - "properties": {"result": {"title": "Result", "type": "string"}}, + "properties": {"result": {"type": "string"}}, "required": ["result"], - "title": "_WrappedResult", "type": "object", "x-fastmcp-wrap-result": True, }, @@ -123,16 +121,15 @@ class TestToolFromFunction: "enabled": True, "parameters": { "properties": { - "x": {"title": "X", "type": "integer"}, - "y": {"title": "Y", "type": "integer"}, + "x": {"type": "integer"}, + "y": {"type": "integer"}, }, "required": ["x", "y"], "type": "object", }, "output_schema": { - "properties": {"result": {"title": "Result", "type": "integer"}}, + "properties": {"result": {"type": "integer"}}, "required": ["result"], - "title": "_WrappedResult", "type": "object", "x-fastmcp-wrap-result": True, }, @@ -157,16 +154,15 @@ class TestToolFromFunction: "enabled": True, "parameters": { "properties": { - "x": {"title": "X", "type": "integer"}, - "y": {"title": "Y", "type": "integer"}, + "x": {"type": "integer"}, + "y": {"type": "integer"}, }, "required": ["x", "y"], "type": "object", }, "output_schema": { - "properties": {"result": {"title": "Result", "type": "integer"}}, + "properties": {"result": {"type": "integer"}}, "required": ["result"], - "title": "_WrappedResult", "type": "object", "x-fastmcp-wrap-result": True, }, @@ -196,17 +192,16 @@ class TestToolFromFunction: "$defs": { "UserInput": { "properties": { - "name": {"title": "Name", "type": "string"}, - "age": {"title": "Age", "type": "integer"}, + "name": {"type": "string"}, + "age": {"type": "integer"}, }, "required": ["name", "age"], - "title": "UserInput", "type": "object", } }, "properties": { - "user": {"$ref": "#/$defs/UserInput", "title": "User"}, - "flag": {"title": "Flag", "type": "boolean"}, + "user": {"$ref": "#/$defs/UserInput"}, + "flag": {"type": "boolean"}, }, "required": ["user", "flag"], "type": "object", @@ -300,8 +295,8 @@ class TestToolFromFunction: "enabled": True, "parameters": { "properties": { - "_a": {"title": "A", "type": "integer"}, - "_b": {"title": "B", "type": "integer"}, + "_a": {"type": "integer"}, + "_b": {"type": "integer"}, }, "required": ["_a", "_b"], "type": "object", @@ -348,16 +343,15 @@ class TestToolFromFunction: "enabled": True, "parameters": { "properties": { - "x": {"title": "X", "type": "integer"}, - "y": {"title": "Y", "type": "integer"}, + "x": {"type": "integer"}, + "y": {"type": "integer"}, }, "required": ["x", "y"], "type": "object", }, "output_schema": { - "properties": {"result": {"title": "Result", "type": "integer"}}, + "properties": {"result": {"type": "integer"}}, "required": ["result"], - "title": "_WrappedResult", "type": "object", "x-fastmcp-wrap-result": True, }, @@ -467,9 +461,8 @@ class TestToolFromFunctionOutputSchema: # Non-object types get wrapped expected_schema = { "type": "object", - "properties": {"result": {**base_schema, "title": "Result"}}, + "properties": {"result": base_schema}, "required": ["result"], - "title": "_WrappedResult", "x-fastmcp-wrap-result": True, } assert tool.output_schema == expected_schema @@ -495,9 +488,8 @@ class TestToolFromFunctionOutputSchema: base_schema = TypeAdapter(annotation).json_schema() expected_schema = { "type": "object", - "properties": {"result": {**base_schema, "title": "Result"}}, + "properties": {"result": base_schema}, "required": ["result"], - "title": "_WrappedResult", "x-fastmcp-wrap-result": True, } assert tool.output_schema == expected_schema @@ -545,7 +537,9 @@ class TestToolFromFunctionOutputSchema: return Person(name="John", age=30) tool = Tool.from_function(func) - expected_schema = compress_schema(TypeAdapter(Person).json_schema()) + expected_schema = compress_schema( + TypeAdapter(Person).json_schema(), prune_titles=True + ) assert tool.output_schema == expected_schema async def test_base_model_return_annotation(self): @@ -561,11 +555,10 @@ class TestToolFromFunctionOutputSchema: assert tool.output_schema == snapshot( { "properties": { - "name": {"title": "Name", "type": "string"}, - "age": {"title": "Age", "type": "integer"}, + "name": {"type": "string"}, + "age": {"type": "integer"}, }, "required": ["name", "age"], - "title": "Person", "type": "object", } ) @@ -582,11 +575,10 @@ class TestToolFromFunctionOutputSchema: assert tool.output_schema == snapshot( { "properties": { - "name": {"title": "Name", "type": "string"}, - "age": {"title": "Age", "type": "integer"}, + "name": {"type": "string"}, + "age": {"type": "integer"}, }, "required": ["name", "age"], - "title": "Person", "type": "object", } ) @@ -766,9 +758,8 @@ class TestToolFromFunctionOutputSchema: tool = Tool.from_function(func) assert tool.output_schema == snapshot( { - "properties": {"result": {"title": "Result", "type": "integer"}}, + "properties": {"result": {"type": "integer"}}, "required": ["result"], - "title": "_WrappedResult", "type": "object", "x-fastmcp-wrap-result": True, } @@ -839,9 +830,8 @@ class TestToolFromFunctionOutputSchema: tool = Tool.from_function(func) assert tool.output_schema == snapshot( { - "properties": {"result": {"title": "Result", "type": "string"}}, + "properties": {"result": {"type": "string"}}, "required": ["result"], - "title": "_WrappedResult", "type": "object", "x-fastmcp-wrap-result": True, } @@ -1097,6 +1087,9 @@ class TestConvertResultToContent: converted = _convert_to_content(result) assert converted == expected + converted = _convert_to_content([result, result]) + assert converted == expected * 2 + def test_convert_mixed_content(self): result = [ "hello", @@ -1405,8 +1398,8 @@ class TestAutomaticStructuredContent: async with Client(mcp) as client: result = await client.call_tool("get_profile", {"user_id": "456"}) - # Client should deserialize back to a dataclass (type name preserved with new compression) - assert result.data.__class__.__name__ == "UserProfile" + # Client should deserialize back to a dataclass (but type name is lost with title pruning) + assert result.data.__class__.__name__ == "Root" assert result.data.name == "Bob" assert result.data.age == 25 assert result.data.verified is True diff --git a/tests/tools/test_tool_transform.py b/tests/tools/test_tool_transform.py index d01cf666d..565e16ad3 100644 --- a/tests/tools/test_tool_transform.py +++ b/tests/tools/test_tool_transform.py @@ -1084,9 +1084,8 @@ class TestTransformToolOutputSchema: # Should inherit parent's wrapped string schema expected_schema = { "type": "object", - "properties": {"result": {"type": "string", "title": "Result"}}, + "properties": {"result": {"type": "string"}}, "required": ["result"], - "title": "_WrappedResult", "x-fastmcp-wrap-result": True, } assert new_tool.output_schema == expected_schema @@ -1145,9 +1144,8 @@ class TestTransformToolOutputSchema: # Should infer string schema from custom function and wrap it expected_schema = { "type": "object", - "properties": {"result": {"type": "string", "title": "Result"}}, + "properties": {"result": {"type": "string"}}, "required": ["result"], - "title": "_WrappedResult", "x-fastmcp-wrap-result": True, } assert new_tool.output_schema == expected_schema @@ -1574,15 +1572,12 @@ class TestInputSchema: assert transformed_tool.parameters == snapshot( { "type": "object", - "properties": { - "used_param": {"$ref": "#/$defs/UsedType", "title": "Used Param"} - }, + "properties": {"used_param": {"$ref": "#/$defs/UsedType"}}, "required": ["used_param"], "$defs": { "UsedType": { - "properties": {"value": {"title": "Value", "type": "string"}}, + "properties": {"value": {"type": "string"}}, "required": ["value"], - "title": "UsedType", "type": "object", } }, @@ -1615,18 +1610,12 @@ class TestInputSchema: assert transformed.parameters == snapshot( { "type": "object", - "properties": { - "renamed_input": { - "$ref": "#/$defs/InputType", - "title": "Input Data", - } - }, + "properties": {"renamed_input": {"$ref": "#/$defs/InputType"}}, "required": ["renamed_input"], "$defs": { "InputType": { - "properties": {"data": {"title": "Data", "type": "string"}}, + "properties": {"data": {"type": "string"}}, "required": ["data"], - "title": "InputType", "type": "object", } }, @@ -1659,21 +1648,19 @@ class TestInputSchema: { "type": "object", "properties": { - "param_a": {"$ref": "#/$defs/TypeA", "title": "Param A"}, - "param_b": {"$ref": "#/$defs/TypeB", "title": "Param B"}, + "param_a": {"$ref": "#/$defs/TypeA"}, + "param_b": {"$ref": "#/$defs/TypeB"}, }, "required": IsList("param_b", "param_a", check_order=False), "$defs": { "TypeA": { - "properties": {"a": {"title": "A", "type": "string"}}, + "properties": {"a": {"type": "string"}}, "required": ["a"], - "title": "TypeA", "type": "object", }, "TypeB": { - "properties": {"b": {"title": "B", "type": "integer"}}, + "properties": {"b": {"type": "integer"}}, "required": ["b"], - "title": "TypeB", "type": "object", }, }, @@ -1693,15 +1680,12 @@ class TestInputSchema: assert transform2.parameters == snapshot( { "type": "object", - "properties": { - "param_a": {"$ref": "#/$defs/TypeA", "title": "Param A"} - }, + "properties": {"param_a": {"$ref": "#/$defs/TypeA"}}, "required": ["param_a"], "$defs": { "TypeA": { - "properties": {"a": {"title": "A", "type": "string"}}, + "properties": {"a": {"type": "string"}}, "required": ["a"], - "title": "TypeA", "type": "object", } }, diff --git a/tests/utilities/test_cli.py b/tests/utilities/test_cli.py index a14aab2f5..f463c2913 100644 --- a/tests/utilities/test_cli.py +++ b/tests/utilities/test_cli.py @@ -1,5 +1,7 @@ """Tests for CLI utility functions.""" +from pathlib import Path + from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment @@ -16,14 +18,14 @@ class TestEnvironmentBuildUVRunCommand: def test_build_uv_run_command_with_editable(self): """Test building uv command with editable package.""" - editable_path = "/path/to/package" + editable_path = Path("/path/to/package") env = UVEnvironment(editable=[editable_path]) cmd = env.build_command(["fastmcp", "run", "server.py"]) expected = [ "uv", "run", "--with-editable", - editable_path, + str(editable_path.resolve()), "fastmcp", "run", "server.py", @@ -64,14 +66,14 @@ class TestEnvironmentBuildUVRunCommand: def test_build_uv_run_command_with_requirements(self): """Test building uv command with requirements file.""" - requirements_path = "/path/to/requirements.txt" + requirements_path = Path("/path/to/requirements.txt") env = UVEnvironment(requirements=requirements_path) cmd = env.build_command(["fastmcp", "run", "server.py"]) expected = [ "uv", "run", "--with-requirements", - requirements_path, + str(requirements_path.resolve()), "fastmcp", "run", "server.py", @@ -80,14 +82,14 @@ class TestEnvironmentBuildUVRunCommand: def test_build_uv_run_command_with_project(self): """Test building uv command with project directory.""" - project_path = "/path/to/project" + project_path = Path("/path/to/project") env = UVEnvironment(project=project_path) cmd = env.build_command(["fastmcp", "run", "server.py"]) expected = [ "uv", "run", "--project", - project_path, + str(project_path.resolve()), "fastmcp", "run", "server.py", @@ -96,8 +98,8 @@ class TestEnvironmentBuildUVRunCommand: def test_build_uv_run_command_with_everything(self): """Test building uv command with all options.""" - requirements_path = "/path/to/requirements.txt" - editable_path = "/local/pkg" + requirements_path = Path("/path/to/requirements.txt") + editable_path = Path("/local/pkg") env = UVEnvironment( python="3.10", dependencies=["pandas", "numpy"], @@ -115,9 +117,9 @@ class TestEnvironmentBuildUVRunCommand: "--with", "pandas", "--with-requirements", - requirements_path, + str(requirements_path.resolve()), "--with-editable", - editable_path, + str(editable_path.resolve()), "fastmcp", "run", "server.py", @@ -129,23 +131,24 @@ class TestEnvironmentBuildUVRunCommand: def test_build_uv_run_command_project_with_extras(self): """Test that project flag works with additional dependencies.""" - project_path = "/path/to/project" + project_path = Path("/path/to/project") + editable_path = Path("/pkg") env = UVEnvironment( project=project_path, python="3.10", # Should be ignored with project dependencies=["pandas"], # Should be added on top of project - editable=["/pkg"], # Should be added on top of project + editable=[editable_path], # Should be added on top of project ) cmd = env.build_command(["fastmcp", "run", "server.py"]) expected = [ "uv", "run", "--project", - project_path, + str(project_path.resolve()), "--with", "pandas", "--with-editable", - "/pkg", + str(editable_path.resolve()), "fastmcp", "run", "server.py", @@ -168,17 +171,17 @@ class TestEnvironmentNeedsUV: def test_needs_uv_with_requirements(self): """Test that needs_uv returns True with requirements.""" - env = UVEnvironment(requirements="/path/to/requirements.txt") + env = UVEnvironment(requirements=Path("/path/to/requirements.txt")) assert env._must_run_with_uv() is True def test_needs_uv_with_project(self): """Test that needs_uv returns True with project.""" - env = UVEnvironment(project="/path/to/project") + env = UVEnvironment(project=Path("/path/to/project")) assert env._must_run_with_uv() is True def test_needs_uv_with_editable(self): """Test that needs_uv returns True with editable.""" - env = UVEnvironment(editable=["/pkg"]) + env = UVEnvironment(editable=[Path("/pkg")]) assert env._must_run_with_uv() is True def test_needs_uv_empty(self): diff --git a/tests/utilities/test_inspect.py b/tests/utilities/test_inspect.py index e75439ea9..420335c82 100644 --- a/tests/utilities/test_inspect.py +++ b/tests/utilities/test_inspect.py @@ -90,7 +90,7 @@ class TestGetFastMCPInfo: assert info.fastmcp_version == fastmcp.__version__ assert info.mcp_version == importlib.metadata.version("mcp") assert info.server_generation == 2 # v2 server - assert info.version is None + assert info.version == fastmcp.__version__ assert info.tools == [] assert info.prompts == [] assert info.resources == [] @@ -405,7 +405,7 @@ class TestFastMCP1xCompatibility: assert info1x.server_generation == 1 # v1 assert info2x.server_generation == 2 # v2 assert info1x.version is None - assert info2x.version is None + assert info2x.version == fastmcp.__version__ # No templates added in these tests assert len(info1x.templates) == 0 diff --git a/tests/utilities/test_json_schema.py b/tests/utilities/test_json_schema.py index 65ba9b2e3..5fce3be68 100644 --- a/tests/utilities/test_json_schema.py +++ b/tests/utilities/test_json_schema.py @@ -433,3 +433,75 @@ class TestCompressSchema: "additionalProperties" not in result["properties"]["foo"]["properties"]["nested"] ) + + def test_title_pruning_preserves_parameter_named_title(self): + """Test that a parameter named 'title' is not removed during title pruning. + + This is a critical edge case - we want to remove title metadata but preserve + actual parameters that happen to be named 'title'. + """ + from typing import Annotated + + from pydantic import Field, TypeAdapter + + def greet( + name: Annotated[str, Field(description="The name to greet")], + title: Annotated[str, Field(description="Optional title", default="")], + ) -> str: + """A greeting function.""" + return f"Hello {title} {name}" + + adapter = TypeAdapter(greet) + schema = adapter.json_schema() + + # Compress with title pruning + compressed = compress_schema(schema, prune_titles=True) + + # The 'title' parameter should be preserved + assert "title" in compressed["properties"] + assert compressed["properties"]["title"]["description"] == "Optional title" + assert compressed["properties"]["title"]["default"] == "" + + # But title metadata should be removed + assert "title" not in compressed["properties"]["name"] + assert "title" not in compressed["properties"]["title"] + + def test_title_pruning_with_nested_properties(self): + """Test that nested property structures are handled correctly.""" + schema = { + "type": "object", + "title": "OuterObject", + "properties": { + "title": { # This is a property named "title", not metadata + "type": "object", + "title": "TitleObject", # This is metadata + "properties": { + "subtitle": { + "type": "string", + "title": "SubTitle", # This is metadata + } + }, + }, + "normal_field": { + "type": "string", + "title": "NormalField", # This is metadata + }, + }, + } + + compressed = compress_schema(schema, prune_titles=True) + + # Root title should be removed + assert "title" not in compressed + + # The property named "title" should be preserved + assert "title" in compressed["properties"] + + # But its metadata title should be removed + assert "title" not in compressed["properties"]["title"] + + # Nested metadata titles should be removed + assert ( + "title" not in compressed["properties"]["title"]["properties"]["subtitle"] + ) + assert "title" not in compressed["properties"]["normal_field"] diff --git a/tests/utilities/test_logging.py b/tests/utilities/test_logging.py index 30ea963e9..e6d8086fc 100644 --- a/tests/utilities/test_logging.py +++ b/tests/utilities/test_logging.py @@ -5,14 +5,14 @@ from fastmcp.utilities.logging import get_logger def test_logging_doesnt_affect_other_loggers(caplog): # set FastMCP loggers to CRITICAL and ensure other loggers still emit messages - original_level = logging.getLogger("FastMCP").getEffectiveLevel() + original_level = logging.getLogger("fastmcp").getEffectiveLevel() try: - logging.getLogger("FastMCP").setLevel(logging.CRITICAL) + logging.getLogger("fastmcp").setLevel(logging.CRITICAL) root_logger = logging.getLogger() app_logger = logging.getLogger("app") - fastmcp_logger = logging.getLogger("FastMCP") + fastmcp_logger = logging.getLogger("fastmcp") fastmcp_server_logger = get_logger("server") with caplog.at_level(logging.INFO): @@ -27,4 +27,4 @@ def test_logging_doesnt_affect_other_loggers(caplog): assert "--FASTMCP SERVER--" not in caplog.text finally: - logging.getLogger("FastMCP").setLevel(original_level) + logging.getLogger("fastmcp").setLevel(original_level) diff --git a/uv.lock b/uv.lock index 92afacc39..bdb524bac 100644 --- a/uv.lock +++ b/uv.lock @@ -688,10 +688,12 @@ dependencies = [ { name = "mcp" }, { name = "openapi-core" }, { name = "openapi-pydantic" }, + { name = "py-key-value-aio", extra = ["disk", "memory"] }, { name = "pydantic", extra = ["email"] }, { name = "pyperclip" }, { name = "python-dotenv" }, { name = "rich" }, + { name = "websockets" }, ] [package.optional-dependencies] @@ -708,9 +710,6 @@ contrib-middleware-elasticsearch-cache = [ openai = [ { name = "openai" }, ] -websockets = [ - { name = "websockets" }, -] [package.dev-dependencies] dev = [ @@ -733,6 +732,7 @@ dev = [ { name = "pytest-flakefinder" }, { name = "pytest-httpx" }, { name = "pytest-report" }, + { name = "pytest-retry" }, { name = "pytest-timeout" }, { name = "pytest-xdist" }, { name = "ruff" }, @@ -755,13 +755,14 @@ requires-dist = [ { name = "openai", marker = "extra == 'openai'", specifier = ">=1.102.0" }, { name = "openapi-core", specifier = ">=0.19.5" }, { name = "openapi-pydantic", specifier = ">=0.5.1" }, + { name = "py-key-value-aio", extras = ["disk", "memory"], specifier = ">=0.2.1" }, { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" }, { name = "pyperclip", specifier = ">=1.9.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, { name = "rich", specifier = ">=13.9.4" }, - { name = "websockets", marker = "extra == 'websockets'", specifier = ">=15.0.1" }, + { name = "websockets", specifier = ">=15.0.1" }, ] -provides-extras = ["caching", "contrib-middleware-elasticsearch-cache", "openai", "websockets"] +provides-extras = ["openai"] [package.metadata.requires-dev] dev = [ @@ -785,6 +786,7 @@ dev = [ { name = "pytest-flakefinder" }, { name = "pytest-httpx", specifier = ">=0.35.0" }, { name = "pytest-report", specifier = ">=0.2.1" }, + { name = "pytest-retry", specifier = ">=1.7.0" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "ruff" }, @@ -1553,6 +1555,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, ] +[[package]] +name = "pathvalidate" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, +] + [[package]] name = "pdbpp" version = "0.11.7" @@ -1755,6 +1766,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, ] +[[package]] +name = "py-key-value-aio" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-key-value-shared" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/bf/7237a1d41b4afc33a8c0f71c991d95a6bb6719cd5ccab8d1628b72fbe03c/py_key_value_aio-0.2.1.tar.gz", hash = "sha256:79c8c835451b61d4abd863c65d33870612f3a80dc312120b2d1445269764d625", size = 19440, upload-time = "2025-10-09T03:26:28.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/82/41b5574270fbed7171d34a9b7c9b1b18fd86c31421e45dc935ba354eb42f/py_key_value_aio-0.2.1-py3-none-any.whl", hash = "sha256:5f0bc1bb3f886578a88ed2b61858658142db35c59dd3ccd9ec727184c540288a", size = 41564, upload-time = "2025-10-09T03:26:26.174Z" }, +] + +[package.optional-dependencies] +disk = [ + { name = "diskcache" }, + { name = "pathvalidate" }, +] +memory = [ + { name = "cachetools" }, +] + +[[package]] +name = "py-key-value-shared" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/09/7c76aa82e5e41c6ad5e0e43bcc2072b48d84e03439dbb25b3e184773b553/py_key_value_shared-0.2.0.tar.gz", hash = "sha256:ee6d9a9101b54f228876c61b2f2f83a951c9c52233d8271599532c069fa26052", size = 6285, upload-time = "2025-09-29T02:27:46.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f8/6c6cf5abcb78d103006ea1bec6137c9859611ffc50093684b5130c5642c1/py_key_value_shared-0.2.0-py3-none-any.whl", hash = "sha256:84cb4f6b6bed97a32feebc512ce1e333097ce5768c7198abcd7d4bd3c5f1de06", size = 10437, upload-time = "2025-09-29T02:27:45.281Z" }, +] + [[package]] name = "pycparser" version = "2.22" @@ -2079,6 +2123,18 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/3b/82/e141da085de0b6dac3f047ae009e136bcedbcfca4ada082a55359d6f735e/pytest-report-0.2.1.tar.gz", hash = "sha256:d382e8db4c52a815d39dae5f21ee5edc0da3ae8ec19a22e55e9be5c60714a39d", size = 3517, upload-time = "2016-05-11T02:08:04.665Z" } +[[package]] +name = "pytest-retry" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/5b/607b017994cca28de3a1ad22a3eee8418e5d428dcd8ec25b26b18e995a73/pytest_retry-1.7.0.tar.gz", hash = "sha256:f8d52339f01e949df47c11ba9ee8d5b362f5824dff580d3870ec9ae0057df80f", size = 19977, upload-time = "2025-01-19T01:56:13.115Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/ff/3266c8a73b9b93c4b14160a7e2b31d1e1088e28ed29f4c2d93ae34093bfd/pytest_retry-1.7.0-py3-none-any.whl", hash = "sha256:a2dac85b79a4e2375943f1429479c65beb6c69553e7dae6b8332be47a60954f4", size = 13775, upload-time = "2025-01-19T01:56:11.199Z" }, +] + [[package]] name = "pytest-timeout" version = "2.4.0" @@ -2571,11 +2627,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.14.1" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]]